From 5db84956adaa8dafb9c26bea147c37cfb44e4110 Mon Sep 17 00:00:00 2001 From: Bertrand Martin Date: Wed, 29 Jul 2026 00:03:42 +0200 Subject: [PATCH 01/17] Command stdin and interactive shell: stdin() builders, RemoteProcess.stdin(), CLI shell subcommand (#136) Phase 1 - stdin for commands (library): - New WSMan Send envelope (rsp:Send, base64 stdin stream, End="true" on the final chunk) and a send(byte[], end) / interrupt() seam on RemoteCommand and CommandCursor; input larger than one envelope is chunked automatically. - stdin(String|Path|InputStream) on CommandRequest: pre-supplied input, delivered in full right after startup and ending with the End mark; it switches the remote stdin to pipe semantics (WINRS_CONSOLEMODE_STDIN=FALSE) so filters like sort see EOF. Works with execute() and start(). - RemoteProcess.stdin() completes the java.lang.Process shape: flush() emits one WSMan Send, close() marks the end of input. RemoteProcess.interrupt() sends the new ctrl_c Signal (a console Ctrl+C that leaves the session alive), and RemoteProcess.poll(Duration) exposes one bounded round trip. - Cleanup discipline from #134 carries over: closing stdin after completion or an early close is a silent local no-op - no stray requests, no revived transport, no exit code hidden behind cleanup failures. Phase 2 - interactive shell subcommand (CLI): - winrm-java ... shell starts cmd.exe remotely and bridges it to the local terminal until it exits: a single-threaded pump (drain queued local input -> Send -> bounded poll -> forward output) with one helper thread reading local stdin, line-oriented like winrs. Local EOF sends End="true"; Ctrl+C is rerouted (sun.misc.Signal, reflective with graceful fallback) to the WSMan ctrl_c Signal; the remote exit code uses the CLI exit-code contract. - command forwards piped/redirected local stdin as the remote command's stdin: winrm-java ... command sort < data.txt just works. Bounded-poll fix exposed by the pump (latent from #134): the WSMan service clamps OperationTimeout below 500 ms up to 500 ms (measured on Windows Server 2008 R2), so a wire poll shorter than the floor plus transit slack always lost the race between the "nothing yet" fault and its own socket cut. Wire polls now require a 750 ms budget (shorter waits are waited out locally) and never ask the server for less than the 500 ms floor. FakeWsmanServer learns Send (decodes and records stdin chunks with their End flag); new CommandStdinTest and InteractiveShellTest cover content, chunking, End, console-mode option on the wire, a scripted REPL round trip, Ctrl+C, exit-code propagation, and the cleanup invariants. Verified live against a real Windows Server 2008 R2 host (piped sort and a full shell session). Closes #136 Co-Authored-By: Claude Fable 5 --- README.md | 26 +- .../org/metricshub/winrm/CommandCursor.java | 37 ++ .../org/metricshub/winrm/CommandRequest.java | 121 +++++- .../org/metricshub/winrm/RemoteProcess.java | 199 +++++++++- .../winrm/WindowsRemoteExecutor.java | 34 ++ .../metricshub/winrm/cli/CliArguments.java | 18 +- .../winrm/cli/InteractiveShell.java | 269 +++++++++++++ .../org/metricshub/winrm/cli/WinRmCli.java | 195 ++++++++- .../org/metricshub/winrm/light/Envelopes.java | 81 +++- .../winrm/light/LightWinRMService.java | 26 +- .../metricshub/winrm/light/WsmanClient.java | 106 ++++- src/site/markdown/cli.md | 46 ++- src/site/markdown/commands.md | 56 ++- .../metricshub/winrm/CommandStdinTest.java | 369 ++++++++++++++++++ .../metricshub/winrm/StreamingApiTest.java | 11 +- .../winrm/cli/CliArgumentsTest.java | 2 +- .../winrm/cli/InteractiveShellTest.java | 299 ++++++++++++++ .../metricshub/winrm/cli/WinRmCliTest.java | 96 +++++ .../winrm/light/FakeWsmanResponses.java | 9 + .../winrm/light/FakeWsmanServer.java | 51 +++ 20 files changed, 1994 insertions(+), 57 deletions(-) create mode 100644 src/main/java/org/metricshub/winrm/cli/InteractiveShell.java create mode 100644 src/test/java/org/metricshub/winrm/CommandStdinTest.java create mode 100644 src/test/java/org/metricshub/winrm/cli/InteractiveShellTest.java diff --git a/README.md b/README.md index c52b372..91a1f48 100644 --- a/README.md +++ b/README.md @@ -126,6 +126,20 @@ try (RemoteProcess p = client.command("wevtutil qe System /f:text").start()) { int exitCode = p.waitFor(); // or waitFor(Duration) for a deadline } +// Commands can be fed standard input — pre-supplied (stdin(String|Path|InputStream), +// the remote equivalent of a `< file` redirection)... +CommandResult sorted = client.command("sort").stdin(Path.of("data.txt")).execute(); + +// ...or written interactively through the process handle (flush() delivers, close() is EOF): +try (RemoteProcess p = client.command("some-repl.exe").start()) { + try (BufferedWriter in = p.stdin()) { + in.write("first request\n"); + in.flush(); + System.out.println(p.stdout().readLine()); + } + p.waitFor(); +} + // Middle ground: tail the output live, keep the blocking terminal and its full result. client.command("longRunningThing.exe") .onStdout(chunk -> log.info(chunk)) @@ -197,11 +211,19 @@ java -jar target/winrm-java--standalone.jar \ exec ipconfig /all ``` +Open an interactive session on the remote host (`winrs`-style, line-oriented): + +```bash +java -jar target/winrm-java--standalone.jar \ + -h server.example.net -u 'DOMAIN\user' -pf password.txt shell +``` + Use `--help` for the option list and `--version` for the build version. The CLI is built on the streaming API: WQL rows are written to stdout as UTF-8 [JSON Lines](https://jsonlines.org/) **as the enumeration pages arrive**, and remote command stdout and stderr are forwarded **live** to the -corresponding local streams while the command runs. Diagnostics go only to stderr, and the exit -codes are stable for scripting. +corresponding local streams while the command runs — with local stdin forwarded to the remote +command when it is piped or redirected (`... command sort < data.txt`). Diagnostics go only to +stderr, and the exit codes are stable for scripting. The full manual — options, password handling, Kerberos configuration, streaming and timeout semantics, exit codes — is the diff --git a/src/main/java/org/metricshub/winrm/CommandCursor.java b/src/main/java/org/metricshub/winrm/CommandCursor.java index 6445d88..f00613a 100644 --- a/src/main/java/org/metricshub/winrm/CommandCursor.java +++ b/src/main/java/org/metricshub/winrm/CommandCursor.java @@ -71,6 +71,43 @@ default Chunk poll(final long maxWaitMillis) throws TimeoutException, WindowsRem return next(); } + /** + * Feed standard input to the running command — the WSMan Send operation, carrying the bytes to + * the command's {@code stdin} stream. Input larger than one envelope's worth is split into + * several Send requests automatically. A Send is an ordinary request on the executor's serial + * connection: it alternates with {@link #next()}/{@link #poll(long)} on the caller's thread, + * it never runs concurrently with them. + *

+ * The default implementation throws {@link UnsupportedOperationException}: only executors that + * support command input (such as the built-in lightweight backend) implement this method. + * + * @param data the input bytes (possibly empty — with {@code end}, a pure end-of-input Send) + * @param end {@code true} to mark the end of input: the command's stdin then reaches EOF, and + * no further input may be sent + * @throws IllegalStateException when the command has already completed or the cursor is closed + * @throws TimeoutException when the server does not answer the Send in time + * @throws WindowsRemoteException for any other failure while sending + */ + default void send(final byte[] data, final boolean end) throws TimeoutException, WindowsRemoteException { + throw new UnsupportedOperationException(getClass().getName() + " does not support command input."); + } + + /** + * Interrupt the command the way a console Ctrl+C would — the WSMan Signal operation with the + * {@code ctrl_c} code. Unlike {@link #close()}'s terminate Signal, it interrupts the command's + * child process without ending the command itself: the cursor stays fully usable. A no-op once + * the command has completed or the cursor is closed. + *

+ * The default implementation throws {@link UnsupportedOperationException}: only executors that + * support it (such as the built-in lightweight backend) implement this method. + * + * @throws TimeoutException when the server does not answer the Signal in time + * @throws WindowsRemoteException for any other failure while signaling + */ + default void interrupt() throws TimeoutException, WindowsRemoteException { + throw new UnsupportedOperationException(getClass().getName() + " does not support command interruption."); + } + /** * Get the command's exit code. * diff --git a/src/main/java/org/metricshub/winrm/CommandRequest.java b/src/main/java/org/metricshub/winrm/CommandRequest.java index 6947515..457831c 100644 --- a/src/main/java/org/metricshub/winrm/CommandRequest.java +++ b/src/main/java/org/metricshub/winrm/CommandRequest.java @@ -20,8 +20,11 @@ * ╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱ */ +import java.io.ByteArrayInputStream; import java.io.IOException; +import java.io.InputStream; import java.nio.charset.Charset; +import java.nio.file.Files; import java.nio.file.Path; import java.time.Duration; import java.util.ArrayList; @@ -42,6 +45,9 @@ */ public final class CommandRequest { + /** How many bytes of pre-supplied input are read and sent at a time. */ + private static final int STDIN_BUFFER_SIZE = 64 * 1024; + private final WinRMClient client; private final String commandLine; private String workingDirectory; @@ -50,6 +56,13 @@ public final class CommandRequest { private final List uploads = new ArrayList<>(); private Consumer stdoutConsumer; private Consumer stderrConsumer; + private StdinSource stdinSource; + + /** A deferred source of pre-supplied standard input, opened when the command starts. */ + @FunctionalInterface + private interface StdinSource { + InputStream open(Charset charset) throws IOException; + } /** * Create the request. @@ -134,6 +147,64 @@ public CommandRequest upload(final Path... files) { return this; } + /** + * 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. + *

+ * Supplying input switches the remote stdin to pipe semantics + * ({@code WINRS_CONSOLEMODE_STDIN=FALSE}): tools like {@code sort} or {@code findstr} consume + * it and terminate on EOF, exactly as with a local {@code <} redirection. + * + *

{@code
+	 * CommandResult result = client.command("sort").stdin("beta\nalpha\n").execute();
+	 * }
+ * + * Works with both {@link #execute()} and {@link #start()}; with {@code start()}, the + * {@link RemoteProcess#stdin()} writer is closed from the start — pre-supplied and interactive + * input are mutually exclusive. The input is delivered before any output is read: feeding a + * large input to a command that floods its output in the meantime can deadlock both sides, + * exactly like the {@link java.lang.Process} pipes — the caller's to avoid. + * + * @param text the input text + * @return this request + */ + public CommandRequest stdin(final String text) { + Utils.checkNonNull(text, "text"); + this.stdinSource = charset -> new ByteArrayInputStream(text.getBytes(charset)); + return this; + } + + /** + * Feed the content of the given local file to the command's standard input — the equivalent of + * a local {@code < file} redirection. Same contract as {@link #stdin(String)}, except the bytes + * are sent exactly as stored (no re-encoding). + * + * @param file the local file to read + * @return this request + */ + public CommandRequest stdin(final Path file) { + Utils.checkNonNull(file, "file"); + this.stdinSource = charset -> Files.newInputStream(file); + return this; + } + + /** + * Feed the given stream to the command's standard input, reading it to its end. Same contract + * as {@link #stdin(String)}, except the bytes are sent exactly as read (no re-encoding). The + * stream is consumed when the command starts and closed afterward; it is read on the thread + * driving the execution, so a stream that blocks forever stalls the command. + * + * @param stream the input stream to consume + * @return this request + */ + public CommandRequest stdin(final InputStream stream) { + Utils.checkNonNull(stream, "stream"); + this.stdinSource = charset -> stream; + return this; + } + /** * Register a callback receiving each chunk of standard output as it arrives, while * {@link #execute()} is still running — a middle ground between collecting everything and @@ -195,7 +266,7 @@ public CommandResult execute() { "No time left to execute the command" ); - if (stdoutConsumer == null && stderrConsumer == null) { + if (stdoutConsumer == null && stderrConsumer == null && stdinSource == null) { final WindowsRemoteCommandResult result = client .executor() .executeCommand(prepared.command, prepared.workingDirectory, prepared.charset, remaining); @@ -208,9 +279,10 @@ public CommandResult execute() { ); } - // Callback variant: drain the streaming cursor, delivering each chunk as it arrives. - // The same wall-clock deadline governs, enforced the way the blocking path enforces - // it — a worker runs the exchange and is cancelled when the deadline fires. + // Callback/stdin variant: drain the streaming cursor, delivering each chunk as it + // arrives (input, when supplied, is fed right after startup). The same wall-clock + // deadline governs, enforced the way the blocking path enforces it — a worker runs the + // exchange and is cancelled when the deadline fires. return Utils.execute(() -> drainWithCallbacks(prepared, remaining, start), remaining); } catch (final TimeoutException e) { throw timeoutException(e); @@ -261,8 +333,18 @@ public RemoteProcess start() { // round trip (inactivity), not the overall exchange the preparation steps count against. final CommandCursor cursor = client .executor() - .startCommand(prepared.command, prepared.workingDirectory, timeoutMillis); - return new RemoteProcess(cursor, prepared.charset, client.hostname(), timeout); + .startCommand(prepared.command, prepared.workingDirectory, timeoutMillis, stdinSource == null); + if (stdinSource != null) { + try { + feedStdin(cursor, prepared.charset); + } catch (final Exception e) { + // The input could not be delivered: terminate the command and release the client's + // connection before reporting — a failed start must not leave an open handle behind. + cursor.close(); + throw e; + } + } + return new RemoteProcess(cursor, prepared.charset, client.hostname(), timeout, stdinSource != null); } catch (final TimeoutException e) { throw timeoutException(e); } catch (final IOException e) { @@ -329,7 +411,10 @@ private CommandResult drainWithCallbacks(final Prepared prepared, final long tim final StringBuilder stderr = new StringBuilder(); try ( CommandCursor cursor = client.executor() - .startCommand(prepared.command, prepared.workingDirectory, timeoutMillis)) { + .startCommand(prepared.command, prepared.workingDirectory, timeoutMillis, stdinSource == null)) { + if (stdinSource != null) { + feedStdin(cursor, prepared.charset); + } CommandCursor.Chunk chunk; while ((chunk = cursor.next()) != null) { deliver(stdoutDecoder.decode(chunk.stdout()), stdout, stdoutConsumer); @@ -346,6 +431,28 @@ private CommandResult drainWithCallbacks(final Prepared prepared, final long tim } } + /** + * Deliver the pre-supplied input to the started command, buffer by buffer, marking the last + * one as the end of input so the remote stdin reaches EOF. The one-buffer read-ahead makes the + * final Send carry data AND the end mark together (no extra empty round trip), except for an + * empty source, whose single Send only announces the EOF. + */ + private void feedStdin(final CommandCursor cursor, final Charset charset) + throws IOException, TimeoutException, WindowsRemoteException { + try (InputStream input = stdinSource.open(charset)) { + byte[] current = input.readNBytes(STDIN_BUFFER_SIZE); + if (current.length == 0) { + cursor.send(current, true); + return; + } + while (current.length > 0) { + final byte[] next = input.readNBytes(STDIN_BUFFER_SIZE); + cursor.send(current, next.length == 0); + current = next; + } + } + } + /** Append a decoded chunk to the accumulated output and hand it to the callback, when any. */ private static void deliver(final String text, final StringBuilder accumulator, final Consumer consumer) { if (text.isEmpty()) { diff --git a/src/main/java/org/metricshub/winrm/RemoteProcess.java b/src/main/java/org/metricshub/winrm/RemoteProcess.java index a50ff41..189ed7b 100644 --- a/src/main/java/org/metricshub/winrm/RemoteProcess.java +++ b/src/main/java/org/metricshub/winrm/RemoteProcess.java @@ -20,8 +20,11 @@ * ╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱ */ +import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; import java.io.BufferedReader; +import java.io.BufferedWriter; import java.io.Reader; +import java.io.Writer; import java.nio.charset.Charset; import java.time.Duration; import java.util.concurrent.TimeoutException; @@ -61,7 +64,15 @@ * throw {@link WinRMTimeoutException} when the command stays silent for a whole timeout; use * {@link #waitFor(Duration)} for an overall deadline. *

- * Threading. A process is not thread-safe: read, wait and close from one thread at a time. + * Writing. {@link #stdin()} feeds the command's standard input: {@code flush()} carries the + * written text to the host as a WSMan Send, {@code close()} marks the end of input (the remote + * stdin then reaches EOF). Writes and reads alternate on the caller's thread, exactly like + * {@link java.lang.Process} pipes — including the classic deadlock, which is the caller's to + * avoid: blocking on a read while the remote command itself is blocked waiting for input (or the + * reverse) hangs both sides until the inactivity timeout fires. + *

+ * Threading. A process is not thread-safe: read, write, wait and close from one thread at a + * time. *

* Failures during consumption are reported through the unchecked * {@link org.metricshub.winrm.exceptions.WinRMClientException} hierarchy, including from the @@ -72,6 +83,7 @@ public final class RemoteProcess implements AutoCloseable { private final CommandCursor cursor; private final String hostname; private final Duration timeout; + private final Charset charset; private final ChunkDecoder stdoutDecoder; private final ChunkDecoder stderrDecoder; @@ -80,22 +92,39 @@ public final class RemoteProcess implements AutoCloseable { private final StringBuilder stdoutPending = new StringBuilder(); private final StringBuilder stderrPending = new StringBuilder(); + // Written input that has not been flushed to the host yet. + private final StringBuilder stdinPending = new StringBuilder(); + private final BufferedReader stdout; private final BufferedReader stderr; + private final BufferedWriter stdin; // finished = no more protocol fetches may happen: the command completed OR the process was // closed early. exitCode is non-null only when completion was actually observed. private boolean finished; private Integer exitCode; - RemoteProcess(final CommandCursor cursor, final Charset charset, final String hostname, final Duration timeout) { + // The end-of-input Send was already emitted (or the input was pre-supplied by the request): + // no further input may be sent. + private boolean stdinClosed; + + RemoteProcess( + final CommandCursor cursor, + final Charset charset, + final String hostname, + final Duration timeout, + final boolean stdinAlreadySupplied + ) { this.cursor = cursor; this.hostname = hostname; this.timeout = timeout; + this.charset = charset; this.stdoutDecoder = new ChunkDecoder(charset); this.stderrDecoder = new ChunkDecoder(charset); this.stdout = new BufferedReader(new ChannelReader(stdoutPending)); this.stderr = new BufferedReader(new ChannelReader(stderrPending)); + this.stdin = new BufferedWriter(new StdinWriter()); + this.stdinClosed = stdinAlreadySupplied; } /** @@ -120,6 +149,55 @@ public BufferedReader stderr() { return stderr; } + /** + * Get the standard input of the remote command. Written text is buffered locally until + * {@code flush()}, which carries it to the host as one WSMan Send (encoded with the request's + * charset); {@code close()} sends the final chunk flagged as the end of input, after which the + * remote stdin reaches EOF. Always the same writer instance; closing it does not close the + * process — but unlike the readers it does talk to the host, so close it before (or via) the + * process itself, not after. + *

+ * When the request pre-supplied the input ({@code stdin(...)} on the builder), that input was + * already delivered in full: this writer is then closed from the start. + *

+ * 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}. + * + * @return the standard input writer + */ + @SuppressFBWarnings(value = "EI_EXPOSE_REP", justification = "The writer IS the API: it feeds the process's standard input, exactly like " + + + "Process.getOutputStream()") + public BufferedWriter stdin() { + return stdin; + } + + /** + * Interrupt the command the way a console Ctrl+C would — the WSMan {@code ctrl_c} Signal. It + * interrupts the command's child process without terminating the command or this process + * handle: the process stays fully usable, which is what an interactive session needs. A no-op + * once the command has completed or the process is closed. + * + * @throws WinRMTimeoutException when the server does not answer the Signal in time + * @throws org.metricshub.winrm.exceptions.WinRMClientException for any other failure + */ + public synchronized void interrupt() { + if (finished) { + return; + } + try { + cursor.interrupt(); + } catch (final TimeoutException e) { + throw new WinRMTimeoutException( + String.format("The interrupt Signal was not answered within %s on %s", timeout, hostname), + e + ); + } catch (final WindowsRemoteException e) { + throw WinRMClient.translate(e); + } + } + /** * Wait for the command to complete, buffering any unread output in the meantime (read it * afterward from {@link #stdout()}/{@link #stderr()}). @@ -135,6 +213,34 @@ public synchronized int waitFor() { return exitCodeValue(); } + /** + * Advance the stream by at most one bounded protocol round trip: block at most the + * given wait for the next chunk of output, buffer whatever arrives for the readers, and report + * whether the command has completed. The wait expiring is not a failure — the server answers + * with the protocol's "nothing yet" and the process stays fully usable — so a polling consumer + * (e.g. an interactive session pump) never trips the inactivity timeout while idle. Unlike + * {@link #waitFor(Duration)}, which keeps polling until its deadline, this returns as soon as + * the server answers: output that is ready arrives (and can be read) immediately. + *

+ * A wait too short for a network round trip — the WSMan service holds a bounded Receive for at + * least 500 ms before answering "nothing yet", and the answer needs transit slack on top — is + * waited out locally without touching the wire: the stream then does not advance. Use waits of + * about a second or more to actually poll the server. + * + * @param maxWait how long to block at most (at least one millisecond) + * @return {@code true} when the command has completed — the exit code is then available from + * {@link #exitCode()} — {@code false} when it is still running + * @throws WinRMTimeoutException when the server does not answer the bounded request in time + * @throws org.metricshub.winrm.exceptions.WinRMClientException for any other failure + */ + public synchronized boolean poll(final Duration maxWait) { + WinRMClient.checkPositive(maxWait, "maxWait"); + if (!finished) { + absorb(advance(WinRMClient.toMillis(maxWait))); + } + return finished; + } + /** * Wait at most the given duration for the command to complete — an overall deadline, on top of * the per-response inactivity timeout. The remaining wait is a hard bound on the active @@ -263,6 +369,65 @@ private void absorb(final CommandCursor.Chunk chunk) { } } + /** + * Flush the buffered input to the host — one WSMan Send, flagged as the end of input when + * {@code end} is set. Holds the monitor. Ending the input is idempotent; a plain flush with + * nothing buffered is a no-op. Once the command completed (or the process was closed), ending + * the input touches nothing — the cursor already released the connection, and closing a + * writer must never turn into a stray request or a failure hiding a known exit code (input it + * still held is discarded, like a {@link java.lang.Process} pipe's) — while flushing actual + * input is refused: it can no longer be delivered. + */ + private synchronized void sendStdin(final boolean end) { + if (stdinClosed) { + if (end) { + return; + } + throw new IllegalStateException("The command's standard input has already been closed."); + } + final byte[] bytes = stdinPending.toString().getBytes(charset); + stdinPending.setLength(0); + if (finished) { + if (end) { + stdinClosed = true; + return; + } + if (bytes.length == 0) { + return; + } + throw new IllegalStateException("The command has completed: its standard input is closed."); + } + if (bytes.length == 0 && !end) { + return; + } + if (end) { + stdinClosed = true; + } + try { + cursor.send(bytes, end); + } catch (final TimeoutException e) { + throw new WinRMTimeoutException( + String.format("The command input was not accepted within %s on %s", timeout, hostname), + e + ); + } catch (final WindowsRemoteException e) { + throw WinRMClient.translate(e); + } + } + + /** Buffer written input until the next flush. Holds the monitor. */ + private synchronized void bufferStdin(final char[] cbuf, final int off, final int len) { + if (stdinClosed) { + throw new IllegalStateException("The command's standard input has already been closed."); + } + stdinPending.append(cbuf, off, len); + } + + /** Whether the channel's buffer holds decoded output ready to be read without a round trip. */ + private synchronized boolean hasPending(final StringBuilder pending) { + return pending.length() > 0; + } + /** Serve a read from the channel's buffer, advancing the Receive loop while it is empty. */ private synchronized int read(final StringBuilder pending, final char[] cbuf, final int off, final int len) { while (pending.length() == 0 && !finished) { @@ -297,10 +462,40 @@ public int read(final char[] cbuf, final int off, final int len) { return RemoteProcess.this.read(pending, cbuf, off, len); } + @Override + public boolean ready() { + // Buffered output can be read without a protocol round trip. This is what lets a polling + // consumer (e.g. the CLI's interactive shell pump) drain everything a bounded wait + // absorbed without ever blocking on the wire. + return RemoteProcess.this.hasPending(pending); + } + @Override public void close() { // Closing a reader does not close the process: the RemoteProcess owns the lifecycle, so // each reader can sit in its own try-with-resources while the process lives on. } } + + /** + * The command's standard input as a {@link Writer}: writes buffer locally, {@code flush()} + * emits one WSMan Send, {@code close()} emits the final Send flagged as the end of input. + */ + private final class StdinWriter extends Writer { + + @Override + public void write(final char[] cbuf, final int off, final int len) { + bufferStdin(cbuf, off, len); + } + + @Override + public void flush() { + sendStdin(false); + } + + @Override + public void close() { + sendStdin(true); + } + } } diff --git a/src/main/java/org/metricshub/winrm/WindowsRemoteExecutor.java b/src/main/java/org/metricshub/winrm/WindowsRemoteExecutor.java index ebf8dc9..ebf218e 100644 --- a/src/main/java/org/metricshub/winrm/WindowsRemoteExecutor.java +++ b/src/main/java/org/metricshub/winrm/WindowsRemoteExecutor.java @@ -151,6 +151,40 @@ default WqlCursor streamWql( */ default CommandCursor startCommand(final String command, final String workingDirectory, final long timeout) throws TimeoutException, WindowsRemoteException { + return startCommand(command, workingDirectory, timeout, true); + } + + /** + *

+ * Variant of {@link #startCommand(String, String, long)} making the {@code WINRS_CONSOLEMODE_STDIN} + * option explicit. The three-argument variant keeps the historical console semantics + * ({@code TRUE}); pass {@code false} when the command will be fed standard input through + * {@link CommandCursor#send(byte[], boolean)} and must see it as an ordinary pipe — a + * console-mode stdin never reaches EOF for tools like {@code sort} or {@code more}. + *

+ *

+ * The default implementation throws {@link UnsupportedOperationException}: only executors that + * support streaming (such as the built-in lightweight backend) implement this method. + *

+ * + * @param command The command to execute + * @param workingDirectory Path of the directory for the spawned process on the remote system (can be null) + * @param timeout timeout in milliseconds of each WSMan round trip — the inactivity timeout of + * the stream, not an overall deadline (throws an IllegalArgumentException if negative + * or zero) + * @param consoleModeStdin the value of the {@code WINRS_CONSOLEMODE_STDIN} option: {@code true} + * for console semantics (the historical default), {@code false} for pipe semantics + * @return a cursor over the command output, owning the executor's connection until the command + * completes or the cursor is closed — always close it (try-with-resources) + * @throws TimeoutException when the server does not answer the command startup in time + * @throws WindowsRemoteException For any problem encountered + */ + default CommandCursor startCommand( + final String command, + final String workingDirectory, + final long timeout, + final boolean consoleModeStdin + ) throws TimeoutException, WindowsRemoteException { throw new UnsupportedOperationException(getClass().getName() + " does not support streaming command execution."); } diff --git a/src/main/java/org/metricshub/winrm/cli/CliArguments.java b/src/main/java/org/metricshub/winrm/cli/CliArguments.java index 4335e47..4a53ac3 100644 --- a/src/main/java/org/metricshub/winrm/cli/CliArguments.java +++ b/src/main/java/org/metricshub/winrm/cli/CliArguments.java @@ -43,7 +43,8 @@ enum Operation { HELP, VERSION, WQL, - COMMAND + COMMAND, + SHELL } static final long DEFAULT_TIMEOUT = 60_000L; @@ -169,6 +170,13 @@ private static int parseOption(final Builder builder, final String[] arguments, private static void parseOperation(final Builder builder, final String name, final List values) throws CliUsageException { + if ("shell".equals(name)) { + builder.operation = Operation.SHELL; + if (!values.isEmpty()) { + throw new CliUsageException("shell takes no argument"); + } + return; + } if ("wql".equals(name)) { builder.operation = Operation.WQL; builder.input = String.join(" ", values); @@ -186,9 +194,9 @@ private static void validate(final Builder builder) throws CliUsageException { return; } if (builder.operation == null) { - throw new CliUsageException("missing subcommand (wql or command)"); + throw new CliUsageException("missing subcommand (wql, command, or shell)"); } - if (isBlank(builder.input)) { + if (builder.operation != Operation.SHELL && isBlank(builder.input)) { throw new CliUsageException( builder.operation == Operation.WQL ? "wql requires a query" : "command requires a command line" ); @@ -383,7 +391,9 @@ private static boolean isSubcommand(final String value) { || "exec".equals(value) || - "run".equals(value); + "run".equals(value) + || + "shell".equals(value); } private static boolean isBlank(final String value) { diff --git a/src/main/java/org/metricshub/winrm/cli/InteractiveShell.java b/src/main/java/org/metricshub/winrm/cli/InteractiveShell.java new file mode 100644 index 0000000..fd4de9a --- /dev/null +++ b/src/main/java/org/metricshub/winrm/cli/InteractiveShell.java @@ -0,0 +1,269 @@ +package org.metricshub.winrm.cli; + +/*- + * ╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲ + * WinRM Java Client + * ჻჻჻჻჻჻ + * Copyright (C) 2023 - 2026 MetricsHub + * ჻჻჻჻჻჻ + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * ╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱ + */ + +import java.io.BufferedReader; +import java.io.BufferedWriter; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.io.PrintStream; +import java.nio.charset.Charset; +import java.time.Duration; +import java.util.concurrent.BlockingQueue; +import java.util.concurrent.LinkedBlockingQueue; +import java.util.concurrent.atomic.AtomicBoolean; +import org.metricshub.winrm.RemoteProcess; + +/** + * The protocol pump behind the CLI's {@code shell} subcommand: bridges a {@link RemoteProcess} + * (typically a remote {@code cmd.exe}) to local streams until the remote shell exits, and returns + * its exit code. + *

+ * The pump is single-threaded on the wire — the WinRM connection stays strictly serial. + * Each round of the loop forwards the queued local input (one WSMan Send), polls the remote + * output for a short bounded wait, and writes whatever arrived to the local streams; the poll + * cadence is the interaction latency. An idle session never trips the inactivity timeout: every + * poll round trip completes with output or the protocol's "nothing yet" answer. + *

+ * The only helper thread reads the local input into a queue, because a blocking read of + * {@code System.in} must not stall the pump; it never touches the connection. Input is + * line-oriented (like {@code winrs}): lines are forwarded once the local terminal hands + * them out, i.e. after Enter. + *

+ * Local end of input (Ctrl+Z then Enter on Windows, Ctrl+D elsewhere) closes the remote stdin — + * the final Send is flagged {@code End} — after which {@code cmd.exe} exits on its own. Setting + * the interrupt flag (wired to Ctrl+C by the CLI) forwards a WSMan {@code ctrl_c} Signal, which + * interrupts the remote child process without ending the session. + */ +final class InteractiveShell { + + /** + * Poll cadence of the CLI session. It paces the IDLE rounds only — a poll returns as soon as + * the server has output, and the poll following a forwarded line is issued immediately — so + * typed input echoes back with sub-second latency while idle rounds stay cheap. The cadence + * cannot be lower: the WSMan service holds a bounded Receive for at least 500 ms (its + * OperationTimeout floor), and the poll needs transit slack on top. + */ + static final long DEFAULT_POLL_MILLIS = 1_000L; + + private InteractiveShell() {} + + /** + * The local input lines available to the pump, without ever blocking it. Seam between the + * pump's deterministic protocol loop and the helper thread that feeds it in production. + */ + interface LineSource { + /** + * @return the next queued local input line (without its line terminator), or {@code null} + * when none is available right now + */ + String nextLine(); + + /** + * @return whether the local input reached its end and every queued line was consumed + */ + boolean endOfInput(); + } + + /** + * Bridge the process to the given local streams until the remote command exits. + * + * @param process the running remote shell + * @param localInput the local input to forward, read line by line on a helper thread + * @param out where the remote standard output goes + * @param err where the remote standard error goes + * @param interruptRequested set externally (e.g. by a Ctrl+C handler) to forward a + * {@code ctrl_c} Signal on the next round; cleared once forwarded + * @param pollMillis the poll cadence in milliseconds + * @return the remote command's exit code + * @throws IOException when forwarding the local input fails + */ + static int run( + final RemoteProcess process, + final InputStream localInput, + final PrintStream out, + final PrintStream err, + final AtomicBoolean interruptRequested, + final long pollMillis + ) throws IOException { + final QueuedLineSource lines = new QueuedLineSource(); + final Thread reader = new Thread(() -> lines.readAll(localInput), "winrm-shell-stdin"); + reader.setDaemon(true); + reader.start(); + return bridge(process, lines, out, err, interruptRequested, pollMillis); + } + + /** + * The pump itself, deterministic given its inputs: forward queued local input (one Send per + * round at most), poll the remote output for a short bounded wait, forward whatever arrived to + * the local streams — until the remote command completes. + * + * @param process the running remote shell + * @param lines the local input lines + * @param out where the remote standard output goes + * @param err where the remote standard error goes + * @param interruptRequested checked (and cleared) every round; forwards a {@code ctrl_c} Signal + * @param pollMillis the poll cadence in milliseconds + * @return the remote command's exit code + * @throws IOException when forwarding the local input fails + */ + static int bridge( + final RemoteProcess process, + final LineSource lines, + final PrintStream out, + final PrintStream err, + final AtomicBoolean interruptRequested, + final long pollMillis + ) throws IOException { + boolean eofForwarded = false; + boolean completed = false; + while (!completed) { + if (interruptRequested.getAndSet(false)) { + process.interrupt(); + } + if (!eofForwarded) { + eofForwarded = forwardLocalInput(lines, process.stdin()); + } + // One bounded round trip: it returns as soon as the server answers, so available + // output is forwarded immediately — the cadence only paces the idle rounds. + completed = process.poll(Duration.ofMillis(pollMillis)); + forward(process.stdout(), out); + forward(process.stderr(), err); + } + // Completion was observed with output possibly still buffered: drain it to the end. + forward(process.stdout(), out); + forward(process.stderr(), err); + return process.exitCode(); + } + + /** + * Forward every queued local line as one flushed write (one WSMan Send). Lines are terminated + * with CRLF — what the remote {@code cmd.exe} console expects. Once the local input ends, the + * remote stdin is closed (the final Send carries {@code End="true"}) and {@code true} is + * returned: no further input will be forwarded. + */ + private static boolean forwardLocalInput(final LineSource lines, final BufferedWriter stdin) throws IOException { + boolean wrote = false; + String line; + while ((line = lines.nextLine()) != null) { + stdin.write(line); + stdin.write("\r\n"); + wrote = true; + } + if (lines.endOfInput()) { + // Closing flushes the pending lines too: they travel with the End mark, one single Send. + stdin.close(); + return true; + } + if (wrote) { + stdin.flush(); + } + return false; + } + + /** Write everything already buffered for the channel — never a protocol round trip. */ + private static void forward(final BufferedReader channel, final PrintStream target) throws IOException { + final char[] buffer = new char[4096]; + boolean wrote = false; + while (channel.ready()) { + final int read = channel.read(buffer); + if (read < 0) { + break; + } + target.print(new String(buffer, 0, read)); + wrote = true; + } + if (wrote) { + target.flush(); + } + } + + /** + * The production {@link LineSource}: a queue fed by the helper thread reading the local + * standard input, drained by the pump without ever blocking. + */ + static final class QueuedLineSource implements LineSource { + + /** One queued item: a line, or the end of the local input when {@code line} is null. */ + private static final class Item { + + private final String line; + + private Item(final String line) { + this.line = line; + } + } + + private final BlockingQueue queue = new LinkedBlockingQueue<>(); + private boolean ended; + + /** Feed the queue from the local input, line by line, ending with the EOF marker. */ + void readAll(final InputStream localInput) { + // The local console charset: what the terminal actually feeds System.in with. The + // reader is deliberately never closed — the stream is the caller's (System.in). + final BufferedReader reader = new BufferedReader( + new InputStreamReader(localInput, Charset.defaultCharset()) + ); + try { + String line; + while ((line = reader.readLine()) != null) { + queue.put(new Item(line)); + } + queue.put(new Item(null)); + } catch (final IOException e) { + // The local input died: treat it as its end. + putSilently(new Item(null)); + } catch (final InterruptedException e) { + Thread.currentThread().interrupt(); + } + } + + private void putSilently(final Item item) { + try { + queue.put(item); + } catch (final InterruptedException e) { + Thread.currentThread().interrupt(); + } + } + + @Override + public String nextLine() { + if (ended) { + return null; + } + final Item item = queue.poll(); + if (item == null) { + return null; + } + if (item.line == null) { + ended = true; + return null; + } + return item.line; + } + + @Override + public boolean endOfInput() { + return ended; + } + } +} diff --git a/src/main/java/org/metricshub/winrm/cli/WinRmCli.java b/src/main/java/org/metricshub/winrm/cli/WinRmCli.java index de89e65..f6199ea 100644 --- a/src/main/java/org/metricshub/winrm/cli/WinRmCli.java +++ b/src/main/java/org/metricshub/winrm/cli/WinRmCli.java @@ -22,7 +22,11 @@ import java.io.Console; import java.io.IOException; +import java.io.InputStream; import java.io.PrintStream; +import java.lang.reflect.InvocationHandler; +import java.lang.reflect.Method; +import java.lang.reflect.Proxy; import java.net.ConnectException; import java.net.NoRouteToHostException; import java.net.SocketException; @@ -32,10 +36,13 @@ import java.util.Locale; import java.util.Map; import java.util.concurrent.TimeoutException; +import java.util.concurrent.atomic.AtomicBoolean; import java.util.function.Consumer; import java.util.stream.Stream; import javax.net.ssl.SSLException; import org.metricshub.winrm.AuthScheme; +import org.metricshub.winrm.CommandRequest; +import org.metricshub.winrm.RemoteProcess; import org.metricshub.winrm.WinRMClient; import org.metricshub.winrm.WinRMHttpProtocolEnum; import org.metricshub.winrm.WqlRow; @@ -50,7 +57,10 @@ * WS-Enumeration pages arrive — a large query starts producing output immediately and memory * stays bounded, but a mid-stream failure can leave partial output on standard output (with a * nonzero exit code). Remote command output is forwarded live to the matching local output - * stream while the command runs. Diagnostics are written only to standard error. + * stream while the command runs; when the local standard input is not an interactive console + * (piped or redirected), it is forwarded as the remote command's standard input. The {@code shell} + * subcommand starts {@code cmd.exe} on the remote host and bridges it to the local terminal, + * line by line, until the remote shell exits. Diagnostics are written only to standard error. *

* NTLM is the default authentication scheme. Kerberos requires HTTPS. HTTPS validates certificates * and hostnames unless the explicitly insecure {@code --https-permissive} option is used. @@ -81,7 +91,16 @@ private WinRmCli() {} * @param arguments command-line arguments */ public static void main(final String[] arguments) { - System.exit(run(arguments, System.out, System.err, WinRmCli::connect)); + System.exit( + run( + arguments, + System.out, + System.err, + WinRmCli::connect, + WinRmCli::readConsolePassword, + new LocalInput(System.console() != null, System.in) + ) + ); } static int run( @@ -99,6 +118,26 @@ static int run( final PrintStream standardError, final RemoteFactory remoteFactory, final PasswordReader passwordReader + ) { + // Tests reaching this overload behave as if run from an interactive console: no local + // standard input is forwarded implicitly (the test JVM's System.in is not theirs to consume). + return run( + arguments, + standardOutput, + standardError, + remoteFactory, + passwordReader, + new LocalInput(true, System.in) + ); + } + + static int run( + final String[] arguments, + final PrintStream standardOutput, + final PrintStream standardError, + final RemoteFactory remoteFactory, + final PasswordReader passwordReader, + final LocalInput localInput ) { try (CliArguments parsed = CliArguments.parse(arguments)) { if (parsed.operation() == CliArguments.Operation.HELP) { @@ -110,7 +149,7 @@ static int run( return 0; } ensurePassword(parsed, passwordReader); - return execute(parsed, standardOutput, standardError, remoteFactory); + return execute(parsed, standardOutput, standardError, remoteFactory, localInput); } catch (final CliUsageException e) { standardError.println("winrm-java: " + e.getMessage()); standardError.println("Try 'winrm-java --help' for usage."); @@ -144,7 +183,8 @@ private static int execute( final CliArguments arguments, final PrintStream standardOutput, final PrintStream standardError, - final RemoteFactory remoteFactory + final RemoteFactory remoteFactory, + final LocalInput localInput ) { final String previousKerberosKdc = System.getProperty(KERBEROS_KDC_PROPERTY); final String previousKerberosRealm = System.getProperty(KERBEROS_REALM_PROPERTY); @@ -164,10 +204,15 @@ private static int execute( ); return 0; } + if (arguments.operation() == CliArguments.Operation.SHELL) { + return interactiveShell(arguments, standardOutput, standardError, remote, localInput); + } // Forward each output chunk as it arrives, so a long-running command can be followed live. + // Piped/redirected local standard input travels the other way, as the command's stdin. final int exitCode = remote.executeCommand( arguments.input(), arguments.timeout(), + localInput.terminal ? null : localInput.stream, chunk -> { standardOutput.print(chunk); standardOutput.flush(); @@ -192,6 +237,87 @@ private static int execute( } } + /** + * Run the interactive {@code shell} session: local Ctrl+C is rerouted to the remote child (a + * WSMan {@code ctrl_c} Signal) for the duration of the session, and the remote shell's exit + * code is propagated through the usual contract. + */ + private static int interactiveShell( + final CliArguments arguments, + final PrintStream standardOutput, + final PrintStream standardError, + final RemoteOperations remote, + final LocalInput localInput + ) throws Exception { + final AtomicBoolean interruptRequested = new AtomicBoolean(); + final Runnable restoreInterruptHandler = forwardSigint(interruptRequested); + try { + final int exitCode = remote.shell( + arguments.timeout(), + localInput.stream, + standardOutput, + standardError, + interruptRequested + ); + return remoteExitCode(exitCode, standardError); + } finally { + restoreInterruptHandler.run(); + } + } + + /** + * Reroute Ctrl+C (SIGINT) into the given flag instead of killing this JVM, so the interactive + * shell can forward it to the remote child process. Uses {@code sun.misc.Signal} reflectively: + * the JDK ships it in {@code jdk.unsupported}, but it is not part of the Java SE API, so a + * runtime without it simply keeps the default behavior (Ctrl+C ends the CLI — and the remote + * shell with it, through the client's close). Returns the action restoring the previous + * handler. + */ + private static Runnable forwardSigint(final AtomicBoolean interruptRequested) { + try { + final Class signalClass = Class.forName("sun.misc.Signal"); + final Class handlerClass = Class.forName("sun.misc.SignalHandler"); + final InvocationHandler invocationHandler = (proxy, method, args) -> { + if ("handle".equals(method.getName())) { + interruptRequested.set(true); + return null; + } + // Object methods a well-behaved proxy must answer (equals/hashCode/toString). + return invokeObjectMethod(proxy, method, args); + }; + final Object handler = Proxy.newProxyInstance( + handlerClass.getClassLoader(), + new Class[] + { handlerClass }, + invocationHandler + ); + final Object sigint = signalClass.getConstructor(String.class).newInstance("INT"); + final Method handle = signalClass.getMethod("handle", signalClass, handlerClass); + final Object previous = handle.invoke(null, sigint, handler); + return () -> { + try { + handle.invoke(null, sigint, previous); + } catch (final ReflectiveOperationException | RuntimeException ignored) { + // The session is over either way; the JVM exits right after. + } + }; + } catch (final ReflectiveOperationException | RuntimeException | LinkageError e) { + return () -> {}; + } + } + + /** Default implementations of the {@link Object} methods for a reflective proxy. */ + private static Object invokeObjectMethod(final Object proxy, final Method method, final Object[] args) { + switch (method.getName()) { + case "equals": + return proxy == args[0]; + case "hashCode": + return System.identityHashCode(proxy); + default: + return "winrm-java SIGINT forwarder"; + } + } + static RemoteOperations connect(final CliArguments arguments) { final WinRMClient.Builder builder = WinRMClient .builder(arguments.hostname()) @@ -304,6 +430,7 @@ private static String help() { return "Usage:\n" + " winrm-java [options] wql \n" + " winrm-java [options] command|cmd|exec|run \n" + + " winrm-java [options] shell\n" + "\n" + "Connection options:\n" + " -h, --hostname Target hostname or IP address (required)\n" + @@ -337,15 +464,40 @@ interface PasswordReader { char[] readPassword() throws CliUsageException; } + /** The local standard input: whether it is an interactive console, and the stream itself. */ + static final class LocalInput { + + private final boolean terminal; + private final InputStream stream; + + LocalInput(final boolean terminal, final InputStream stream) { + this.terminal = terminal; + this.stream = stream; + } + } + interface RemoteOperations extends AutoCloseable { /** Run the WQL query, handing each row to the consumer as it arrives. */ void streamWql(String query, long timeout, Consumer> rowConsumer) throws Exception; /** * Run the command, forwarding each decoded output chunk to the matching consumer as it - * arrives, and return the remote exit code. + * arrives, and return the remote exit code. A non-null {@code stdin} is consumed to its end + * and forwarded as the command's standard input. + */ + int executeCommand( + String command, + long timeout, + InputStream stdin, + Consumer stdoutConsumer, + Consumer stderrConsumer + ) throws Exception; + + /** + * Start {@code cmd.exe} on the remote host and bridge it to the given local streams until + * it exits; return its exit code. See {@link InteractiveShell}. */ - int executeCommand(String command, long timeout, Consumer stdoutConsumer, Consumer stderrConsumer) + int shell(long timeout, InputStream localInput, PrintStream out, PrintStream err, AtomicBoolean interruptRequested) throws Exception; @Override @@ -372,16 +524,39 @@ public void streamWql(final String query, final long timeout, final Consumer stdoutConsumer, final Consumer stderrConsumer ) { - return client + final CommandRequest request = client .command(command) .timeout(Duration.ofMillis(timeout)) .onStdout(stdoutConsumer) - .onStderr(stderrConsumer) - .execute() - .exitCode(); + .onStderr(stderrConsumer); + if (stdin != null) { + request.stdin(stdin); + } + return request.execute().exitCode(); + } + + @Override + public int shell( + final long timeout, + final InputStream localInput, + final PrintStream out, + final PrintStream err, + final AtomicBoolean interruptRequested + ) throws Exception { + // The remote side of the bridge: cmd.exe with console-mode stdin, exactly like winrs. + // The timeout bounds each protocol round trip; an idle session never trips it, because + // every poll completes with output or the protocol's "nothing yet" answer. + try ( + RemoteProcess process = client.command("cmd.exe") + .timeout(Duration.ofMillis(timeout)) + .start()) { + return InteractiveShell + .run(process, localInput, out, err, interruptRequested, InteractiveShell.DEFAULT_POLL_MILLIS); + } } @Override diff --git a/src/main/java/org/metricshub/winrm/light/Envelopes.java b/src/main/java/org/metricshub/winrm/light/Envelopes.java index b120b22..ab4ea97 100644 --- a/src/main/java/org/metricshub/winrm/light/Envelopes.java +++ b/src/main/java/org/metricshub/winrm/light/Envelopes.java @@ -29,7 +29,7 @@ /** * WS-Management SOAP envelope templates — the only "WSDL" the light client needs. * Covers Identify, WQL enumeration (Enumerate / Pull / Release), and the command shell lifecycle - * (Create / Command / Receive / Signal / Delete). + * (Create / Command / Send / Receive / Signal / Delete). */ final class Envelopes { @@ -48,12 +48,28 @@ final class Envelopes { private static final String ACTION_COMMAND = RSP + "/Command"; private static final String ACTION_RECEIVE = RSP + "/Receive"; private static final String ACTION_SIGNAL = RSP + "/Signal"; + private static final String ACTION_SEND = RSP + "/Send"; static final String SHELL_RESOURCE_URI = RSP + "/cmd"; static final String TERMINATE_CODE = RSP + "/signal/terminate"; + + /** + * The WSMan equivalent of a console Ctrl+C: it interrupts the command's child process without + * terminating the command (and its shell) the way {@link #TERMINATE_CODE} does. + */ + static final String CTRL_C_CODE = RSP + "/signal/ctrl_c"; + static final String COMMAND_STATE_DONE = RSP + "/CommandState/Done"; - private static final int MAX_ENVELOPE_SIZE = 153600; + static final int MAX_ENVELOPE_SIZE = 153600; + + /** + * Largest stdin payload carried by a single Send, in raw bytes before base64. The whole envelope + * must stay under {@link #MAX_ENVELOPE_SIZE}: base64 inflates by 4/3, and the SOAP header, + * selectors and element markup around the payload need a few hundred bytes — 96 KiB of raw input + * becomes 128 KiB of base64, leaving comfortable room inside the 150 KiB limit. + */ + static final int MAX_STDIN_CHUNK = 96 * 1024; /** * Console code page of the remote shell: UTF-8. The shell's output charset must be one the @@ -142,9 +158,25 @@ static String createShell(final String url, final String workingDirectory, final ""; } - static String command(final String url, final String shellId, final String commandLine, final long timeoutMs) { + /** + * Start a command in an existing shell. + * + * @param consoleModeStdin value of the {@code WINRS_CONSOLEMODE_STDIN} option: {@code TRUE} makes + * the remote stdin behave like a console (what an interactive session and a command that + * never reads input want), {@code FALSE} makes it an ordinary pipe, which is what a + * command fed programmatic input needs — a console-mode stdin never reaches EOF for tools + * like {@code sort} or {@code findstr} + */ + static String command( + final String url, + final String shellId, + final String commandLine, + final long timeoutMs, + final boolean consoleModeStdin + ) { final String optionSet = "" + - "TRUE" + + "" + (consoleModeStdin ? "TRUE" : "FALSE") + + "" + "FALSE" + ""; return envelopeOpen(true) + @@ -154,6 +186,31 @@ static String command(final String url, final String shellId, final String comma ""; } + /** + * Feed one chunk of standard input to a running command. + * + * @param base64Data the chunk's bytes, base64-encoded (may be empty on a pure end-of-input Send) + * @param end {@code true} to mark this chunk as the last one — the remote stdin then reaches EOF + */ + static String send( + final String url, + final String shellId, + final String commandId, + final String base64Data, + final boolean end, + final long timeoutMs + ) { + return envelopeOpen(true) + + header(url, SHELL_RESOURCE_URI, ACTION_SEND, timeoutMs, shellSelector(shellId), null) + + "" + + base64Data + + ""; + } + static String receive(final String url, final String shellId, final String commandId, final long timeoutMs) { return envelopeOpen(true) + header(url, SHELL_RESOURCE_URI, ACTION_RECEIVE, timeoutMs, shellSelector(shellId), null) + @@ -162,13 +219,25 @@ static String receive(final String url, final String shellId, final String comma "\">stdout stderr"; } - static String signal(final String url, final String shellId, final String commandId, final long timeoutMs) { + /** + * Signal a running command. + * + * @param code the signal code — {@link #TERMINATE_CODE} to stop the command, {@link #CTRL_C_CODE} + * to interrupt its child process the way a console Ctrl+C would + */ + static String signal( + final String url, + final String shellId, + final String commandId, + final String code, + final long timeoutMs + ) { return envelopeOpen(true) + header(url, SHELL_RESOURCE_URI, ACTION_SIGNAL, timeoutMs, shellSelector(shellId), null) + "" + - TERMINATE_CODE + + code + ""; } diff --git a/src/main/java/org/metricshub/winrm/light/LightWinRMService.java b/src/main/java/org/metricshub/winrm/light/LightWinRMService.java index 25894ac..a9d0945 100644 --- a/src/main/java/org/metricshub/winrm/light/LightWinRMService.java +++ b/src/main/java/org/metricshub/winrm/light/LightWinRMService.java @@ -285,8 +285,12 @@ private void checkWqlArguments( } @Override - public CommandCursor startCommand(final String command, final String workingDirectory, final long timeout) - throws TimeoutException, WindowsRemoteException { + public CommandCursor startCommand( + final String command, + final String workingDirectory, + final long timeout, + final boolean consoleModeStdin + ) throws TimeoutException, WindowsRemoteException { checkNotClosed(); Utils.checkNonNull(command, "command"); Utils.checkArgumentNotZeroOrNegative(timeout, "timeout"); @@ -294,7 +298,7 @@ public CommandCursor startCommand(final String command, final String workingDire // Shell creation and command startup happen here, on the caller's thread, so failures // surface immediately rather than on the first output chunk. final WsmanClient.RemoteCommand remoteCommand = callStreaming( - () -> client.startCommand(command, workingDirectory, timeout, true) + () -> client.startCommand(command, workingDirectory, timeout, true, consoleModeStdin) ); return new CommandCursor() { @Override @@ -311,6 +315,22 @@ private Chunk adapt(final WsmanClient.RemoteCommand.Chunk chunk) { return chunk == null ? null : new Chunk(chunk.stdout, chunk.stderr); } + @Override + public void send(final byte[] data, final boolean end) throws TimeoutException, WindowsRemoteException { + callStreaming(() -> { + remoteCommand.send(data, end); + return null; + }); + } + + @Override + public void interrupt() throws TimeoutException, WindowsRemoteException { + callStreaming(() -> { + remoteCommand.interrupt(); + return null; + }); + } + @Override public int exitCode() { return remoteCommand.exitCode(); diff --git a/src/main/java/org/metricshub/winrm/light/WsmanClient.java b/src/main/java/org/metricshub/winrm/light/WsmanClient.java index 95c9c61..a703c63 100644 --- a/src/main/java/org/metricshub/winrm/light/WsmanClient.java +++ b/src/main/java/org/metricshub/winrm/light/WsmanClient.java @@ -26,6 +26,7 @@ import java.nio.charset.Charset; import java.nio.charset.StandardCharsets; import java.util.ArrayList; +import java.util.Arrays; import java.util.Base64; import java.util.LinkedHashMap; import java.util.List; @@ -56,9 +57,16 @@ final class WsmanClient implements AutoCloseable { private static final String FAULT_OPERATION_TIMEOUT = "2150858793"; private static final String FAULT_SHELL_NOT_FOUND = "2150858843"; - // A bounded poll shorter than this cannot be honored by a network round trip (the answer could - // not come back in time): it is waited out locally instead of going to the wire. - private static final long MIN_WIRE_POLL_MS = 100; + // 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. + private static final long MIN_OPERATION_TIMEOUT_MS = 500; + + // A bounded poll shorter than this cannot be honored by a network round trip: the server + // answers no earlier than MIN_OPERATION_TIMEOUT_MS, and the fault needs transit slack to beat + // the socket cut at the budget. Shorter waits are waited out locally instead of going to the + // wire. + private static final long MIN_WIRE_POLL_MS = 750; // WS-Enumeration namespace: the EndOfSequence / EnumerationContext markers live here. Match them by // namespace, never by local name alone, so a WMI property that happens to be named "EndOfSequence" @@ -428,7 +436,7 @@ CommandOutput executeCommand( final Charset cs = charset != null ? charset : WindowsRemoteExecutor.SHELL_OUTPUT_CHARSET; final ByteArrayOutputStream stdout = new ByteArrayOutputStream(); final ByteArrayOutputStream stderr = new ByteArrayOutputStream(); - try (RemoteCommand command = startCommand(commandLine, workingDirectory, operationTimeoutMs, false)) { + try (RemoteCommand command = startCommand(commandLine, workingDirectory, operationTimeoutMs, false, true)) { RemoteCommand.Chunk chunk; while ((chunk = command.nextChunk()) != null) { stdout.write(chunk.stdout, 0, chunk.stdout.length); @@ -457,12 +465,15 @@ CommandOutput executeCommand( * fault or a socket read timeout into a {@link TimeoutException} instead of re-issuing * the Receive forever (the blocking path is bounded by the caller's wall-clock deadline * instead) + * @param consoleModeStdin value of the {@code WINRS_CONSOLEMODE_STDIN} option; see + * {@link Envelopes#command(String, String, String, long, boolean)} */ RemoteCommand startCommand( final String commandLine, final String workingDirectory, final long operationTimeoutMs, - final boolean failOnQuietTimeout + final boolean failOnQuietTimeout, + final boolean consoleModeStdin ) throws Exception { // Serialize the whole shell lifecycle (Create + Command + Receive loop + Signal) against any // other operation sharing this connection and the shellId field; see connectionPermit. @@ -482,7 +493,7 @@ RemoteCommand startCommand( checkNotCancelled(); String commandId; try { - commandId = sendCommand(commandLine, operationTimeoutMs, failOnQuietTimeout); + commandId = sendCommand(commandLine, operationTimeoutMs, failOnQuietTimeout, consoleModeStdin); } catch (final WinRMFaultException e) { if (!FAULT_SHELL_NOT_FOUND.equals(e.getFaultCode())) { throw e; @@ -493,7 +504,7 @@ RemoteCommand startCommand( shellId = null; createShell(shellWorkingDirectory, operationTimeoutMs, failOnQuietTimeout); checkNotCancelled(); - commandId = sendCommand(commandLine, operationTimeoutMs, failOnQuietTimeout); + commandId = sendCommand(commandLine, operationTimeoutMs, failOnQuietTimeout, consoleModeStdin); } opened = true; return new RemoteCommand(commandId, operationTimeoutMs, failOnQuietTimeout); @@ -593,13 +604,16 @@ Chunk pollChunk(final long maxWaitMs) throws Exception { // transit slack for its "nothing yet" op-timeout fault to arrive BEFORE the socket cuts // at the full budget — the expected expiry of a bounded poll is that fault, and it must // win the race or the poll would desync the connection it is supposed to leave intact. + // The hold never asks for less than the service's own floor: the answer would not come + // any earlier, and the requested timeout should reflect when the server may answer. final long transit = Math.min(1_000, budget / 2); + final long hold = Math.max(MIN_OPERATION_TIMEOUT_MS, budget - transit); transport.pollTimeout(toSocketTimeoutMillis(budget)); try { checkNotCancelled(); final Decoded resp; try { - resp = request(Envelopes.receive(url, shellId, commandId, budget - transit)); + resp = request(Envelopes.receive(url, shellId, commandId, hold)); } catch (final SocketTimeoutException e) { // The peer answered neither within its shortened hold nor within the transit // slack. The Receive is abandoned mid-flight, so drop the connection outright: a @@ -621,6 +635,61 @@ Chunk pollChunk(final long maxWaitMs) throws Exception { } } + /** + * Feed standard input to the running command: one or more WSMan Send requests carrying the + * bytes as base64 {@code stdin} streams, the last one flagged {@code End} when {@code end} is + * set. Payloads larger than {@link Envelopes#MAX_STDIN_CHUNK} are split so no envelope + * exceeds the MaxEnvelopeSize the client advertises; the split happens on the ENCODED bytes, + * so a multibyte character straddling two Sends is reassembled by the remote pipe. + *

+ * 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. + * + * @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 + */ + void send(final byte[] data, final boolean end) throws Exception { + if (finished || exitCode != null) { + throw new IllegalStateException("The command has completed: its standard input is closed."); + } + if (data.length == 0 && !end) { + // Nothing to say and no EOF to announce: an empty Send would be a pure round trip. + return; + } + int offset = 0; + do { + 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)); + final Decoded resp = request( + Envelopes.send(url, shellId, commandId, base64, end && last, operationTimeoutMs) + ); + if (resp.status != 200) { + throw faultException("Send", resp); + } + offset += length; + } while (offset < data.length); + } + + /** + * Interrupt the command's child process the way a console Ctrl+C would, WITHOUT terminating + * the command or its shell: the session stays usable afterward, which is what an interactive + * shell needs. A missing shell is tolerated, exactly like the terminate Signal. + */ + void interrupt() throws Exception { + // Nothing to interrupt once the command completed (or the handle was closed): the child + // is gone, and the Signal would only draw a fault. + if (finished || exitCode != null) { + return; + } + checkNotCancelled(); + signal(commandId, Envelopes.CTRL_C_CODE, operationTimeoutMs); + } + /** Turn one 200 Receive response into a chunk, recording the exit code when it says Done. */ private Chunk toChunk(final Decoded resp) { final ByteArrayOutputStream stdout = new ByteArrayOutputStream(); @@ -775,10 +844,14 @@ private void createShell(final String workingDirectory, final long timeoutMs, fi throw new IllegalStateException("Shell ID not found in Create response"); } - private String sendCommand(final String commandLine, final long timeoutMs, final boolean failOnQuietTimeout) - throws Exception { + private String sendCommand( + final String commandLine, + final long timeoutMs, + final boolean failOnQuietTimeout, + final boolean consoleModeStdin + ) throws Exception { final Document doc = exchange( - Envelopes.command(url, shellId, commandLine, timeoutMs), + Envelopes.command(url, shellId, commandLine, timeoutMs, consoleModeStdin), "Command", timeoutMs, failOnQuietTimeout @@ -791,7 +864,16 @@ private String sendCommand(final String commandLine, final long timeoutMs, final } private void terminate(final String commandId, final long timeoutMs) throws Exception { - final Decoded resp = request(Envelopes.signal(url, shellId, commandId, timeoutMs)); + signal(commandId, Envelopes.TERMINATE_CODE, timeoutMs); + } + + /** + * Send one Signal for a command. A missing shell is tolerated (the command may already be gone), + * and the cached shell id is then dropped so the next command creates a fresh one up front + * instead of discovering the stale one the hard way. + */ + private void signal(final String commandId, final String code, final long timeoutMs) throws Exception { + final Decoded resp = request(Envelopes.signal(url, shellId, commandId, code, timeoutMs)); // A missing shell is fine here — the command already finished and the shell may be gone. // But drop the cached ID so the next command creates a fresh shell up front instead of // discovering the stale one the hard way. diff --git a/src/site/markdown/cli.md b/src/site/markdown/cli.md index 0d5b725..17ef70e 100644 --- a/src/site/markdown/cli.md +++ b/src/site/markdown/cli.md @@ -1,5 +1,5 @@ -keywords: cli, command line, standalone, jar, wql, exec, exit codes, manual -description: Manual page of the winrm-java standalone command-line client - subcommands, options, passwords, Kerberos, streaming output, and exit codes. +keywords: cli, command line, standalone, jar, wql, exec, shell, interactive, stdin, exit codes, manual +description: Manual page of the winrm-java standalone command-line client - subcommands, options, passwords, Kerberos, streaming output, the interactive shell, and exit codes. # Command-Line Client @@ -15,6 +15,7 @@ This page is its manual. ```text java -jar winrm-java-standalone.jar [options] wql java -jar winrm-java-standalone.jar [options] command|cmd|exec|run +java -jar winrm-java-standalone.jar [options] shell java -jar winrm-java-standalone.jar --help | --version ``` @@ -24,9 +25,11 @@ java -jar winrm-java-standalone.jar --help | --version | --- | --- | | `wql ` | Run a WQL query and print the rows to stdout as UTF-8 [JSON Lines](https://jsonlines.org/). | | `command ` | Run a command on the remote host, forwarding its output. `cmd`, `exec`, and `run` are aliases. | +| `shell` | Open an interactive `cmd.exe` session on the remote host (see [Interactive shell](#Interactive_shell)). | Everything after the subcommand is the query or the command line; quoting follows your local -shell's rules, and multi-word command lines are reassembled for the remote `cmd.exe`. +shell's rules, and multi-word command lines are reassembled for the remote `cmd.exe`. `shell` +takes no argument. ## Options @@ -93,6 +96,41 @@ real time. The output is decoded as UTF-8: the remote shell is created with cons locale. See [Character encoding](commands.html#character-encoding) for the two legacy tools that ignore the console code page. +When the local standard input is **not** an interactive console — it is piped or redirected — it +is forwarded as the remote command's standard input, with pipe semantics, so filters just work: + +```bash +java -jar winrm-java-standalone.jar -h server -u 'DOMAIN\user' -pf pw.txt command sort < data.txt +``` + +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`. + +## Interactive shell + +```bash +java -jar winrm-java-standalone.jar -h server.example.net -u 'DOMAIN\user' -pf pw.txt shell +``` + +`shell` starts `cmd.exe` on the remote host and bridges it to the local terminal until the remote +shell exits (type `exit`, or send end-of-input: Ctrl+Z then Enter on Windows, Ctrl+D elsewhere). +The remote exit code is propagated through the usual [exit-code contract](#Exit_codes). + +* **Line-oriented, like `winrs`** — input is line-buffered by the local terminal and forwarded + when you press Enter. There is no raw-terminal/PTY mode (with zero dependencies there is none in + pure Java): full-screen programs, cmd.exe line editing, tab completion, and ANSI cursor control + are not supported. Command output echoes back with sub-second latency; a line typed while the + session is idle can wait up to the poll cadence (about one second — the WSMan protocol's floor + for a bounded poll) before it is forwarded. +* **Ctrl+C interrupts the remote command, not the session** — it is forwarded as the WSMan + `ctrl_c` signal, which stops the running remote child (like a console Ctrl+C) and returns to the + remote prompt. On a Java runtime without `sun.misc.Signal`, Ctrl+C keeps its default behavior + and ends the CLI (terminating the remote shell with it). +* **An idle session does not time out** — `-t`/`--timeout` bounds each protocol round trip, and + every round trip of an idle session completes with the protocol's "nothing yet" answer. Only a + server that stops answering altogether trips the timeout. + ## Timeout semantics `-t`/`--timeout` follows the operation: @@ -102,6 +140,8 @@ ignore the console code page. the server keeps answering. * For `command`, it is the **overall deadline** covering the command itself and any file uploads. +* For `shell`, it bounds **each protocol round trip**; an idle interactive session never trips it + (see [Interactive shell](#Interactive_shell)). See [Timeouts and Errors](timeouts-and-errors.html) for the underlying semantics. diff --git a/src/site/markdown/commands.md b/src/site/markdown/commands.md index 75f284e..53aa1b0 100644 --- a/src/site/markdown/commands.md +++ b/src/site/markdown/commands.md @@ -1,5 +1,5 @@ -keywords: command, execute, cmd, stdout, stderr, exit code, file copy, script -description: Execute remote commands with the fluent WinRMClient API, capture output and exit codes, and copy local files to the host. +keywords: command, execute, cmd, stdout, stderr, stdin, exit code, file copy, script +description: Execute remote commands with the fluent WinRMClient API, capture output and exit codes, feed standard input, and copy local files to the host. # Remote Commands @@ -47,6 +47,7 @@ Everything between `command(...)` and `execute()` is optional: | `charset(Charset)` | `UTF-8` | The charset used to decode the command output (see below). | | `workingDirectory(String)` | remote default | Working directory of the remote process. The remote shell is created by the client's **first** command and reused afterward, so this only takes effect on that first command. | | `upload(Path...)` | none | Local files to copy to the host before running (see below). | +| `stdin(String)` / `stdin(Path)` / `stdin(InputStream)` | none | Standard input fed to the command — the remote equivalent of a `< file` redirection (see below). | | `onStdout(Consumer)` / `onStderr(Consumer)` | none | Callbacks receiving each chunk of output live while `execute()` runs (see below). | ## The result @@ -93,6 +94,57 @@ Points to know: keeps producing output. Use `waitFor(Duration)` when you need a hard deadline. See [Timeouts and Errors](timeouts-and-errors.html). +## Standard input + +Commands that read their standard input can be fed in two ways. + +### Pre-supplied input + +`stdin(...)` on the request delivers the whole input right after the command starts, ending with +the protocol's end-of-input mark so the remote stdin reaches EOF — the remote equivalent of a +local `< file` redirection. It works with both `execute()` and `start()`: + +```java +CommandResult result = client.command("sort") + .stdin(Path.of("data.txt")) // also stdin(InputStream) and stdin(String) + .execute(); +``` + +`stdin(String)` is encoded with the request's charset (see `charset(...)`); the `Path` and +`InputStream` variants send the bytes exactly as stored. Large input is split into +protocol-sized chunks automatically. + +Supplying input switches the remote stdin to **pipe semantics** +(`WINRS_CONSOLEMODE_STDIN=FALSE`): filters like `sort`, `findstr`, or `more` consume it and +terminate on EOF, exactly as with a local redirection. Without it, the historical console +semantics are kept. + +### Interactive input + +For a request started with `start()`, `RemoteProcess.stdin()` completes the `java.lang.Process` +shape: written text is buffered locally, `flush()` carries it to the host (one WSMan `Send`), +and `close()` marks the end of input: + +```java +try (RemoteProcess p = client.command("some-repl.exe").start()) { + try (BufferedWriter in = p.stdin()) { + in.write("first request\n"); + in.flush(); // delivers the buffered text + System.out.println(p.stdout().readLine()); + } // close() = end of input (EOF) + p.waitFor(); +} +``` + +Writes and reads alternate on the caller's thread, exactly like `java.lang.Process` pipes — +including the classic deadlock, which is the caller's to avoid: blocking on a read while the +remote command itself is blocked waiting for input (or feeding a large input to a command that +floods its output in the meantime) hangs both sides until the inactivity timeout fires. + +`RemoteProcess` also exposes `interrupt()` — the WSMan `ctrl_c` Signal, the remote equivalent of +a console Ctrl+C: it interrupts the command's child process without terminating the command or +the process handle. + ### Tailing the output of a blocking execution When you only want to *observe* the output live — logging, progress reporting — but still want the diff --git a/src/test/java/org/metricshub/winrm/CommandStdinTest.java b/src/test/java/org/metricshub/winrm/CommandStdinTest.java new file mode 100644 index 0000000..55303cd --- /dev/null +++ b/src/test/java/org/metricshub/winrm/CommandStdinTest.java @@ -0,0 +1,369 @@ +package org.metricshub.winrm; + +/*- + * ╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲ + * WinRM Java Client + * ჻჻჻჻჻჻ + * Copyright 2023 - 2026 MetricsHub + * ჻჻჻჻჻჻ + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * ╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱ + */ + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.metricshub.winrm.light.FakeWsmanResponses.commandResponse; +import static org.metricshub.winrm.light.FakeWsmanResponses.done; +import static org.metricshub.winrm.light.FakeWsmanResponses.enqueueShellDeletion; +import static org.metricshub.winrm.light.FakeWsmanResponses.envelope; +import static org.metricshub.winrm.light.FakeWsmanResponses.receiveResponse; +import static org.metricshub.winrm.light.FakeWsmanResponses.resourceCreated; +import static org.metricshub.winrm.light.FakeWsmanResponses.sendResponse; +import static org.metricshub.winrm.light.FakeWsmanResponses.signalResponse; +import static org.metricshub.winrm.light.FakeWsmanResponses.stream; + +import java.io.BufferedReader; +import java.io.BufferedWriter; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Duration; +import java.util.List; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +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). + */ +class CommandStdinTest { + + private static final String DOMAIN = "FAKE"; + private static final String USER = "user"; + private static final String PASSWORD = "s3cret-Passw0rd"; + + private static final String SHELL_ID = "SHELL-1"; + private static final String COMMAND_ID = "CMD-1"; + + private FakeWsmanServer server; + + @BeforeEach + void startServer() throws Exception { + server = new FakeWsmanServer(DOMAIN, USER, PASSWORD); + } + + @AfterEach + void stopServer() { + server.close(); + } + + private WinRMClient.Builder builder() { + return WinRMClient + .builder("127.0.0.1") + .port(server.port()) + .credentials(DOMAIN + "\\" + USER, PASSWORD.toCharArray()) + .timeout(Duration.ofSeconds(10)); + } + + private void enqueueStartup() { + server.enqueue(200, envelope(resourceCreated(SHELL_ID))).enqueue(200, envelope(commandResponse(COMMAND_ID))); + } + + @Test + void presuppliedStringStdinIsDeliveredWithPipeSemantics() throws Exception { + enqueueStartup(); + server + .enqueue(200, envelope(sendResponse())) + .enqueue( + 200, + envelope( + receiveResponse( + stream("stdout", COMMAND_ID, "alpha\r\nbeta\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("sort").stdin("beta\nalpha\n").execute(); + + assertEquals(0, result.exitCode()); + assertEquals("alpha\r\nbeta\r\n", result.stdout()); + } + + // The whole input fits one Send: a single chunk carrying the End flag. + final List chunks = server.stdinChunks(); + assertEquals(1, chunks.size()); + assertArrayEquals("beta\nalpha\n".getBytes(StandardCharsets.UTF_8), chunks.get(0).data()); + assertTrue(chunks.get(0).end()); + + // Programmatic stdin switches the remote stdin to pipe semantics on the wire. + final List requests = server.decryptedRequests(); + assertTrue( + requests.get(1).contains("FALSE"), + requests.get(1) + ); + } + + @Test + void presuppliedFileStdinIsDeliveredVerbatim(@TempDir final Path tempDir) throws Exception { + final byte[] content = "line one\r\nline two\r\n".getBytes(StandardCharsets.UTF_8); + final Path file = tempDir.resolve("input.txt"); + Files.write(file, content); + + enqueueStartup(); + server + .enqueue(200, envelope(sendResponse())) + .enqueue(200, envelope(receiveResponse("", done(COMMAND_ID, 3)))) + .enqueue(200, envelope(signalResponse())); + enqueueShellDeletion(server); + + try (WinRMClient client = builder().build()) { + assertEquals(3, client.command("findstr two").stdin(file).execute().exitCode()); + } + + final List chunks = server.stdinChunks(); + assertEquals(1, chunks.size()); + assertArrayEquals(content, chunks.get(0).data()); + assertTrue(chunks.get(0).end()); + } + + @Test + void largePresuppliedStdinIsChunkedAndOnlyTheLastChunkCarriesEnd() throws Exception { + // Three 64 KiB read buffers: 64 KiB + 64 KiB + 18 928 bytes. + final int size = 150_000; + final StringBuilder text = new StringBuilder(size); + for (int i = 0; i < size; i++) { + text.append((char) ('a' + i % 26)); + } + + enqueueStartup(); + server + .enqueue(200, envelope(sendResponse())) + .enqueue(200, envelope(sendResponse())) + .enqueue(200, envelope(sendResponse())) + .enqueue(200, envelope(receiveResponse("", done(COMMAND_ID, 0)))) + .enqueue(200, envelope(signalResponse())); + enqueueShellDeletion(server); + + try (WinRMClient client = builder().build()) { + assertEquals(0, client.command("sort").stdin(text.toString()).execute().exitCode()); + } + + final List chunks = server.stdinChunks(); + assertEquals(3, chunks.size()); + assertFalse(chunks.get(0).end()); + assertFalse(chunks.get(1).end()); + assertTrue(chunks.get(2).end()); + + // Reassembling the chunks yields exactly the input. + final ByteArrayOutputStream reassembled = new ByteArrayOutputStream(); + for (final FakeWsmanServer.StdinChunk chunk : chunks) { + reassembled.writeBytes(chunk.data()); + } + assertArrayEquals(text.toString().getBytes(StandardCharsets.UTF_8), reassembled.toByteArray()); + } + + @Test + void emptyStdinAnnouncesOnlyTheEndOfInput() throws Exception { + enqueueStartup(); + server + .enqueue(200, envelope(sendResponse())) + .enqueue(200, envelope(receiveResponse("", done(COMMAND_ID, 0)))) + .enqueue(200, envelope(signalResponse())); + enqueueShellDeletion(server); + + try (WinRMClient client = builder().build()) { + assertEquals(0, client.command("sort").stdin("").execute().exitCode()); + } + + final List chunks = server.stdinChunks(); + assertEquals(1, chunks.size()); + assertEquals(0, chunks.get(0).data().length); + assertTrue(chunks.get(0).end()); + } + + @Test + void remoteProcessStdinSupportsAWriteFlushReadRoundTrip() throws Exception { + enqueueStartup(); + server + // write + flush → one Send + .enqueue(200, envelope(sendResponse())) + // the REPL answers + .enqueue( + 200, + envelope(receiveResponse(stream("stdout", COMMAND_ID, "pong\r\n".getBytes(StandardCharsets.UTF_8)), null)) + ) + // closing stdin → the End-of-input Send + .enqueue(200, envelope(sendResponse())) + // the REPL exits + .enqueue(200, envelope(receiveResponse("", done(COMMAND_ID, 0)))) + .enqueue(200, envelope(signalResponse())); + enqueueShellDeletion(server); + + try (WinRMClient client = builder().build()) { + try (RemoteProcess process = client.command("repl.exe").start()) { + final BufferedWriter stdin = process.stdin(); + final BufferedReader stdout = process.stdout(); + + stdin.write("ping\n"); + stdin.flush(); + assertEquals("pong", stdout.readLine()); + + stdin.close(); + assertEquals(0, process.waitFor()); + } + } + + final List chunks = server.stdinChunks(); + assertEquals(2, chunks.size()); + assertArrayEquals("ping\n".getBytes(StandardCharsets.UTF_8), chunks.get(0).data()); + assertFalse(chunks.get(0).end()); + assertEquals(0, chunks.get(1).data().length); + assertTrue(chunks.get(1).end()); + + // Without programmatic pre-supplied input the historical console semantics are kept. + assertTrue( + server.decryptedRequests().get(1).contains("TRUE") + ); + } + + @Test + void interactiveWriteLargerThanOneEnvelopeIsSplit() throws Exception { + // 200 KiB of ASCII: 98 304 + 98 304 + 8 192 bytes across three Sends of one flush. + final char[] text = new char[200 * 1024]; + java.util.Arrays.fill(text, 'x'); + + enqueueStartup(); + server + .enqueue(200, envelope(sendResponse())) + .enqueue(200, envelope(sendResponse())) + .enqueue(200, envelope(sendResponse())) + .enqueue(200, envelope(signalResponse())); + enqueueShellDeletion(server); + + try (WinRMClient client = builder().build()) { + try (RemoteProcess process = client.command("consume.exe").start()) { + process.stdin().write(text); + process.stdin().flush(); + } + } + + final List chunks = server.stdinChunks(); + assertEquals(3, chunks.size()); + assertEquals(96 * 1024, chunks.get(0).data().length); + assertEquals(96 * 1024, chunks.get(1).data().length); + assertEquals(200 * 1024 - 2 * 96 * 1024, chunks.get(2).data().length); + assertFalse(chunks.get(0).end()); + assertFalse(chunks.get(1).end()); + assertFalse(chunks.get(2).end()); + } + + @Test + void earlyCloseWithStdinOpenLeavesNoStrayRequests() throws Exception { + enqueueStartup(); + server.enqueue(200, envelope(signalResponse())); + enqueueShellDeletion(server); + + try (WinRMClient client = builder().build()) { + final RemoteProcess process = client.command("repl.exe").start(); + final BufferedWriter stdin = process.stdin(); + stdin.write("never flushed"); + process.close(); + + final int requestsAfterClose = server.decryptedRequests().size(); + assertEquals(3, requestsAfterClose, () -> String.join("\n---\n", server.decryptedRequests())); + assertTrue(server.decryptedRequests().get(2).contains("signal/terminate")); + + // Ending the input after the close is silent cleanup: no request may leave the client, + // and the input it still held is discarded, like a java.lang.Process pipe's. + stdin.close(); + assertEquals(requestsAfterClose, server.decryptedRequests().size()); + + // Further writes are refused locally. + assertThrows(IOException.class, () -> writeAndFlush(stdin, "more")); + assertEquals(requestsAfterClose, server.decryptedRequests().size()); + } + } + + @Test + void stdinAfterCompletionIsSilentOnCloseAndRefusedOnWrite() throws Exception { + enqueueStartup(); + server + .enqueue(200, envelope(receiveResponse("", done(COMMAND_ID, 5)))) + .enqueue(200, envelope(signalResponse())); + enqueueShellDeletion(server); + + try (WinRMClient client = builder().build()) { + try (RemoteProcess process = client.command("run.exe").start()) { + assertEquals(5, process.waitFor()); + final int requestsAfterCompletion = server.decryptedRequests().size(); + + // Closing stdin after completion is a clean no-op... + process.stdin().close(); + assertEquals(requestsAfterCompletion, server.decryptedRequests().size()); + + // ...but writing to it is refused, locally. + assertThrows(IOException.class, () -> writeAndFlush(process.stdin(), "late")); + assertEquals(requestsAfterCompletion, server.decryptedRequests().size()); + } + } + } + + @Test + void interruptSendsCtrlCAndTheSessionContinues() throws Exception { + enqueueStartup(); + server + // the ctrl_c Signal + .enqueue(200, envelope(signalResponse())) + // the interrupted child's shell keeps running, then exits + .enqueue(200, envelope(receiveResponse("", done(COMMAND_ID, 130)))) + .enqueue(200, envelope(signalResponse())); + enqueueShellDeletion(server); + + try (WinRMClient client = builder().build()) { + try (RemoteProcess process = client.command("cmd.exe").start()) { + process.interrupt(); + assertEquals(130, process.waitFor()); + + // Once completed, further interrupts are local no-ops. + final int requests = server.decryptedRequests().size(); + process.interrupt(); + assertEquals(requests, server.decryptedRequests().size()); + } + } + + final List requests = server.decryptedRequests(); + assertTrue(requests.get(2).contains("signal/ctrl_c"), requests.get(2)); + assertTrue(requests.get(4).contains("signal/terminate"), requests.get(4)); + } + + /** Write then flush, unwrapping nothing: the assertion targets the raised exception type. */ + private static void writeAndFlush(final BufferedWriter writer, final String text) throws IOException { + writer.write(text); + writer.flush(); + } +} diff --git a/src/test/java/org/metricshub/winrm/StreamingApiTest.java b/src/test/java/org/metricshub/winrm/StreamingApiTest.java index dd07b45..375b8f8 100644 --- a/src/test/java/org/metricshub/winrm/StreamingApiTest.java +++ b/src/test/java/org/metricshub/winrm/StreamingApiTest.java @@ -519,14 +519,15 @@ void deadPeerCannotHoldABoundedWaitHostage() throws Exception { try (WinRMClient client = builder().build()) { try (RemoteProcess process = client.command("dead.exe").charset(StandardCharsets.UTF_8).start()) { final long start = System.nanoTime(); - assertThrows(WinRMTimeoutException.class, () -> process.waitFor(Duration.ofMillis(200))); + assertThrows(WinRMTimeoutException.class, () -> process.waitFor(Duration.ofMillis(1_000))); final long elapsedMillis = (System.nanoTime() - start) / 1_000_000; assertTrue( elapsedMillis < 3_000, "a dead peer must be detected at the bounded wait, not " + elapsedMillis + " ms later" ); - // The server was asked to answer within half the 200 ms budget. - assertTrue(server.decryptedRequests().get(2).contains("PT0.1S<")); + // The server was asked to answer at the WSMan floor (the budget minus the transit + // slack, never below 500 ms — the service would not answer earlier anyway). + assertTrue(server.decryptedRequests().get(2).contains("PT0.5S<")); } // close() terminated the command over a fresh connection (the abandoned one was dropped). final List requests = server.decryptedRequests(); @@ -566,11 +567,11 @@ void completionArrivingNearTheDeadlineIsStillReported() throws Exception { // The final Done-carrying response lands close to the wait's deadline: too little budget is // left for a wire Signal, but the completion happened WITHIN the wait and must be reported // as such — never as a spurious expiry. - server.enqueueDelayed(200, envelope(receiveResponse(stdoutChunk("late\n"), done(COMMAND_ID, 9))), 520); + server.enqueueDelayed(200, envelope(receiveResponse(stdoutChunk("late\n"), done(COMMAND_ID, 9))), 800); try (WinRMClient client = builder().build()) { try (RemoteProcess process = client.command("barely.exe").charset(StandardCharsets.UTF_8).start()) { - assertTrue(process.waitFor(Duration.ofMillis(600)), "completion within the wait must be reported"); + assertTrue(process.waitFor(Duration.ofMillis(1_000)), "completion within the wait must be reported"); assertEquals(9, process.exitCode()); assertEquals("late", process.stdout().readLine()); // The leftover budget could not fit a Signal round trip: none was sent. diff --git a/src/test/java/org/metricshub/winrm/cli/CliArgumentsTest.java b/src/test/java/org/metricshub/winrm/cli/CliArgumentsTest.java index 1f06362..0dc72d6 100644 --- a/src/test/java/org/metricshub/winrm/cli/CliArgumentsTest.java +++ b/src/test/java/org/metricshub/winrm/cli/CliArgumentsTest.java @@ -217,7 +217,7 @@ void rejectsInvalidArguments() { }, { "-P must be between 1 and 65535", concat(base, "-P", "65536", "command", "whoami") }, { "-t must be greater than zero", concat(base, "-t", "0", "command", "whoami") }, - { "missing subcommand (wql or command)", base }, + { "missing subcommand (wql, command, or shell)", base }, { "wql requires a query", concat(base, "wql", "") }, { "missing required option --hostname", diff --git a/src/test/java/org/metricshub/winrm/cli/InteractiveShellTest.java b/src/test/java/org/metricshub/winrm/cli/InteractiveShellTest.java new file mode 100644 index 0000000..1f55d12 --- /dev/null +++ b/src/test/java/org/metricshub/winrm/cli/InteractiveShellTest.java @@ -0,0 +1,299 @@ +package org.metricshub.winrm.cli; + +/*- + * ╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲ + * WinRM Java Client + * ჻჻჻჻჻჻ + * Copyright 2023 - 2026 MetricsHub + * ჻჻჻჻჻჻ + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * ╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱ + */ + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.metricshub.winrm.light.FakeWsmanResponses.commandResponse; +import static org.metricshub.winrm.light.FakeWsmanResponses.done; +import static org.metricshub.winrm.light.FakeWsmanResponses.enqueueShellDeletion; +import static org.metricshub.winrm.light.FakeWsmanResponses.envelope; +import static org.metricshub.winrm.light.FakeWsmanResponses.receiveResponse; +import static org.metricshub.winrm.light.FakeWsmanResponses.resourceCreated; +import static org.metricshub.winrm.light.FakeWsmanResponses.sendResponse; +import static org.metricshub.winrm.light.FakeWsmanResponses.signalResponse; +import static org.metricshub.winrm.light.FakeWsmanResponses.stream; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.PrintStream; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.ArrayDeque; +import java.util.Deque; +import java.util.List; +import java.util.concurrent.atomic.AtomicBoolean; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.metricshub.winrm.RemoteProcess; +import org.metricshub.winrm.WinRMClient; +import org.metricshub.winrm.light.FakeWsmanServer; + +/** + * Headless tests of the interactive {@code shell} pump (issue #136, phase 2) against + * {@link FakeWsmanServer}, with a scripted {@link InteractiveShell.LineSource} making every round + * deterministic: input line → Send on the wire, scripted output → local stdout, local EOF → + * {@code End="true"}, Ctrl+C → {@code ctrl_c} Signal, and exit-code propagation. + */ +class InteractiveShellTest { + + private static final String DOMAIN = "FAKE"; + private static final String USER = "user"; + private static final String PASSWORD = "s3cret-Passw0rd"; + + private static final String SHELL_ID = "SHELL-1"; + private static final String COMMAND_ID = "CMD-1"; + + private static final long POLL_MILLIS = 2_000L; + + private FakeWsmanServer server; + + @BeforeEach + void startServer() throws Exception { + server = new FakeWsmanServer(DOMAIN, USER, PASSWORD); + } + + @AfterEach + void stopServer() { + server.close(); + } + + private WinRMClient client() { + return WinRMClient + .builder("127.0.0.1") + .port(server.port()) + .credentials(DOMAIN + "\\" + USER, PASSWORD.toCharArray()) + .timeout(Duration.ofSeconds(10)) + .build(); + } + + private void enqueueStartup() { + server.enqueue(200, envelope(resourceCreated(SHELL_ID))).enqueue(200, envelope(commandResponse(COMMAND_ID))); + } + + /** A line source whose {@code nextLine()} answers are fully scripted, including "not yet". */ + private static final class ScriptedLines implements InteractiveShell.LineSource { + + private final Deque answers = new ArrayDeque<>(); + private boolean ended; + + /** The given lines, one per {@code nextLine()} call, then the end of the local input. */ + private static ScriptedLines endingAfter(final String... lines) { + final ScriptedLines scripted = new ScriptedLines(); + for (final String line : lines) { + scripted.answers.addLast(line); + } + scripted.ended = true; + return scripted; + } + + /** A local input that never produces anything and never ends (a silent terminal). */ + private static ScriptedLines silent() { + return new ScriptedLines(); + } + + @Override + public String nextLine() { + return answers.pollFirst(); + } + + @Override + public boolean endOfInput() { + return ended && answers.isEmpty(); + } + } + + @Test + void bridgesInputLinesAndOutputAndPropagatesTheExitCode() throws Exception { + enqueueStartup(); + server + // the typed line, CRLF-terminated, traveling WITH the End mark (the input ends after it) + .enqueue(200, envelope(sendResponse())) + // the shell answers and exits + .enqueue( + 200, + envelope( + receiveResponse( + stream("stdout", COMMAND_ID, "MODE PREPARE\r\n".getBytes(StandardCharsets.UTF_8)), + done(COMMAND_ID, 7) + ) + ) + ) + // completion cleanup: the bounded terminate Signal + .enqueue(200, envelope(signalResponse())); + enqueueShellDeletion(server); + + final ByteArrayOutputStream stdout = new ByteArrayOutputStream(); + final ByteArrayOutputStream stderr = new ByteArrayOutputStream(); + final int exitCode; + try (WinRMClient client = client()) { + try (RemoteProcess process = client.command("cmd.exe").start()) { + exitCode = InteractiveShell.bridge( + process, + ScriptedLines.endingAfter("MODE PREPARE"), + new PrintStream(stdout, true, "UTF-8"), + new PrintStream(stderr, true, "UTF-8"), + new AtomicBoolean(), + POLL_MILLIS + ); + } + } + + assertEquals(7, exitCode); + assertEquals("MODE PREPARE\r\n", stdout.toString("UTF-8")); + assertEquals("", stderr.toString("UTF-8")); + + final List chunks = server.stdinChunks(); + assertEquals(1, chunks.size()); + assertArrayEquals("MODE PREPARE\r\n".getBytes(StandardCharsets.UTF_8), chunks.get(0).data()); + assertTrue(chunks.get(0).end()); + } + + @Test + void forwardsCtrlCAsTheCtrlCSignalAndTheSessionSurvivesIt() throws Exception { + enqueueStartup(); + server + // the forwarded Ctrl+C + .enqueue(200, envelope(signalResponse())) + // the interrupted child returns to the prompt... + .enqueue( + 200, + envelope(receiveResponse(stream("stdout", COMMAND_ID, "^C\r\nC:\\>".getBytes(StandardCharsets.UTF_8)), null)) + ) + // ...and the shell later exits on its own + .enqueue(200, envelope(receiveResponse("", done(COMMAND_ID, 0)))) + .enqueue(200, envelope(signalResponse())); + enqueueShellDeletion(server); + + final ByteArrayOutputStream stdout = new ByteArrayOutputStream(); + final AtomicBoolean interruptRequested = new AtomicBoolean(true); + final int exitCode; + try (WinRMClient client = client()) { + try (RemoteProcess process = client.command("cmd.exe").start()) { + exitCode = InteractiveShell.bridge( + process, + ScriptedLines.silent(), + new PrintStream(stdout, true, "UTF-8"), + new PrintStream(new ByteArrayOutputStream(), true, "UTF-8"), + interruptRequested, + POLL_MILLIS + ); + } + } + + assertEquals(0, exitCode); + assertFalse(interruptRequested.get(), "the flag must be cleared once forwarded"); + assertTrue(stdout.toString("UTF-8").contains("^C")); + + final List requests = server.decryptedRequests(); + assertTrue(requests.get(2).contains("signal/ctrl_c"), requests.get(2)); + // The session went on after the interrupt: output flowed, and only completion terminated it. + assertTrue(requests.get(5).contains("signal/terminate"), requests.get(5)); + } + + @Test + void echoesOutputArrivingBeforeAnyInput() throws Exception { + enqueueStartup(); + server + // round 1: no local input yet — the poll picks up the shell banner + .enqueue( + 200, + envelope( + receiveResponse( + stream("stdout", COMMAND_ID, "Microsoft Windows\r\nC:\\>".getBytes(StandardCharsets.UTF_8)), + null + ) + ) + ) + // round 2: the user typed "exit" + .enqueue(200, envelope(sendResponse())) + .enqueue(200, envelope(receiveResponse("", done(COMMAND_ID, 0)))) + .enqueue(200, envelope(signalResponse())); + enqueueShellDeletion(server); + + // Nothing on the first nextLine() poll, then "exit", then the local input keeps going + // (never ends): the End mark must NOT be sent, completion alone ends the bridge. + final ByteArrayOutputStream stdout = new ByteArrayOutputStream(); + final int exitCode; + try (WinRMClient client = client()) { + try (RemoteProcess process = client.command("cmd.exe").start()) { + final PrintStream out = new PrintStream(stdout, true, "UTF-8"); + exitCode = InteractiveShell.bridge( + process, + new InteractiveShell.LineSource() { + private int round; + + @Override + public String nextLine() { + round++; + // Round 1 (and the drain call right after "exit"): nothing queued. + return round == 2 ? "exit" : null; + } + + @Override + public boolean endOfInput() { + return false; + } + }, + out, + new PrintStream(new ByteArrayOutputStream(), true, "UTF-8"), + new AtomicBoolean(), + POLL_MILLIS + ); + } + } + + assertEquals(0, exitCode); + assertTrue(stdout.toString("UTF-8").startsWith("Microsoft Windows")); + + // The banner traveled BEFORE the input: Receive first, the Send only on the next round. + final List requests = server.decryptedRequests(); + assertTrue(requests.get(2).contains(":Receive>"), requests.get(2)); + assertTrue(requests.get(3).contains(":Send>"), requests.get(3)); + + final List chunks = server.stdinChunks(); + assertEquals(1, chunks.size()); + assertArrayEquals("exit\r\n".getBytes(StandardCharsets.UTF_8), chunks.get(0).data()); + assertFalse(chunks.get(0).end()); + } + + @Test + void queuedLineSourceDeliversLinesInOrderThenTheEndOfInput() { + // The production LineSource behind InteractiveShell.run: fed by the helper thread reading + // the local standard input. Here the whole stream is read synchronously (readAll returns + // once the input ends), making the outcome deterministic. + final InteractiveShell.QueuedLineSource lines = new InteractiveShell.QueuedLineSource(); + lines.readAll(new ByteArrayInputStream("first\nsecond\r\nexit\n".getBytes(StandardCharsets.UTF_8))); + + assertFalse(lines.endOfInput(), "queued lines must be consumed before the end of input is reported"); + assertEquals("first", lines.nextLine()); + assertEquals("second", lines.nextLine()); + assertFalse(lines.endOfInput()); + assertEquals("exit", lines.nextLine()); + assertNull(lines.nextLine()); + assertTrue(lines.endOfInput()); + assertNull(lines.nextLine()); + } +} diff --git a/src/test/java/org/metricshub/winrm/cli/WinRmCliTest.java b/src/test/java/org/metricshub/winrm/cli/WinRmCliTest.java index 016f9b7..a431902 100644 --- a/src/test/java/org/metricshub/winrm/cli/WinRmCliTest.java +++ b/src/test/java/org/metricshub/winrm/cli/WinRmCliTest.java @@ -52,6 +52,7 @@ void helpAndVersionDoNotConnect() throws Exception { final Invocation help = invoke(new String[] { "--help" }, arguments -> failingRemote()); assertEquals(0, help.exitCode); assertTrue(help.stdout.contains("command|cmd|exec|run")); + assertTrue(help.stdout.contains("[options] shell")); assertTrue(help.stdout.contains("-P, --port")); assertTrue(help.stdout.contains("--kerberos-kdc")); assertTrue(help.stdout.contains("--kerberos-realm")); @@ -107,6 +108,52 @@ void forwardsCommandStreamsAndExitCode() throws Exception { assertEquals("echo \"hello world\"", remote.command); } + @Test + void shellSubcommandBridgesTheRemoteShellAndPropagatesItsExitCode() throws Exception { + final FakeRemote remote = new FakeRemote(); + remote.stdoutChunks = List.of("Microsoft Windows\r\nC:\\>"); + remote.commandExitCode = 3; + + final Invocation invocation = invoke(concat(REQUIRED, "shell"), args -> remote); + + assertEquals(3, invocation.exitCode); + assertTrue(remote.shellStarted); + assertEquals("Microsoft Windows\r\nC:\\>", invocation.stdout); + assertTrue(remote.closed); + } + + @Test + void shellTakesNoArgument() throws Exception { + final Invocation invocation = invoke(concat(REQUIRED, "shell", "cmd.exe"), args -> failingRemote()); + assertEquals(WinRmCli.EXIT_USAGE, invocation.exitCode); + assertTrue(invocation.stderr.contains("shell takes no argument")); + } + + @Test + void pipedLocalStandardInputIsForwardedToTheCommandButAConsoleIsNot() throws Exception { + final java.io.ByteArrayInputStream piped = new java.io.ByteArrayInputStream( + "beta\nalpha\n".getBytes(StandardCharsets.UTF_8) + ); + final FakeRemote remote = new FakeRemote(); + Invocation invocation = invoke( + concat(REQUIRED, "command", "sort"), + args -> remote, + new WinRmCli.LocalInput(false, piped) + ); + assertEquals(0, invocation.exitCode); + assertEquals(piped, remote.forwardedStdin); + + // From an interactive console, nothing is forwarded implicitly. + final FakeRemote interactive = new FakeRemote(); + invocation = invoke( + concat(REQUIRED, "command", "sort"), + args -> interactive, + new WinRmCli.LocalInput(true, piped) + ); + assertEquals(0, invocation.exitCode); + org.junit.jupiter.api.Assertions.assertNull(interactive.forwardedStdin); + } + @Test void mapsUsageTimeoutConnectionAuthenticationAndProtocolFailures() throws Exception { assertEquals( @@ -333,6 +380,34 @@ private static Invocation invoke( } } + private static Invocation invoke( + final String[] arguments, + final WinRmCli.RemoteFactory factory, + final WinRmCli.LocalInput localInput + ) throws Exception { + final ByteArrayOutputStream stdoutBytes = new ByteArrayOutputStream(); + final ByteArrayOutputStream stderrBytes = new ByteArrayOutputStream(); + try ( + PrintStream stdout = new PrintStream(stdoutBytes, true, StandardCharsets.UTF_8.name()); + PrintStream stderr = new PrintStream(stderrBytes, true, StandardCharsets.UTF_8.name())) { + final int exitCode = WinRmCli.run( + arguments, + stdout, + stderr, + factory, + () -> { + throw new AssertionError("No password prompt expected"); + }, + localInput + ); + return new Invocation( + exitCode, + stdoutBytes.toString(StandardCharsets.UTF_8.name()), + stderrBytes.toString(StandardCharsets.UTF_8.name()) + ); + } + } + private static String[] concat(final String[] prefix, final String... suffix) { final String[] result = new String[prefix.length + suffix.length]; System.arraycopy(prefix, 0, result, 0, prefix.length); @@ -369,6 +444,8 @@ private static final class FakeRemote implements WinRmCli.RemoteOperations { private int commandExitCode; private Exception failure; private String command; + private java.io.InputStream forwardedStdin; + private boolean shellStarted; private boolean closed; @Override @@ -382,16 +459,35 @@ public void streamWql(final String query, final long timeout, final Consumer stdoutConsumer, final Consumer stderrConsumer ) throws Exception { this.command = command; + this.forwardedStdin = stdin; failIfConfigured(); stdoutChunks.forEach(stdoutConsumer); stderrChunks.forEach(stderrConsumer); return commandExitCode; } + @Override + public int shell( + final long timeout, + final java.io.InputStream localInput, + final java.io.PrintStream out, + final java.io.PrintStream err, + final java.util.concurrent.atomic.AtomicBoolean interruptRequested + ) throws Exception { + shellStarted = true; + failIfConfigured(); + stdoutChunks.forEach(chunk -> { + out.print(chunk); + out.flush(); + }); + return commandExitCode; + } + @Override public void close() { closed = true; diff --git a/src/test/java/org/metricshub/winrm/light/FakeWsmanResponses.java b/src/test/java/org/metricshub/winrm/light/FakeWsmanResponses.java index af9da42..818d509 100644 --- a/src/test/java/org/metricshub/winrm/light/FakeWsmanResponses.java +++ b/src/test/java/org/metricshub/winrm/light/FakeWsmanResponses.java @@ -199,6 +199,15 @@ public static String signalResponse() { return ""; } + /** + * The SendResponse body answering a stdin Send. + * + * @return the SendResponse body + */ + public static String sendResponse() { + return ""; + } + /** * A complete WSMan fault envelope (not to be wrapped in {@link #envelope(String)}). * diff --git a/src/test/java/org/metricshub/winrm/light/FakeWsmanServer.java b/src/test/java/org/metricshub/winrm/light/FakeWsmanServer.java index fe9e157..56c99b4 100644 --- a/src/test/java/org/metricshub/winrm/light/FakeWsmanServer.java +++ b/src/test/java/org/metricshub/winrm/light/FakeWsmanServer.java @@ -186,6 +186,57 @@ public List decryptedRequests() { return new ArrayList<>(decryptedRequests); } + /** One stdin chunk carried by a WSMan Send request: its decoded bytes and its End flag. */ + public static final class StdinChunk { + + private final byte[] data; + private final boolean end; + + private StdinChunk(final byte[] data, final boolean end) { + this.data = data; + this.end = end; + } + + /** + * @return the decoded stdin bytes of this chunk + */ + public byte[] data() { + return data.clone(); + } + + /** + * @return whether the chunk was flagged as the end of input + */ + public boolean end() { + return end; + } + } + + /** + * The stdin chunks received so far through WSMan Send requests, in order: base64-decoded + * content and End flag, extracted from the decrypted request bodies. + * + * @return the stdin chunks, in the order they were received + */ + public List stdinChunks() { + final List chunks = new ArrayList<>(); + final java.util.regex.Pattern stream = java.util.regex.Pattern.compile( + "]*)>([^<]*)" + ); + for (final String request : decryptedRequests) { + final java.util.regex.Matcher matcher = stream.matcher(request); + while (matcher.find()) { + chunks.add( + new StdinChunk( + Base64.getDecoder().decode(matcher.group(2)), + matcher.group(1).contains("End=\"true\"") + ) + ); + } + } + return chunks; + } + @Override public void close() { closed = true; From e9a471a4bda03356a79e25e227f0ac1ec0c757f9 Mon Sep 17 00:00:00 2001 From: Bertrand Martin Date: Wed, 29 Jul 2026 00:20:21 +0200 Subject: [PATCH 02/17] Address Codex review: pipe-mode declaration, stateful stdin encoding, End tracking, shell timeout floor - stdin() (no argument) on CommandRequest declares interactive input for start(): pipe semantics (WINRS_CONSOLEMODE_STDIN=FALSE) without pre-supplied content, so RemoteProcess.stdin().close() actually delivers EOF to filters like sort. execute() rejects the declaration up front - it cannot take interactive input, and a command waiting on a never-fed pipe would hang for a whole timeout. Plain start() keeps the historical console semantics (what the interactive CLI shell wants). - New ChunkEncoder (the mirror image of ChunkDecoder) carries the charset encoder state across stdin flushes: a byte-order mark is emitted once, not per flush, and a surrogate pair split by a flush boundary is withheld and completed by the next write instead of becoming two replacements. - RemoteCommand.send() tracks the delivered End mark and rejects later sends locally, so direct CommandCursor users cannot append input after EOF. - The CLI rejects a shell --timeout below 1000 ms: it caps each bounded poll of the session pump, and a poll below the WSMan 500 ms floor plus transit slack never reaches the wire - the shell would spin locally without ever fetching output. Co-Authored-By: Claude Fable 5 --- README.md | 2 +- .../org/metricshub/winrm/ChunkEncoder.java | 92 ++++++++++++++++++ .../org/metricshub/winrm/CommandRequest.java | 43 ++++++++- .../org/metricshub/winrm/RemoteProcess.java | 13 ++- .../metricshub/winrm/cli/CliArguments.java | 10 ++ .../metricshub/winrm/light/WsmanClient.java | 10 ++ src/site/markdown/cli.md | 4 +- src/site/markdown/commands.md | 13 ++- .../metricshub/winrm/CommandStdinTest.java | 94 ++++++++++++++++++- .../metricshub/winrm/cli/WinRmCliTest.java | 13 +++ 10 files changed, 282 insertions(+), 12 deletions(-) create mode 100644 src/main/java/org/metricshub/winrm/ChunkEncoder.java diff --git a/README.md b/README.md index 91a1f48..63a6b92 100644 --- a/README.md +++ b/README.md @@ -131,7 +131,7 @@ try (RemoteProcess p = client.command("wevtutil qe System /f:text").start()) { CommandResult sorted = client.command("sort").stdin(Path.of("data.txt")).execute(); // ...or written interactively through the process handle (flush() delivers, close() is EOF): -try (RemoteProcess p = client.command("some-repl.exe").start()) { +try (RemoteProcess p = client.command("some-repl.exe").stdin().start()) { try (BufferedWriter in = p.stdin()) { in.write("first request\n"); in.flush(); diff --git a/src/main/java/org/metricshub/winrm/ChunkEncoder.java b/src/main/java/org/metricshub/winrm/ChunkEncoder.java new file mode 100644 index 0000000..d272082 --- /dev/null +++ b/src/main/java/org/metricshub/winrm/ChunkEncoder.java @@ -0,0 +1,92 @@ +package org.metricshub.winrm; + +/*- + * ╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲ + * WinRM Java Client + * ჻჻჻჻჻჻ + * Copyright (C) 2023 - 2026 MetricsHub + * ჻჻჻჻჻჻ + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * ╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱ + */ + +import java.io.ByteArrayOutputStream; +import java.nio.ByteBuffer; +import java.nio.CharBuffer; +import java.nio.charset.Charset; +import java.nio.charset.CharsetEncoder; +import java.nio.charset.CoderResult; +import java.nio.charset.CodingErrorAction; + +/** + * Incremental, stateful charset encoding for streamed command input — the mirror image of + * {@link ChunkDecoder}. The text fed to a command's standard input arrives in flush-sized pieces, + * and a per-piece {@code String.getBytes(Charset)} would not encode it the way one whole-string + * encode would: a stateful charset (e.g. UTF-16) writes its byte-order mark on every call instead + * of once, and a surrogate pair split across two pieces becomes two replacement characters. A + * single {@link CharsetEncoder} carried across calls keeps that state: the mark is emitted once, + * and a trailing lone high surrogate is withheld until the character is completed by the next + * piece. Malformed and unmappable input is replaced, matching {@link String#getBytes(Charset)}. + */ +final class ChunkEncoder { + + private final CharsetEncoder encoder; + + // Unencoded tail of the previous piece — a lone high surrogate — replayed in front of the + // next piece. + private char[] pending = new char[0]; + + ChunkEncoder(final Charset charset) { + this.encoder = charset + .newEncoder() + .onMalformedInput(CodingErrorAction.REPLACE) + .onUnmappableCharacter(CodingErrorAction.REPLACE); + } + + /** + * Encode the next piece of input, returning the bytes that are complete so far. A trailing + * lone high surrogate is withheld until the next call completes the pair — unless + * {@code endOfInput} is set, which flushes the encoder: the dangling half becomes a + * replacement, exactly as a whole-string encode would render it. + */ + byte[] encode(final String piece, final boolean endOfInput) { + final CharBuffer in = CharBuffer.allocate(pending.length + piece.length()); + in.put(pending); + in.put(piece); + in.flip(); + + final ByteArrayOutputStream bytes = new ByteArrayOutputStream(); + final ByteBuffer out = ByteBuffer.allocate(Math.max(16, in.remaining() * 4)); + CoderResult result; + do { + result = encoder.encode(in, out, endOfInput); + bytes.write(out.array(), 0, out.position()); + out.clear(); + // With REPLACE in force the only non-underflow result is overflow: loop for more room. + } while (result.isOverflow()); + + if (endOfInput) { + do { + result = encoder.flush(out); + bytes.write(out.array(), 0, out.position()); + out.clear(); + } while (result.isOverflow()); + pending = new char[0]; + } else { + // Whatever the encoder left in the input is an incomplete character: carry it over. + pending = new char[in.remaining()]; + in.get(pending); + } + return bytes.toByteArray(); + } +} diff --git a/src/main/java/org/metricshub/winrm/CommandRequest.java b/src/main/java/org/metricshub/winrm/CommandRequest.java index 457831c..4d47388 100644 --- a/src/main/java/org/metricshub/winrm/CommandRequest.java +++ b/src/main/java/org/metricshub/winrm/CommandRequest.java @@ -57,6 +57,7 @@ public final class CommandRequest { private Consumer stdoutConsumer; private Consumer stderrConsumer; private StdinSource stdinSource; + private boolean pipeStdin; /** A deferred source of pre-supplied standard input, opened when the command starts. */ @FunctionalInterface @@ -173,6 +174,35 @@ public CommandRequest upload(final Path... files) { public CommandRequest stdin(final String text) { Utils.checkNonNull(text, "text"); this.stdinSource = charset -> new ByteArrayInputStream(text.getBytes(charset)); + this.pipeStdin = true; + return this; + } + + /** + * Declare that the command will be fed standard input interactively, through + * {@link RemoteProcess#stdin()}, without pre-supplying it: the remote stdin switches to + * pipe semantics ({@code WINRS_CONSOLEMODE_STDIN=FALSE}), so closing the writer + * actually delivers EOF — a console-mode stdin never reaches EOF for tools like {@code sort} + * or {@code findstr}. + * + *

{@code
+	 * try (RemoteProcess p = client.command("sort").stdin().start()) {
+	 * 	try (BufferedWriter in = p.stdin()) {
+	 * 		in.write("beta\nalpha\n");
+	 * 	} // EOF: sort now sorts and exits
+	 * 	p.waitFor();
+	 * }
+	 * }
+ * + * Only meaningful with {@link #start()} — {@link #execute()} cannot take interactive input and + * rejects a request configured this way. Without any {@code stdin} call, the historical + * console semantics are kept (what a command that never reads input, and the interactive CLI + * shell, want). + * + * @return this request + */ + public CommandRequest stdin() { + this.pipeStdin = true; return this; } @@ -187,6 +217,7 @@ public CommandRequest stdin(final String text) { public CommandRequest stdin(final Path file) { Utils.checkNonNull(file, "file"); this.stdinSource = charset -> Files.newInputStream(file); + this.pipeStdin = true; return this; } @@ -202,6 +233,7 @@ public CommandRequest stdin(final Path file) { public CommandRequest stdin(final InputStream stream) { Utils.checkNonNull(stream, "stream"); this.stdinSource = charset -> stream; + this.pipeStdin = true; return this; } @@ -256,6 +288,13 @@ public CommandRequest onStderr(final Consumer consumer) { * @throws org.metricshub.winrm.exceptions.WinRMClientException for any other failure */ public CommandResult execute() { + if (pipeStdin && stdinSource == null) { + // stdin() without content promises interactive input, which only start() can take: a + // command waiting on its never-fed pipe would hang for a whole timeout. + throw new IllegalStateException( + "stdin() without content declares interactive input: use start(), or supply the input to stdin(...)." + ); + } final long start = Utils.getCurrentTimeMillis(); final long timeoutMillis = WinRMClient.toMillis(timeout); try { @@ -333,7 +372,7 @@ public RemoteProcess start() { // round trip (inactivity), not the overall exchange the preparation steps count against. final CommandCursor cursor = client .executor() - .startCommand(prepared.command, prepared.workingDirectory, timeoutMillis, stdinSource == null); + .startCommand(prepared.command, prepared.workingDirectory, timeoutMillis, !pipeStdin); if (stdinSource != null) { try { feedStdin(cursor, prepared.charset); @@ -411,7 +450,7 @@ private CommandResult drainWithCallbacks(final Prepared prepared, final long tim final StringBuilder stderr = new StringBuilder(); try ( CommandCursor cursor = client.executor() - .startCommand(prepared.command, prepared.workingDirectory, timeoutMillis, stdinSource == null)) { + .startCommand(prepared.command, prepared.workingDirectory, timeoutMillis, !pipeStdin)) { if (stdinSource != null) { feedStdin(cursor, prepared.charset); } diff --git a/src/main/java/org/metricshub/winrm/RemoteProcess.java b/src/main/java/org/metricshub/winrm/RemoteProcess.java index 189ed7b..959a1e8 100644 --- a/src/main/java/org/metricshub/winrm/RemoteProcess.java +++ b/src/main/java/org/metricshub/winrm/RemoteProcess.java @@ -83,10 +83,10 @@ public final class RemoteProcess implements AutoCloseable { private final CommandCursor cursor; private final String hostname; private final Duration timeout; - private final Charset charset; private final ChunkDecoder stdoutDecoder; private final ChunkDecoder stderrDecoder; + private final ChunkEncoder stdinEncoder; // Decoded output that has arrived but has not been read yet, per channel. private final StringBuilder stdoutPending = new StringBuilder(); @@ -118,9 +118,9 @@ public final class RemoteProcess implements AutoCloseable { this.cursor = cursor; this.hostname = hostname; this.timeout = timeout; - this.charset = charset; this.stdoutDecoder = new ChunkDecoder(charset); this.stderrDecoder = new ChunkDecoder(charset); + this.stdinEncoder = new ChunkEncoder(charset); this.stdout = new BufferedReader(new ChannelReader(stdoutPending)); this.stderr = new BufferedReader(new ChannelReader(stderrPending)); this.stdin = new BufferedWriter(new StdinWriter()); @@ -160,6 +160,11 @@ public BufferedReader stderr() { * When the request pre-supplied the input ({@code stdin(...)} on the builder), that input was * already delivered in full: this writer is then closed from the start. *

+ * A command that must observe the EOF — a filter like {@code sort} that only acts once + * its input ends — needs pipe semantics: declare the interactive input with the no-argument + * {@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. + *

* 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}. @@ -385,7 +390,9 @@ private synchronized void sendStdin(final boolean end) { } throw new IllegalStateException("The command's standard input has already been closed."); } - final byte[] bytes = stdinPending.toString().getBytes(charset); + // Stateful encoding: a charset mark is emitted once (not per flush) and a surrogate pair + // split across two flushes is withheld until complete, exactly as one whole-string encode. + final byte[] bytes = stdinEncoder.encode(stdinPending.toString(), end); stdinPending.setLength(0); if (finished) { if (end) { diff --git a/src/main/java/org/metricshub/winrm/cli/CliArguments.java b/src/main/java/org/metricshub/winrm/cli/CliArguments.java index 4a53ac3..1cb46da 100644 --- a/src/main/java/org/metricshub/winrm/cli/CliArguments.java +++ b/src/main/java/org/metricshub/winrm/cli/CliArguments.java @@ -49,6 +49,13 @@ enum Operation { static final long DEFAULT_TIMEOUT = 60_000L; + /** + * Smallest usable {@code --timeout} for the interactive shell: it caps each bounded poll of + * the session pump, and a poll below the WSMan floor (a 500 ms server-side hold plus transit + * slack) never reaches the wire — the shell would spin locally without ever fetching output. + */ + static final long MIN_SHELL_TIMEOUT = 1_000L; + private final Operation operation; private final String hostname; private final String username; @@ -228,6 +235,9 @@ private static void validate(final Builder builder) throws CliUsageException { if (builder.permissiveHttps && !builder.https) { throw new CliUsageException("--https-permissive requires --https"); } + if (builder.operation == Operation.SHELL && builder.timeout < MIN_SHELL_TIMEOUT) { + throw new CliUsageException("shell requires --timeout of at least " + MIN_SHELL_TIMEOUT + " milliseconds"); + } } private static void validateKerberosConfiguration(final Builder builder) throws CliUsageException { diff --git a/src/main/java/org/metricshub/winrm/light/WsmanClient.java b/src/main/java/org/metricshub/winrm/light/WsmanClient.java index a703c63..b37deea 100644 --- a/src/main/java/org/metricshub/winrm/light/WsmanClient.java +++ b/src/main/java/org/metricshub/winrm/light/WsmanClient.java @@ -545,6 +545,10 @@ final class Chunk { private Integer exitCode; private boolean finished; + // The end-of-input Send was delivered: the remote stdin reached EOF, later sends are a + // caller bug and are rejected locally instead of drawing a server fault. + private boolean stdinEnded; + private RemoteCommand(final String commandId, final long operationTimeoutMs, final boolean failOnQuietTimeout) { this.commandId = commandId; this.operationTimeoutMs = operationTimeoutMs; @@ -653,6 +657,9 @@ void send(final byte[] data, final boolean end) throws Exception { if (finished || exitCode != null) { throw new IllegalStateException("The command has completed: its standard input is closed."); } + if (stdinEnded) { + throw new IllegalStateException("The command's standard input has already been closed."); + } if (data.length == 0 && !end) { // Nothing to say and no EOF to announce: an empty Send would be a pure round trip. return; @@ -673,6 +680,9 @@ void send(final byte[] data, final boolean end) throws Exception { } 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 17ef70e..a7ed564 100644 --- a/src/site/markdown/cli.md +++ b/src/site/markdown/cli.md @@ -129,7 +129,9 @@ The remote exit code is propagated through the usual [exit-code contract](#Exit_ and ends the CLI (terminating the remote shell with it). * **An idle session does not time out** — `-t`/`--timeout` bounds each protocol round trip, and every round trip of an idle session completes with the protocol's "nothing yet" answer. Only a - server that stops answering altogether trips the timeout. + server that stops answering altogether trips the timeout. A `--timeout` below 1000 ms is + rejected for `shell`: one poll round trip cannot complete faster (the WSMan service holds a + bounded request for at least 500 ms before answering "nothing yet"). ## Timeout semantics diff --git a/src/site/markdown/commands.md b/src/site/markdown/commands.md index 53aa1b0..8f94fa5 100644 --- a/src/site/markdown/commands.md +++ b/src/site/markdown/commands.md @@ -48,6 +48,7 @@ Everything between `command(...)` and `execute()` is optional: | `workingDirectory(String)` | remote default | Working directory of the remote process. The remote shell is created by the client's **first** command and reused afterward, so this only takes effect on that first command. | | `upload(Path...)` | none | Local files to copy to the host before running (see below). | | `stdin(String)` / `stdin(Path)` / `stdin(InputStream)` | none | Standard input fed to the command — the remote equivalent of a `< file` redirection (see below). | +| `stdin()` | console semantics | Declare interactive input through `RemoteProcess.stdin()` (with `start()`): pipe semantics without pre-supplied content (see below). | | `onStdout(Consumer)` / `onStderr(Consumer)` | none | Callbacks receiving each chunk of output live while `execute()` runs (see below). | ## The result @@ -123,10 +124,12 @@ semantics are kept. For a request started with `start()`, `RemoteProcess.stdin()` completes the `java.lang.Process` shape: written text is buffered locally, `flush()` carries it to the host (one WSMan `Send`), -and `close()` marks the end of input: +and `close()` marks the end of input. Declare the interactive input with the no-argument +`stdin()` on the request — it switches the remote stdin to pipe semantics, so closing the writer +actually delivers EOF: ```java -try (RemoteProcess p = client.command("some-repl.exe").start()) { +try (RemoteProcess p = client.command("some-repl.exe").stdin().start()) { try (BufferedWriter in = p.stdin()) { in.write("first request\n"); in.flush(); // delivers the buffered text @@ -136,6 +139,12 @@ try (RemoteProcess p = client.command("some-repl.exe").start()) { } ``` +Without the declaration, the remote stdin keeps the historical console semantics: writes are +still delivered, but a filter waiting for its input to *end* (`sort`, `findstr`, `more`) never +sees the EOF. Text is encoded statefully across flushes — a charset mark is emitted once and a +surrogate pair split by a flush is completed by the next write, exactly as one whole-string +encode. + Writes and reads alternate on the caller's thread, exactly like `java.lang.Process` pipes — including the classic deadlock, which is the caller's to avoid: blocking on a read while the remote command itself is blocked waiting for input (or feeding a large input to a command that diff --git a/src/test/java/org/metricshub/winrm/CommandStdinTest.java b/src/test/java/org/metricshub/winrm/CommandStdinTest.java index 55303cd..0598451 100644 --- a/src/test/java/org/metricshub/winrm/CommandStdinTest.java +++ b/src/test/java/org/metricshub/winrm/CommandStdinTest.java @@ -225,7 +225,7 @@ void remoteProcessStdinSupportsAWriteFlushReadRoundTrip() throws Exception { enqueueShellDeletion(server); try (WinRMClient client = builder().build()) { - try (RemoteProcess process = client.command("repl.exe").start()) { + try (RemoteProcess process = client.command("repl.exe").stdin().start()) { final BufferedWriter stdin = process.stdin(); final BufferedReader stdout = process.stdout(); @@ -245,9 +245,10 @@ void remoteProcessStdinSupportsAWriteFlushReadRoundTrip() throws Exception { assertEquals(0, chunks.get(1).data().length); assertTrue(chunks.get(1).end()); - // Without programmatic pre-supplied input the historical console semantics are kept. + // The no-argument stdin() declared interactive input: pipe semantics on the wire, so the + // End mark above actually delivered EOF to the command. assertTrue( - server.decryptedRequests().get(1).contains("TRUE") + server.decryptedRequests().get(1).contains("FALSE") ); } @@ -359,6 +360,93 @@ void interruptSendsCtrlCAndTheSessionContinues() throws Exception { final List requests = server.decryptedRequests(); assertTrue(requests.get(2).contains("signal/ctrl_c"), requests.get(2)); assertTrue(requests.get(4).contains("signal/terminate"), requests.get(4)); + + // Without any stdin declaration the historical console semantics are kept. + assertTrue( + requests.get(1).contains("TRUE"), + requests.get(1) + ); + } + + @Test + void stdinEncodingIsStatefulAcrossFlushes() throws Exception { + // U+1F600 as its surrogate halves: the pair is split across two flushes below. + final char high = (char) 0xD83D; + final char low = (char) 0xDE00; + final String emoji = new String(new char[] { high, low }); + + enqueueStartup(); + server + .enqueue(200, envelope(sendResponse())) + .enqueue(200, envelope(sendResponse())) + .enqueue(200, envelope(sendResponse())) + .enqueue(200, envelope(signalResponse())); + enqueueShellDeletion(server); + + try (WinRMClient client = builder().build()) { + try ( + RemoteProcess process = client.command("consume.exe") + .charset(StandardCharsets.UTF_16) + .stdin() + .start()) { + final BufferedWriter stdin = process.stdin(); + // Two flushes of a charset with a byte-order mark, the second one ending on a lone + // high surrogate: incremental encoding must yield exactly one whole-string encode. + stdin.write("ab"); + stdin.flush(); + stdin.write("cd"); + stdin.write(high); + stdin.flush(); + stdin.write(low); + stdin.close(); + } + } + + final List chunks = server.stdinChunks(); + assertEquals(3, chunks.size()); + final ByteArrayOutputStream reassembled = new ByteArrayOutputStream(); + for (final FakeWsmanServer.StdinChunk chunk : chunks) { + reassembled.writeBytes(chunk.data()); + } + assertArrayEquals(("abcd" + emoji).getBytes(StandardCharsets.UTF_16), reassembled.toByteArray()); + // The byte-order mark travels once, with the first chunk only. + assertArrayEquals("ab".getBytes(StandardCharsets.UTF_16), chunks.get(0).data()); + // The half pair was withheld at its flush and completed by the final write: the second + // chunk carries "cd" alone, the last one the whole character. + assertArrayEquals("cd".getBytes(StandardCharsets.UTF_16BE), chunks.get(1).data()); + assertArrayEquals(emoji.getBytes(StandardCharsets.UTF_16BE), chunks.get(2).data()); + assertTrue(chunks.get(2).end()); + } + + @Test + void cursorRejectsInputAfterTheEndMark() throws Exception { + enqueueStartup(); + server.enqueue(200, envelope(sendResponse())).enqueue(200, envelope(signalResponse())); + enqueueShellDeletion(server); + + try (WinRMClient client = builder().build()) { + try (CommandCursor cursor = client.executor().startCommand("sort", null, 10_000, false)) { + cursor.send("all of it".getBytes(StandardCharsets.UTF_8), true); + final int requestsAfterEnd = server.decryptedRequests().size(); + + // The remote stdin reached EOF: later input is a caller bug, rejected locally. + assertThrows( + IllegalStateException.class, + () -> cursor.send("too late".getBytes(StandardCharsets.UTF_8), false) + ); + assertEquals(requestsAfterEnd, server.decryptedRequests().size()); + } + } + } + + @Test + void interactiveStdinDeclarationIsRejectedByExecute() { + try (WinRMClient client = builder().build()) { + // execute() cannot take interactive input: the misconfiguration is rejected before any + // request leaves the client — a command waiting on a never-fed pipe would hang instead. + assertThrows(IllegalStateException.class, () -> client.command("sort").stdin().execute()); + } + assertEquals(0, server.decryptedRequests().size()); } /** Write then flush, unwrapping nothing: the assertion targets the raised exception type. */ diff --git a/src/test/java/org/metricshub/winrm/cli/WinRmCliTest.java b/src/test/java/org/metricshub/winrm/cli/WinRmCliTest.java index a431902..ae49045 100644 --- a/src/test/java/org/metricshub/winrm/cli/WinRmCliTest.java +++ b/src/test/java/org/metricshub/winrm/cli/WinRmCliTest.java @@ -129,6 +129,19 @@ void shellTakesNoArgument() throws Exception { assertTrue(invocation.stderr.contains("shell takes no argument")); } + @Test + void shellRejectsATimeoutBelowThePollFloor() throws Exception { + // A --timeout below one poll round trip would make the session pump spin locally without + // ever fetching output: the WSMan service holds a bounded Receive for at least 500 ms. + final Invocation invocation = invoke(concat(REQUIRED, "-t", "500", "shell"), args -> failingRemote()); + assertEquals(WinRmCli.EXIT_USAGE, invocation.exitCode); + assertTrue(invocation.stderr.contains("shell requires --timeout of at least 1000 milliseconds")); + + // The same timeout stays perfectly valid for the other subcommands. + final FakeRemote remote = new FakeRemote(); + assertEquals(0, invoke(concat(REQUIRED, "-t", "500", "command", "whoami"), args -> remote).exitCode); + } + @Test void pipedLocalStandardInputIsForwardedToTheCommandButAConsoleIsNot() throws Exception { final java.io.ByteArrayInputStream piped = new java.io.ByteArrayInputStream( From d23a612d8cb987a89c5cb9082d6240a608428d55 Mon Sep 17 00:00:00 2001 From: Bertrand Martin Date: Wed, 29 Jul 2026 00:30:26 +0200 Subject: [PATCH 03/17] Address Codex round 2: preserve 3-arg startCommand overrides; sub-floor timeouts keep polling - WindowsRemoteExecutor.startCommand(4-arg) default now delegates console-mode requests to the historical three-argument variant, so a pre-existing executor that overrides only that variant keeps working for ordinary commands after the upgrade; only pipe-mode stdin is unsupported by default. The three-argument default throws again (as before this PR), and LightWinRMService overrides both variants explicitly. - The bounded poll's cap by the per-round-trip timeout no longer pushes a caller's wait below the wire-poll minimum: a process configured with an inactivity timeout under 750 ms could otherwise never issue a Receive from poll()/waitFor(Duration) and never observe completion. Same clamp for the bounded completion Signal. Co-Authored-By: Claude Fable 5 --- .../winrm/WindowsRemoteExecutor.java | 14 ++++-- .../winrm/light/LightWinRMService.java | 6 +++ .../metricshub/winrm/light/WsmanClient.java | 14 ++++-- .../metricshub/winrm/StreamingApiTest.java | 50 +++++++++++++++++++ 4 files changed, 76 insertions(+), 8 deletions(-) diff --git a/src/main/java/org/metricshub/winrm/WindowsRemoteExecutor.java b/src/main/java/org/metricshub/winrm/WindowsRemoteExecutor.java index ebf218e..0748a27 100644 --- a/src/main/java/org/metricshub/winrm/WindowsRemoteExecutor.java +++ b/src/main/java/org/metricshub/winrm/WindowsRemoteExecutor.java @@ -151,7 +151,7 @@ default WqlCursor streamWql( */ default CommandCursor startCommand(final String command, final String workingDirectory, final long timeout) throws TimeoutException, WindowsRemoteException { - return startCommand(command, workingDirectory, timeout, true); + throw new UnsupportedOperationException(getClass().getName() + " does not support streaming command execution."); } /** @@ -163,8 +163,11 @@ default CommandCursor startCommand(final String command, final String workingDir * console-mode stdin never reaches EOF for tools like {@code sort} or {@code more}. *

*

- * The default implementation throws {@link UnsupportedOperationException}: only executors that - * support streaming (such as the built-in lightweight backend) implement this method. + * The default implementation delegates console-mode requests to + * {@link #startCommand(String, String, long)} — an executor that overrides only the historical + * three-argument variant keeps working for ordinary commands — and throws + * {@link UnsupportedOperationException} for pipe mode: only executors that support command + * input (such as the built-in lightweight backend) implement it. *

* * @param command The command to execute @@ -185,7 +188,10 @@ default CommandCursor startCommand( final long timeout, final boolean consoleModeStdin ) throws TimeoutException, WindowsRemoteException { - throw new UnsupportedOperationException(getClass().getName() + " does not support streaming command execution."); + if (consoleModeStdin) { + return startCommand(command, workingDirectory, timeout); + } + throw new UnsupportedOperationException(getClass().getName() + " does not support pipe-mode standard input."); } /** diff --git a/src/main/java/org/metricshub/winrm/light/LightWinRMService.java b/src/main/java/org/metricshub/winrm/light/LightWinRMService.java index a9d0945..1266e49 100644 --- a/src/main/java/org/metricshub/winrm/light/LightWinRMService.java +++ b/src/main/java/org/metricshub/winrm/light/LightWinRMService.java @@ -284,6 +284,12 @@ private void checkWqlArguments( } } + @Override + public CommandCursor startCommand(final String command, final String workingDirectory, final long timeout) + throws TimeoutException, WindowsRemoteException { + return startCommand(command, workingDirectory, timeout, true); + } + @Override public CommandCursor startCommand( final String command, diff --git a/src/main/java/org/metricshub/winrm/light/WsmanClient.java b/src/main/java/org/metricshub/winrm/light/WsmanClient.java index b37deea..c885998 100644 --- a/src/main/java/org/metricshub/winrm/light/WsmanClient.java +++ b/src/main/java/org/metricshub/winrm/light/WsmanClient.java @@ -597,10 +597,14 @@ Chunk pollChunk(final long maxWaitMs) throws Exception { finishBounded(maxWaitMs); return null; } - final long budget = Math.max(1, Math.min(maxWaitMs, operationTimeoutMs)); + // The per-round-trip timeout caps the poll — but never below the wire minimum: an + // inactivity timeout under the protocol floor would otherwise turn EVERY poll into a + // local sleep, and a poller could never observe the command's completion. + final long budget = Math.max(1, Math.min(maxWaitMs, Math.max(operationTimeoutMs, MIN_WIRE_POLL_MS))); if (budget < MIN_WIRE_POLL_MS) { - // No answer could come back in time: waiting the budget out locally is the only way - // to honor it. The protocol advances on the next full-size fetch or poll. + // The CALLER asked for less than any network round trip can honor: waiting the + // budget out locally is the only way. The protocol advances on the next fetch or + // full-size poll. Thread.sleep(budget); return new Chunk(new byte[0], new byte[0]); } @@ -807,7 +811,9 @@ private void finishBounded(final long budgetMs) { * the completed command's state with the shell. */ private void terminateCompleted(final long budgetMs) { - final long budget = Math.max(1, Math.min(budgetMs, operationTimeoutMs)); + // Same clamp as the bounded poll: an inactivity timeout under the wire minimum must not + // starve the best-effort Signal that a caller's comfortable budget could well afford. + final long budget = Math.max(1, Math.min(budgetMs, Math.max(operationTimeoutMs, MIN_WIRE_POLL_MS))); if (budget < MIN_WIRE_POLL_MS) { return; } diff --git a/src/test/java/org/metricshub/winrm/StreamingApiTest.java b/src/test/java/org/metricshub/winrm/StreamingApiTest.java index 375b8f8..b960a7c 100644 --- a/src/test/java/org/metricshub/winrm/StreamingApiTest.java +++ b/src/test/java/org/metricshub/winrm/StreamingApiTest.java @@ -561,6 +561,26 @@ void completionSignalIsBoundedByThePollBudget() throws Exception { } } + @Test + void boundedPollsStillReachTheWireWithASubFloorInactivityTimeout() throws Exception { + enqueueCommandStartup(); + server + .enqueue(200, envelope(receiveResponse(stdoutChunk("done\n"), done(COMMAND_ID, 4)))) + .enqueue(200, envelope(signalResponse())); + + // The process's inactivity timeout (500 ms) sits below the wire-poll minimum: the poll cap + // must not push a caller's comfortable wait under the protocol floor, or no poll would ever + // reach the wire and completion could never be observed. + try (WinRMClient client = builder().timeout(Duration.ofMillis(500)).build()) { + try (RemoteProcess process = client.command("run.exe").charset(StandardCharsets.UTF_8).start()) { + assertFalse(process.poll(Duration.ofSeconds(5)), "the Done chunk itself is not completion yet"); + assertTrue(process.poll(Duration.ofSeconds(5)), "completion must be observable"); + assertEquals(4, process.exitCode()); + assertEquals("done", process.stdout().readLine()); + } + } + } + @Test void completionArrivingNearTheDeadlineIsStillReported() throws Exception { enqueueCommandStartup(); @@ -716,6 +736,36 @@ void executorsWithoutStreamingSupportRejectTheStreamingEntryPoints() { final WindowsRemoteExecutor executor = new ScriptedWindowsRemoteExecutor(); assertThrows(UnsupportedOperationException.class, () -> executor.streamWql("ROOT\\CIMV2", "SELECT 1", 1000, 10, 0)); assertThrows(UnsupportedOperationException.class, () -> executor.startCommand("dir", null, 1000)); + assertThrows(UnsupportedOperationException.class, () -> executor.startCommand("dir", null, 1000, true)); + } + + @Test + void executorsOverridingTheHistoricalStartCommandKeepWorkingThroughTheNewOverload() throws Exception { + // A pre-existing executor overrides only the three-argument startCommand: the new + // console-mode default must delegate to it, and only pipe mode may be unsupported. + final CommandCursor canned = new CommandCursor() { + @Override + public Chunk next() { + return null; + } + + @Override + public int exitCode() { + return 0; + } + + @Override + public void close() {} + }; + final WindowsRemoteExecutor legacy = new ScriptedWindowsRemoteExecutor() { + @Override + public CommandCursor startCommand(final String command, final String workingDirectory, final long timeout) { + return canned; + } + }; + + assertEquals(canned, legacy.startCommand("dir", null, 1000, true)); + assertThrows(UnsupportedOperationException.class, () -> legacy.startCommand("dir", null, 1000, false)); } private static byte[] concat(final byte[] a, final byte[] b) { From 6a4f6566f47e4a8520c0272cc6d8c2167942643c Mon Sep 17 00:00:00 2001 From: Bertrand Martin Date: Wed, 29 Jul 2026 00:41:03 +0200 Subject: [PATCH 04/17] Address Codex round 3: tolerate completion racing queued input; bound shell input batches - The shell pump no longer fails the session when a line (or the local EOF) gets queued while the final Done-carrying Receive is in flight: the cursor-level IllegalStateException is treated as "the command is gone, stop forwarding", and the next poll reports the exit code normally. - Redirected input is forwarded in bounded batches (32 KiB of characters per round, the rest stays queued) so a fire hose of input cannot starve the output side of the pump or balloon the local buffers, and the reader thread's queue is capped (1024 lines) for backpressure toward the local stdin. Co-Authored-By: Claude Fable 5 --- .../winrm/cli/InteractiveShell.java | 39 ++++++-- .../winrm/cli/InteractiveShellTest.java | 90 +++++++++++++++++++ 2 files changed, 122 insertions(+), 7 deletions(-) diff --git a/src/main/java/org/metricshub/winrm/cli/InteractiveShell.java b/src/main/java/org/metricshub/winrm/cli/InteractiveShell.java index fd4de9a..2e412ad 100644 --- a/src/main/java/org/metricshub/winrm/cli/InteractiveShell.java +++ b/src/main/java/org/metricshub/winrm/cli/InteractiveShell.java @@ -65,6 +65,21 @@ final class InteractiveShell { */ static final long DEFAULT_POLL_MILLIS = 1_000L; + /** + * How much input one round forwards at most (one Send's worth of characters). Redirected + * input can queue lines much faster than the wire consumes them: the bound keeps the local + * buffering flat and, above all, keeps the rounds alternating — a fire hose of input must not + * starve the output side of the pump. + */ + static final int MAX_INPUT_CHARS_PER_ROUND = 32 * 1024; + + /** + * How many lines the local reader thread may queue ahead of the pump before it blocks — + * backpressure toward the local stdin, so a redirected file streams through bounded memory + * instead of being swallowed whole. + */ + private static final int INPUT_QUEUE_CAPACITY = 1_024; + private InteractiveShell() {} /** @@ -141,7 +156,14 @@ static int bridge( process.interrupt(); } if (!eofForwarded) { - eofForwarded = forwardLocalInput(lines, process.stdin()); + try { + eofForwarded = forwardLocalInput(lines, process.stdin()); + } catch (final IllegalStateException e) { + // The command completed while this input was being queued (the final Receive + // beat it): nothing can consume it anymore. Stop forwarding — the next poll + // observes the completion and the exit code is reported normally. + eofForwarded = true; + } } // One bounded round trip: it returns as soon as the server answers, so available // output is forwarded immediately — the cadence only paces the idle rounds. @@ -156,17 +178,20 @@ static int bridge( } /** - * Forward every queued local line as one flushed write (one WSMan Send). Lines are terminated - * with CRLF — what the remote {@code cmd.exe} console expects. Once the local input ends, the - * remote stdin is closed (the final Send carries {@code End="true"}) and {@code true} is - * returned: no further input will be forwarded. + * Forward the queued local lines — at most {@link #MAX_INPUT_CHARS_PER_ROUND} characters per + * round, the rest stays queued for the next one — as one flushed write (one WSMan Send). + * Lines are terminated with CRLF — what the remote {@code cmd.exe} console expects. Once the + * local input ends, the remote stdin is closed (the final Send carries {@code End="true"}) + * and {@code true} is returned: no further input will be forwarded. */ private static boolean forwardLocalInput(final LineSource lines, final BufferedWriter stdin) throws IOException { boolean wrote = false; + int batchedChars = 0; String line; - while ((line = lines.nextLine()) != null) { + while (batchedChars < MAX_INPUT_CHARS_PER_ROUND && (line = lines.nextLine()) != null) { stdin.write(line); stdin.write("\r\n"); + batchedChars += line.length() + 2; wrote = true; } if (lines.endOfInput()) { @@ -213,7 +238,7 @@ private Item(final String line) { } } - private final BlockingQueue queue = new LinkedBlockingQueue<>(); + private final BlockingQueue queue = new LinkedBlockingQueue<>(INPUT_QUEUE_CAPACITY); private boolean ended; /** Feed the queue from the local input, line by line, ending with the EOF marker. */ diff --git a/src/test/java/org/metricshub/winrm/cli/InteractiveShellTest.java b/src/test/java/org/metricshub/winrm/cli/InteractiveShellTest.java index 1f55d12..1ebb9ec 100644 --- a/src/test/java/org/metricshub/winrm/cli/InteractiveShellTest.java +++ b/src/test/java/org/metricshub/winrm/cli/InteractiveShellTest.java @@ -279,6 +279,96 @@ public boolean endOfInput() { assertFalse(chunks.get(0).end()); } + @Test + void inputQueuedWhileTheFinalReceiveWasInFlightDoesNotHideTheExitCode() throws Exception { + enqueueStartup(); + server + // round 1: the final Receive carries the Done state... + .enqueue(200, envelope(receiveResponse("", done(COMMAND_ID, 3)))) + // ...and the completion cleanup Signal follows on the next round + .enqueue(200, envelope(signalResponse())); + enqueueShellDeletion(server); + + // A line shows up right AFTER the final Receive: it can no longer be consumed, and it must + // not turn the session into a failure — the exit code wins. + final InteractiveShell.LineSource lateLine = new InteractiveShell.LineSource() { + private int round; + + @Override + public String nextLine() { + round++; + return round == 2 ? "too late" : null; + } + + @Override + public boolean endOfInput() { + return false; + } + }; + final int exitCode; + try (WinRMClient client = client()) { + try (RemoteProcess process = client.command("cmd.exe").start()) { + exitCode = InteractiveShell.bridge( + process, + lateLine, + new PrintStream(new ByteArrayOutputStream(), true, "UTF-8"), + new PrintStream(new ByteArrayOutputStream(), true, "UTF-8"), + new AtomicBoolean(), + POLL_MILLIS + ); + } + } + + assertEquals(3, exitCode); + // The late line never reached the wire: no Send left the client. + assertTrue(server.stdinChunks().isEmpty()); + assertTrue(server.decryptedRequests().stream().noneMatch(request -> request.contains(":Send>"))); + } + + @Test + void aFireHoseOfInputIsForwardedInBoundedBatches() throws Exception { + final char[] big = new char[InteractiveShell.MAX_INPUT_CHARS_PER_ROUND + 1_000]; + java.util.Arrays.fill(big, 'x'); + final String hugeLine = new String(big); + + enqueueStartup(); + server + // round 1: the first batch (capped) travels alone... + .enqueue(200, envelope(sendResponse())) + .enqueue( + 200, + envelope(receiveResponse(stream("stdout", COMMAND_ID, "ok\r\n".getBytes(StandardCharsets.UTF_8)), null)) + ) + // ...the leftover line follows on the next round, with the End mark + .enqueue(200, envelope(sendResponse())) + .enqueue(200, envelope(receiveResponse("", done(COMMAND_ID, 0)))) + .enqueue(200, envelope(signalResponse())); + enqueueShellDeletion(server); + + final int exitCode; + try (WinRMClient client = client()) { + try (RemoteProcess process = client.command("cmd.exe").start()) { + exitCode = InteractiveShell.bridge( + process, + ScriptedLines.endingAfter(hugeLine, "exit"), + new PrintStream(new ByteArrayOutputStream(), true, "UTF-8"), + new PrintStream(new ByteArrayOutputStream(), true, "UTF-8"), + new AtomicBoolean(), + POLL_MILLIS + ); + } + } + + assertEquals(0, exitCode); + // Two bounded batches instead of one unbounded drain: the output side polled in between. + final List chunks = server.stdinChunks(); + assertEquals(2, chunks.size()); + assertArrayEquals((hugeLine + "\r\n").getBytes(StandardCharsets.UTF_8), chunks.get(0).data()); + assertFalse(chunks.get(0).end()); + assertArrayEquals("exit\r\n".getBytes(StandardCharsets.UTF_8), chunks.get(1).data()); + assertTrue(chunks.get(1).end()); + } + @Test void queuedLineSourceDeliversLinesInOrderThenTheEndOfInput() { // The production LineSource behind InteractiveShell.run: fed by the helper thread reading From 76d1cd5586142d4c7d11102167ae5b3850ac13fe Mon Sep 17 00:00:00 2001 From: Bertrand Martin Date: Wed, 29 Jul 2026 00:51:21 +0200 Subject: [PATCH 05/17] Address Codex round 4: timeout translation for Send/interrupt, suppressed cleanup, closed writer - Send and the ctrl_c Signal now go through the same streaming timeout translation as command startup and Receive: a server staying quiet for a whole inactivity timeout surfaces as the documented WinRMTimeoutException (CLI exit 124), not as a generic connection or protocol failure. - A failed pre-supplied stdin delivery is no longer masked when the cleanup terminate Signal fails too: the original exception wins, the cleanup failure travels as suppressed. - With pre-supplied stdin, the RemoteProcess.stdin() writer is now actually closed at construction: writes are rejected immediately instead of being buffered into the void by the BufferedWriter layer. Co-Authored-By: Claude Fable 5 --- .../org/metricshub/winrm/CommandRequest.java | 9 ++- .../org/metricshub/winrm/RemoteProcess.java | 12 ++++ .../metricshub/winrm/light/WsmanClient.java | 29 +++++++-- .../metricshub/winrm/CommandStdinTest.java | 61 +++++++++++++++++++ 4 files changed, 103 insertions(+), 8 deletions(-) diff --git a/src/main/java/org/metricshub/winrm/CommandRequest.java b/src/main/java/org/metricshub/winrm/CommandRequest.java index 4d47388..8fd16aa 100644 --- a/src/main/java/org/metricshub/winrm/CommandRequest.java +++ b/src/main/java/org/metricshub/winrm/CommandRequest.java @@ -378,8 +378,13 @@ public RemoteProcess start() { feedStdin(cursor, prepared.charset); } catch (final Exception e) { // The input could not be delivered: terminate the command and release the client's - // connection before reporting — a failed start must not leave an open handle behind. - cursor.close(); + // connection before reporting — a failed start must not leave an open handle + // behind, and a cleanup failure must not replace the actual one. + try { + cursor.close(); + } catch (final RuntimeException cleanup) { + e.addSuppressed(cleanup); + } throw e; } } diff --git a/src/main/java/org/metricshub/winrm/RemoteProcess.java b/src/main/java/org/metricshub/winrm/RemoteProcess.java index 959a1e8..ac54f61 100644 --- a/src/main/java/org/metricshub/winrm/RemoteProcess.java +++ b/src/main/java/org/metricshub/winrm/RemoteProcess.java @@ -23,7 +23,9 @@ import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; import java.io.BufferedReader; import java.io.BufferedWriter; +import java.io.IOException; import java.io.Reader; +import java.io.UncheckedIOException; import java.io.Writer; import java.nio.charset.Charset; import java.time.Duration; @@ -125,6 +127,16 @@ public final class RemoteProcess implements AutoCloseable { this.stderr = new BufferedReader(new ChannelReader(stderrPending)); this.stdin = new BufferedWriter(new StdinWriter()); this.stdinClosed = stdinAlreadySupplied; + if (stdinAlreadySupplied) { + // The writer itself must reject writes immediately, not buffer them into the void: + // close it for real. No request leaves here — ending an already-ended input is a no-op. + try { + stdin.close(); + } catch (final IOException e) { + // Unreachable: closing the empty writer performs no I/O (see sendStdin). + throw new UncheckedIOException(e); + } + } } /** diff --git a/src/main/java/org/metricshub/winrm/light/WsmanClient.java b/src/main/java/org/metricshub/winrm/light/WsmanClient.java index c885998..875ea5c 100644 --- a/src/main/java/org/metricshub/winrm/light/WsmanClient.java +++ b/src/main/java/org/metricshub/winrm/light/WsmanClient.java @@ -676,12 +676,15 @@ void send(final byte[] data, final boolean end) throws Exception { final String base64 = length == 0 ? "" : Base64.getEncoder().encodeToString(Arrays.copyOfRange(data, offset, offset + length)); - final Decoded resp = request( - Envelopes.send(url, shellId, commandId, base64, end && last, operationTimeoutMs) + // 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 ); - if (resp.status != 200) { - throw faultException("Send", resp); - } offset += length; } while (offset < data.length); if (end) { @@ -701,7 +704,21 @@ void interrupt() throws Exception { return; } checkNotCancelled(); - signal(commandId, Envelopes.CTRL_C_CODE, operationTimeoutMs); + try { + signal(commandId, Envelopes.CTRL_C_CODE, operationTimeoutMs); + } catch (final SocketTimeoutException e) { + // Same streaming translation as every other round trip: a server staying quiet + // for a whole inactivity timeout is the documented TimeoutException. + if (failOnQuietTimeout) { + throw quietTimeout("The interrupt Signal was not answered", operationTimeoutMs, e); + } + throw e; + } catch (final WinRMFaultException e) { + if (failOnQuietTimeout && FAULT_OPERATION_TIMEOUT.equals(e.getFaultCode())) { + throw quietTimeout("The interrupt Signal was not answered", operationTimeoutMs, null); + } + throw e; + } } /** Turn one 200 Receive response into a chunk, recording the exit code when it says Done. */ diff --git a/src/test/java/org/metricshub/winrm/CommandStdinTest.java b/src/test/java/org/metricshub/winrm/CommandStdinTest.java index 0598451..50a2489 100644 --- a/src/test/java/org/metricshub/winrm/CommandStdinTest.java +++ b/src/test/java/org/metricshub/winrm/CommandStdinTest.java @@ -28,6 +28,7 @@ import static org.metricshub.winrm.light.FakeWsmanResponses.commandResponse; import static org.metricshub.winrm.light.FakeWsmanResponses.done; import static org.metricshub.winrm.light.FakeWsmanResponses.enqueueShellDeletion; +import static org.metricshub.winrm.light.FakeWsmanResponses.fault; import static org.metricshub.winrm.light.FakeWsmanResponses.envelope; import static org.metricshub.winrm.light.FakeWsmanResponses.receiveResponse; import static org.metricshub.winrm.light.FakeWsmanResponses.resourceCreated; @@ -48,6 +49,8 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; +import org.metricshub.winrm.exceptions.WinRMClientException; +import org.metricshub.winrm.exceptions.WinRMTimeoutException; import org.metricshub.winrm.light.FakeWsmanServer; /** @@ -206,6 +209,64 @@ void emptyStdinAnnouncesOnlyTheEndOfInput() throws Exception { assertTrue(chunks.get(0).end()); } + @Test + void presuppliedStdinWithStartClosesTheWriterFromTheStart() throws Exception { + enqueueStartup(); + server + .enqueue(200, envelope(sendResponse())) + .enqueue(200, envelope(receiveResponse("", done(COMMAND_ID, 0)))) + .enqueue(200, envelope(signalResponse())); + enqueueShellDeletion(server); + + try (WinRMClient client = builder().build()) { + try (RemoteProcess process = client.command("sort").stdin("beta\nalpha\n").start()) { + // The input was delivered in full at startup: the writer rejects further input + // immediately, instead of silently buffering it into the void. + assertThrows(IOException.class, () -> process.stdin().write("more")); + assertEquals(0, process.waitFor()); + } + } + + final List chunks = server.stdinChunks(); + assertEquals(1, chunks.size()); + assertArrayEquals("beta\nalpha\n".getBytes(StandardCharsets.UTF_8), chunks.get(0).data()); + assertTrue(chunks.get(0).end()); + } + + @Test + void aSendLeftUnansweredSurfacesAsTheDocumentedTimeout() throws Exception { + enqueueStartup(); + // The Send stays unanswered past the inactivity timeout: the documented timeout must + // surface (the CLI maps it to exit 124), not a generic connection or protocol failure. + server.enqueueDelayed(200, envelope(sendResponse()), 4_000); + + try (WinRMClient client = builder().timeout(Duration.ofMillis(1_500)).build()) { + final RemoteProcess process = client.command("repl.exe").stdin().start(); + final BufferedWriter stdin = process.stdin(); + stdin.write("ping\n"); + assertThrows(WinRMTimeoutException.class, stdin::flush); + } + } + + @Test + void aFailedStdinDeliveryIsNotMaskedByACleanupFailure(@TempDir final Path tempDir) { + enqueueStartup(); + // The terminate Signal cleaning up the failed start is itself answered with a fault: the + // original delivery failure must win, with the cleanup failure attached as suppressed. + server.enqueue(500, fault("999", "Signal rejected")); + enqueueShellDeletion(server); + + try (WinRMClient client = builder().build()) { + final Path missing = tempDir.resolve("missing.txt"); + final WinRMClientException failure = assertThrows( + WinRMClientException.class, + () -> client.command("sort").stdin(missing).start() + ); + assertTrue(failure.getCause() instanceof java.nio.file.NoSuchFileException, String.valueOf(failure.getCause())); + assertEquals(1, failure.getCause().getSuppressed().length); + } + } + @Test void remoteProcessStdinSupportsAWriteFlushReadRoundTrip() throws Exception { enqueueStartup(); From 494b652a0c58669d101fd42641a5df5aea2839cb Mon Sep 17 00:00:00 2001 From: Bertrand Martin Date: Wed, 29 Jul 2026 01:06:26 +0200 Subject: [PATCH 06/17] Address Codex round 5: probe stdin for redirection; decouple the shell cadence from response transit - Stdin forwarding no longer keys on System.console() alone, which is also null when only the OUTPUT is redirected (command hostname > result.txt) and would then hang the CLI consuming an interactive terminal. The input itself is probed: bytes already waiting at startup can only come from a pipe or a redirected file; an untouched terminal reports nothing available. - New cadence variant of the bounded poll, poll(ask, maxWait): the server is asked to answer within the polling cadence, but the answer itself may take up to the inactivity timeout to arrive - one slow answer from a loaded server no longer kills the interactive session at the 1-second cadence. RemoteProcess.poll(Duration) now uses it (the wait is the cadence, the request timeout governs transit); waitFor(Duration) keeps the hard bound for deadlines, including against a dead peer. Verified live against a real Windows Server 2008 R2 host again (interactive shell session, exit-code propagation). Co-Authored-By: Claude Fable 5 --- .../org/metricshub/winrm/CommandCursor.java | 23 ++++++++++ .../org/metricshub/winrm/RemoteProcess.java | 44 +++++++++++++------ .../org/metricshub/winrm/cli/WinRmCli.java | 23 +++++++++- .../winrm/light/LightWinRMService.java | 6 +++ .../metricshub/winrm/light/WsmanClient.java | 30 ++++++++++--- src/site/markdown/cli.md | 13 +++--- .../metricshub/winrm/StreamingApiTest.java | 20 +++++++++ .../metricshub/winrm/cli/WinRmCliTest.java | 31 +++++++++++++ 8 files changed, 163 insertions(+), 27 deletions(-) diff --git a/src/main/java/org/metricshub/winrm/CommandCursor.java b/src/main/java/org/metricshub/winrm/CommandCursor.java index f00613a..b43063a 100644 --- a/src/main/java/org/metricshub/winrm/CommandCursor.java +++ b/src/main/java/org/metricshub/winrm/CommandCursor.java @@ -71,6 +71,29 @@ default Chunk poll(final long maxWaitMillis) throws TimeoutException, WindowsRem return next(); } + /** + * Cadence variant of {@link #poll(long)}: ask the server to answer within + * {@code askMillis} — the polling cadence — while allowing the answer itself up to + * {@code maxWaitMillis} to arrive. A polling consumer (e.g. an interactive session pump) + * wants short idle rounds, but must not fail the stream when a loaded or distant server + * takes longer than one cadence to get its answer across; {@link #poll(long)} is exactly + * this call with {@code askMillis == maxWaitMillis}. + *

+ * The default implementation delegates to {@link #poll(long)} with the full wait. + * + * @param askMillis when the server should answer at the latest — with output when it has + * any, with the protocol's "nothing yet" otherwise + * @param maxWaitMillis how long to block at most, capped by the cursor's per-round-trip + * timeout + * @return the next chunk of raw output — empty when nothing arrived — or {@code null} once + * the command has completed + * @throws TimeoutException when the server does not even answer the bounded request + * @throws WindowsRemoteException for any other failure while receiving + */ + default Chunk poll(final long askMillis, final long maxWaitMillis) throws TimeoutException, WindowsRemoteException { + return poll(maxWaitMillis); + } + /** * Feed standard input to the running command — the WSMan Send operation, carrying the bytes to * the command's {@code stdin} stream. Input larger than one envelope's worth is split into diff --git a/src/main/java/org/metricshub/winrm/RemoteProcess.java b/src/main/java/org/metricshub/winrm/RemoteProcess.java index ac54f61..dcd16db 100644 --- a/src/main/java/org/metricshub/winrm/RemoteProcess.java +++ b/src/main/java/org/metricshub/winrm/RemoteProcess.java @@ -231,29 +231,45 @@ public synchronized int waitFor() { } /** - * Advance the stream by at most one bounded protocol round trip: block at most the - * given wait for the next chunk of output, buffer whatever arrives for the readers, and report - * whether the command has completed. The wait expiring is not a failure — the server answers - * with the protocol's "nothing yet" and the process stays fully usable — so a polling consumer - * (e.g. an interactive session pump) never trips the inactivity timeout while idle. Unlike - * {@link #waitFor(Duration)}, which keeps polling until its deadline, this returns as soon as - * the server answers: output that is ready arrives (and can be read) immediately. + * Advance the stream by at most one bounded protocol round trip: ask the server to + * answer within the given wait — with output when it has any, with the protocol's "nothing + * yet" otherwise — buffer whatever arrives for the readers, and report whether the command + * has completed. An empty answer is not a failure: the process stays fully usable, so a + * polling consumer (e.g. an interactive session pump) never trips the inactivity timeout + * while idle. Unlike {@link #waitFor(Duration)}, which keeps polling until its deadline, this + * returns as soon as the server answers: output that is ready arrives (and can be read) + * immediately. *

- * A wait too short for a network round trip — the WSMan service holds a bounded Receive for at - * least 500 ms before answering "nothing yet", and the answer needs transit slack on top — is - * waited out locally without touching the wire: the stream then does not advance. Use waits of - * about a second or more to actually poll the server. + * The wait is the polling cadence, not a hard bound on the round trip: how long the + * answer itself may take to arrive is governed by the request timeout (the inactivity + * timeout), so one slow answer from a loaded server does not fail the stream — use + * {@link #waitFor(Duration)} when a hard deadline is needed. A wait too short for a network + * round trip — the WSMan service holds a bounded Receive for at least 500 ms before answering + * "nothing yet" — is waited out locally without touching the wire: the stream then does not + * advance. Use waits of about a second or more to actually poll the server. * - * @param maxWait how long to block at most (at least one millisecond) + * @param maxWait when the server should answer at the latest (at least one millisecond) * @return {@code true} when the command has completed — the exit code is then available from * {@link #exitCode()} — {@code false} when it is still running - * @throws WinRMTimeoutException when the server does not answer the bounded request in time + * @throws WinRMTimeoutException when the server stays silent for a whole inactivity timeout * @throws org.metricshub.winrm.exceptions.WinRMClientException for any other failure */ public synchronized boolean poll(final Duration maxWait) { WinRMClient.checkPositive(maxWait, "maxWait"); if (!finished) { - absorb(advance(WinRMClient.toMillis(maxWait))); + final long ask = WinRMClient.toMillis(maxWait); + final CommandCursor.Chunk chunk; + try { + chunk = cursor.poll(ask, Math.max(ask, WinRMClient.toMillis(timeout))); + } catch (final TimeoutException e) { + throw new WinRMTimeoutException( + String.format("Command produced no output within %s on %s", timeout, hostname), + e + ); + } catch (final WindowsRemoteException e) { + throw WinRMClient.translate(e); + } + absorb(chunk); } return finished; } diff --git a/src/main/java/org/metricshub/winrm/cli/WinRmCli.java b/src/main/java/org/metricshub/winrm/cli/WinRmCli.java index f6199ea..afe02ec 100644 --- a/src/main/java/org/metricshub/winrm/cli/WinRmCli.java +++ b/src/main/java/org/metricshub/winrm/cli/WinRmCli.java @@ -98,11 +98,32 @@ public static void main(final String[] arguments) { System.err, WinRmCli::connect, WinRmCli::readConsolePassword, - new LocalInput(System.console() != null, System.in) + new LocalInput(!stdinLooksRedirected(System.console() != null, System.in), System.in) ) ); } + /** + * Whether the local standard input looks piped or redirected — the signal engaging stdin + * forwarding for the {@code command} subcommand. {@code System.console()} alone is not it: it + * is also null when only the output is redirected ({@code ... > result.txt}), and + * consuming an interactive terminal in that case would hang the CLI waiting for an EOF the + * user never sends. So the input itself is probed: bytes already waiting at startup can only + * come from a redirected file or a pipe — an untouched terminal reports nothing available. + * The trade-off is a pipe whose producer has not written anything yet: its input is not + * forwarded (the JVM startup normally leaves any real producer plenty of time to get ahead). + */ + static boolean stdinLooksRedirected(final boolean consoleAttached, final InputStream stdin) { + if (consoleAttached) { + return false; + } + try { + return stdin.available() > 0; + } catch (final IOException e) { + return false; + } + } + static int run( final String[] arguments, final PrintStream standardOutput, diff --git a/src/main/java/org/metricshub/winrm/light/LightWinRMService.java b/src/main/java/org/metricshub/winrm/light/LightWinRMService.java index 1266e49..95e9d9d 100644 --- a/src/main/java/org/metricshub/winrm/light/LightWinRMService.java +++ b/src/main/java/org/metricshub/winrm/light/LightWinRMService.java @@ -317,6 +317,12 @@ public Chunk poll(final long maxWaitMillis) throws TimeoutException, WindowsRemo return adapt(callStreaming(() -> remoteCommand.pollChunk(maxWaitMillis))); } + @Override + public Chunk poll(final long askMillis, final long maxWaitMillis) + throws TimeoutException, WindowsRemoteException { + return adapt(callStreaming(() -> remoteCommand.pollChunk(askMillis, maxWaitMillis))); + } + private Chunk adapt(final WsmanClient.RemoteCommand.Chunk chunk) { return chunk == null ? null : new Chunk(chunk.stdout, chunk.stderr); } diff --git a/src/main/java/org/metricshub/winrm/light/WsmanClient.java b/src/main/java/org/metricshub/winrm/light/WsmanClient.java index 875ea5c..9af0618 100644 --- a/src/main/java/org/metricshub/winrm/light/WsmanClient.java +++ b/src/main/java/org/metricshub/winrm/light/WsmanClient.java @@ -588,6 +588,21 @@ Chunk nextChunk() throws Exception { * timeout */ Chunk pollChunk(final long maxWaitMs) throws Exception { + return pollChunk(maxWaitMs, maxWaitMs); + } + + /** + * Cadence variant: ask the server to answer within {@code askMs} (the polling cadence), + * while allowing the answer itself up to {@code maxWaitMs} to arrive — a polling consumer + * (the interactive shell pump) wants short idle rounds without failing the session when a + * loaded or distant server takes longer than one cadence to get its answer across. The + * hard-bound variant above is simply {@code askMs == maxWaitMs}. + * + * @param askMs when the server should answer at the latest (its Receive hold) + * @param maxWaitMs how long to block at most, capped by the handle's own per-round-trip + * timeout + */ + Chunk pollChunk(final long askMs, final long maxWaitMs) throws Exception { if (finished) { return null; } @@ -608,14 +623,15 @@ Chunk pollChunk(final long maxWaitMs) throws Exception { Thread.sleep(budget); return new Chunk(new byte[0], new byte[0]); } - // Split the budget: the server may hold the Receive for the first part, and the rest is - // transit slack for its "nothing yet" op-timeout fault to arrive BEFORE the socket cuts - // at the full budget — the expected expiry of a bounded poll is that fault, and it must - // win the race or the poll would desync the connection it is supposed to leave intact. - // The hold never asks for less than the service's own floor: the answer would not come - // any earlier, and the requested timeout should reflect when the server may answer. + // Split the budget: the server may hold the Receive for the first part (never more + // than the requested cadence), and the rest is transit slack for its "nothing yet" + // op-timeout fault to arrive BEFORE the socket cuts at the full budget — the expected + // expiry of a bounded poll is that fault, and it must win the race or the poll would + // desync the connection it is supposed to leave intact. The hold never asks for less + // than the service's own floor: the answer would not come any earlier, and the + // requested timeout should reflect when the server may answer. final long transit = Math.min(1_000, budget / 2); - final long hold = Math.max(MIN_OPERATION_TIMEOUT_MS, budget - transit); + final long hold = Math.max(MIN_OPERATION_TIMEOUT_MS, Math.min(askMs, budget - transit)); transport.pollTimeout(toSocketTimeoutMillis(budget)); try { checkNotCancelled(); diff --git a/src/site/markdown/cli.md b/src/site/markdown/cli.md index a7ed564..3047bcd 100644 --- a/src/site/markdown/cli.md +++ b/src/site/markdown/cli.md @@ -96,16 +96,19 @@ real time. The output is decoded as UTF-8: the remote shell is created with cons locale. See [Character encoding](commands.html#character-encoding) for the two legacy tools that ignore the console code page. -When the local standard input is **not** an interactive console — it is piped or redirected — it -is forwarded as the remote command's standard input, with pipe semantics, so filters just work: +When the local standard input is piped or redirected, it is forwarded as the remote command's +standard input, with pipe semantics, so filters just work: ```bash java -jar winrm-java-standalone.jar -h server -u 'DOMAIN\user' -pf pw.txt command sort < data.txt ``` -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`. +Forwarding engages when input is already waiting on a non-console standard input at startup — +which a `< file` redirection or a normal pipe always satisfies. An interactive terminal is never +consumed, even when only the *output* is redirected (`... command hostname > result.txt`). 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`. ## Interactive shell diff --git a/src/test/java/org/metricshub/winrm/StreamingApiTest.java b/src/test/java/org/metricshub/winrm/StreamingApiTest.java index b960a7c..29fa283 100644 --- a/src/test/java/org/metricshub/winrm/StreamingApiTest.java +++ b/src/test/java/org/metricshub/winrm/StreamingApiTest.java @@ -561,6 +561,26 @@ void completionSignalIsBoundedByThePollBudget() throws Exception { } } + @Test + void aPollAnswerSlowerThanTheCadenceDoesNotFailTheStream() throws Exception { + enqueueCommandStartup(); + server + // The answer takes clearly longer than the 1 s polling cadence — a loaded server — + // but stays well within the configured inactivity timeout: the poll must wait for it + // rather than cutting the connection at the cadence. + .enqueueDelayed(200, envelope(receiveResponse(stdoutChunk("late but fine\n"), done(COMMAND_ID, 6))), 2_500) + .enqueue(200, envelope(signalResponse())); + + try (WinRMClient client = builder().build()) { + try (RemoteProcess process = client.command("busy.exe").charset(StandardCharsets.UTF_8).start()) { + assertFalse(process.poll(Duration.ofSeconds(1)), "the Done chunk itself is not completion yet"); + assertTrue(process.poll(Duration.ofSeconds(1)), "completion must be observable"); + assertEquals(6, process.exitCode()); + assertEquals("late but fine", process.stdout().readLine()); + } + } + } + @Test void boundedPollsStillReachTheWireWithASubFloorInactivityTimeout() throws Exception { enqueueCommandStartup(); diff --git a/src/test/java/org/metricshub/winrm/cli/WinRmCliTest.java b/src/test/java/org/metricshub/winrm/cli/WinRmCliTest.java index ae49045..6d579fa 100644 --- a/src/test/java/org/metricshub/winrm/cli/WinRmCliTest.java +++ b/src/test/java/org/metricshub/winrm/cli/WinRmCliTest.java @@ -167,6 +167,37 @@ void pipedLocalStandardInputIsForwardedToTheCommandButAConsoleIsNot() throws Exc org.junit.jupiter.api.Assertions.assertNull(interactive.forwardedStdin); } + @Test + void detectsRedirectedStdinByProbingTheInputItself() { + // An attached console is definitely interactive, whatever the input holds. + assertFalse(WinRmCli.stdinLooksRedirected(true, new java.io.ByteArrayInputStream(new byte[] { 1 }))); + + // No console + bytes already waiting: a pipe or a redirected file. + assertTrue(WinRmCli.stdinLooksRedirected(false, new java.io.ByteArrayInputStream(new byte[] { 1 }))); + + // No console but nothing waiting: typically an interactive terminal whose OUTPUT is + // redirected (System.console() is null then too) — consuming it would hang the CLI. + assertFalse(WinRmCli.stdinLooksRedirected(false, new java.io.ByteArrayInputStream(new byte[0]))); + + // A probe failure counts as not redirected: never risk blocking on a terminal. + assertFalse( + WinRmCli.stdinLooksRedirected( + false, + new java.io.InputStream() { + @Override + public int read() { + return -1; + } + + @Override + public int available() throws java.io.IOException { + throw new java.io.IOException("probe failure"); + } + } + ) + ); + } + @Test void mapsUsageTimeoutConnectionAuthenticationAndProtocolFailures() throws Exception { assertEquals( From 6dfdd052803f9bdb8a7d5131b17d76e6b84cbce2 Mon Sep 17 00:00:00 2001 From: Bertrand Martin Date: Wed, 29 Jul 2026 01:24:06 +0200 Subject: [PATCH 07/17] Address Codex round 6: seekable-fd probe plus explicit --stdin; split oversized shell input records - Stdin redirection detection no longer relies on buffered bytes alone: a seekable descriptor 0 is a redirected file (catching empty files and the null device - verified live: `command sort < /dev/null` now completes immediately instead of hanging), while waiting bytes still catch pipes. The one undetectable case - a pipe whose producer writes only after the CLI started - gets the new explicit -i/--stdin option (command subcommand only), which forces forwarding regardless of detection. - The shell pump now splits an input record larger than one round's budget: the surplus leads the next round instead of being encoded and sent whole, so a giant newline-free record (minified JSON, base64) keeps memory bounded and the input/output rounds alternating. Co-Authored-By: Claude Fable 5 --- .../metricshub/winrm/cli/CliArguments.java | 14 +++++ .../winrm/cli/InteractiveShell.java | 51 +++++++++++++------ .../org/metricshub/winrm/cli/WinRmCli.java | 49 +++++++++++++----- src/site/markdown/cli.md | 15 +++--- .../winrm/cli/InteractiveShellTest.java | 12 +++-- .../metricshub/winrm/cli/WinRmCliTest.java | 36 +++++++++---- 6 files changed, 129 insertions(+), 48 deletions(-) diff --git a/src/main/java/org/metricshub/winrm/cli/CliArguments.java b/src/main/java/org/metricshub/winrm/cli/CliArguments.java index 1cb46da..1de7806 100644 --- a/src/main/java/org/metricshub/winrm/cli/CliArguments.java +++ b/src/main/java/org/metricshub/winrm/cli/CliArguments.java @@ -68,6 +68,7 @@ enum Operation { private final String kerberosKdc; private final String kerberosRealm; private final boolean kerberosRealmInferred; + private final boolean forwardStdin; private final String input; private CliArguments(final Builder builder) { @@ -83,6 +84,7 @@ private CliArguments(final Builder builder) { kerberosKdc = builder.kerberosKdc; kerberosRealm = builder.kerberosRealm; kerberosRealmInferred = builder.kerberosRealmInferred; + forwardStdin = builder.forwardStdin; input = builder.input; } @@ -170,6 +172,10 @@ private static int parseOption(final Builder builder, final String[] arguments, case "--https-permissive": builder.permissiveHttps = true; return index + 1; + case "--stdin": + case "-i": + builder.forwardStdin = true; + return index + 1; default: throw new CliUsageException("unknown option " + safeOptionName(argument)); } @@ -235,6 +241,9 @@ private static void validate(final Builder builder) throws CliUsageException { if (builder.permissiveHttps && !builder.https) { throw new CliUsageException("--https-permissive requires --https"); } + if (builder.forwardStdin && builder.operation != Operation.COMMAND) { + throw new CliUsageException("--stdin requires the command subcommand"); + } if (builder.operation == Operation.SHELL && builder.timeout < MIN_SHELL_TIMEOUT) { throw new CliUsageException("shell requires --timeout of at least " + MIN_SHELL_TIMEOUT + " milliseconds"); } @@ -467,6 +476,10 @@ boolean kerberosRealmInferred() { return kerberosRealmInferred; } + boolean forwardStdin() { + return forwardStdin; + } + String input() { return input; } @@ -493,6 +506,7 @@ private static final class Builder { private String kerberosKdc; private String kerberosRealm; private boolean kerberosRealmInferred; + private boolean forwardStdin; private Integer port; private long timeout = DEFAULT_TIMEOUT; private String input; diff --git a/src/main/java/org/metricshub/winrm/cli/InteractiveShell.java b/src/main/java/org/metricshub/winrm/cli/InteractiveShell.java index 2e412ad..69059d8 100644 --- a/src/main/java/org/metricshub/winrm/cli/InteractiveShell.java +++ b/src/main/java/org/metricshub/winrm/cli/InteractiveShell.java @@ -149,6 +149,9 @@ static int bridge( final AtomicBoolean interruptRequested, final long pollMillis ) throws IOException { + // The tail of a record the current round could not fit entirely (an oversized line): + // forwarded first on the next rounds, before any new line is pulled. + final StringBuilder pendingInput = new StringBuilder(); boolean eofForwarded = false; boolean completed = false; while (!completed) { @@ -157,7 +160,7 @@ static int bridge( } if (!eofForwarded) { try { - eofForwarded = forwardLocalInput(lines, process.stdin()); + eofForwarded = forwardLocalInput(lines, process.stdin(), pendingInput); } catch (final IllegalStateException e) { // The command completed while this input was being queued (the final Receive // beat it): nothing can consume it anymore. Stop forwarding — the next poll @@ -178,24 +181,40 @@ static int bridge( } /** - * Forward the queued local lines — at most {@link #MAX_INPUT_CHARS_PER_ROUND} characters per - * round, the rest stays queued for the next one — as one flushed write (one WSMan Send). - * Lines are terminated with CRLF — what the remote {@code cmd.exe} console expects. Once the - * local input ends, the remote stdin is closed (the final Send carries {@code End="true"}) - * and {@code true} is returned: no further input will be forwarded. + * Forward the queued local input — at most {@link #MAX_INPUT_CHARS_PER_ROUND} characters per + * round — as one flushed write (one WSMan Send). Lines are terminated with CRLF — what the + * remote {@code cmd.exe} console expects — and a record longer than one round's budget is + * split: the surplus stays in {@code pendingInput} and leads the next round, so even a giant + * newline-free record keeps the rounds (and the output side) alternating. Once the local + * input ends and every pending character is out, the remote stdin is closed (the final Send + * carries {@code End="true"}) and {@code true} is returned: no further input will be + * forwarded. */ - private static boolean forwardLocalInput(final LineSource lines, final BufferedWriter stdin) throws IOException { + private static boolean forwardLocalInput( + final LineSource lines, + final BufferedWriter stdin, + final StringBuilder pendingInput + ) throws IOException { + int budget = MAX_INPUT_CHARS_PER_ROUND; boolean wrote = false; - int batchedChars = 0; - String line; - while (batchedChars < MAX_INPUT_CHARS_PER_ROUND && (line = lines.nextLine()) != null) { - stdin.write(line); - stdin.write("\r\n"); - batchedChars += line.length() + 2; - wrote = true; + while (budget > 0) { + if (pendingInput.length() > 0) { + final int take = Math.min(budget, pendingInput.length()); + stdin.write(pendingInput.substring(0, take)); + pendingInput.delete(0, take); + budget -= take; + wrote = true; + continue; + } + final String line = lines.nextLine(); + if (line == null) { + break; + } + pendingInput.append(line).append("\r\n"); } - if (lines.endOfInput()) { - // Closing flushes the pending lines too: they travel with the End mark, one single Send. + if (pendingInput.length() == 0 && lines.endOfInput()) { + // Closing flushes the written characters too: they travel with the End mark, one + // single Send. stdin.close(); return true; } diff --git a/src/main/java/org/metricshub/winrm/cli/WinRmCli.java b/src/main/java/org/metricshub/winrm/cli/WinRmCli.java index afe02ec..777a5eb 100644 --- a/src/main/java/org/metricshub/winrm/cli/WinRmCli.java +++ b/src/main/java/org/metricshub/winrm/cli/WinRmCli.java @@ -21,6 +21,8 @@ */ import java.io.Console; +import java.io.FileDescriptor; +import java.io.FileInputStream; import java.io.IOException; import java.io.InputStream; import java.io.PrintStream; @@ -98,25 +100,31 @@ public static void main(final String[] arguments) { System.err, WinRmCli::connect, WinRmCli::readConsolePassword, - new LocalInput(!stdinLooksRedirected(System.console() != null, System.in), System.in) + new LocalInput(!detectRedirectedStdin(), System.in) ) ); } /** - * Whether the local standard input looks piped or redirected — the signal engaging stdin - * forwarding for the {@code command} subcommand. {@code System.console()} alone is not it: it - * is also null when only the output is redirected ({@code ... > result.txt}), and - * consuming an interactive terminal in that case would hang the CLI waiting for an EOF the - * user never sends. So the input itself is probed: bytes already waiting at startup can only - * come from a redirected file or a pipe — an untouched terminal reports nothing available. - * The trade-off is a pipe whose producer has not written anything yet: its input is not - * forwarded (the JVM startup normally leaves any real producer plenty of time to get ahead). + * Whether the local standard input looks piped or redirected — the signal engaging automatic + * stdin forwarding for the {@code command} subcommand. {@code System.console()} alone is not + * it: it is also null when only the output is redirected ({@code ... > result.txt}), + * and consuming an interactive terminal in that case would hang the CLI waiting for an EOF + * the user never sends. So the input itself is probed: a seekable descriptor is a redirected + * file (terminals and pipes are not seekable — this catches empty files and the null device + * too), and bytes already waiting can only come from a pipe or a redirection. The undetectable + * remainder — a pipe whose producer has not written anything by the time the CLI starts — is + * what the explicit {@code --stdin} option is for. */ - static boolean stdinLooksRedirected(final boolean consoleAttached, final InputStream stdin) { - if (consoleAttached) { + private static boolean detectRedirectedStdin() { + if (System.console() != null) { return false; } + return stdinIsSeekable() || stdinHasAvailableInput(System.in); + } + + /** Bytes already waiting on the given (non-console) input: a pipe or a redirection. */ + static boolean stdinHasAvailableInput(final InputStream stdin) { try { return stdin.available() > 0; } catch (final IOException e) { @@ -124,6 +132,19 @@ static boolean stdinLooksRedirected(final boolean consoleAttached, final InputSt } } + /** Whether descriptor 0 is seekable — a redirected file or the null device. */ + private static boolean stdinIsSeekable() { + // Deliberately never closed: closing a stream created on FileDescriptor.in closes the + // process's standard input itself. + final FileInputStream probe = new FileInputStream(FileDescriptor.in); + try { + probe.getChannel().position(); + return true; + } catch (final IOException e) { + return false; + } + } + static int run( final String[] arguments, final PrintStream standardOutput, @@ -229,11 +250,12 @@ private static int execute( return interactiveShell(arguments, standardOutput, standardError, remote, localInput); } // Forward each output chunk as it arrives, so a long-running command can be followed live. - // Piped/redirected local standard input travels the other way, as the command's stdin. + // Piped/redirected local standard input travels the other way, as the command's stdin — + // automatically when detected, unconditionally with --stdin. final int exitCode = remote.executeCommand( arguments.input(), arguments.timeout(), - localInput.terminal ? null : localInput.stream, + arguments.forwardStdin() || !localInput.terminal ? localInput.stream : null, chunk -> { standardOutput.print(chunk); standardOutput.flush(); @@ -460,6 +482,7 @@ private static String help() { " -pf, --password-file Read a UTF-8 password from a file (preferred for automation)\n" + " -P, --port Target port (default: HTTP 5985, HTTPS 5986)\n" + " -t, --timeout Operation timeout in milliseconds (default: 60000)\n" + + " -i, --stdin Forward the local standard input to the remote command\n" + " --https Use HTTPS\n" + " --https-permissive Trust any HTTPS certificate and hostname (insecure)\n" + " --ntlm Use NTLM authentication (default)\n" + diff --git a/src/site/markdown/cli.md b/src/site/markdown/cli.md index 3047bcd..ca4e0d7 100644 --- a/src/site/markdown/cli.md +++ b/src/site/markdown/cli.md @@ -41,6 +41,7 @@ takes no argument. | `-pf, --password-file ` | Read the password from a UTF-8 file (preferred for automation, see below). | | `-P, --port ` | Target port. Default: 5985 for HTTP, 5986 for HTTPS. | | `-t, --timeout ` | Operation timeout in milliseconds. Default: 60000. See [Timeout semantics](#Timeout_semantics). | +| `-i, --stdin` | Forward the local standard input to the remote command (only with `command`); see below. | | `--https` | Connect over HTTPS. | | `--https-permissive` | Trust any HTTPS certificate and hostname. Intentionally insecure: testing and isolated hosts only. Requires `--https`. | | `--ntlm` | Authenticate with NTLM (the default). | @@ -103,12 +104,14 @@ standard input, with pipe semantics, so filters just work: java -jar winrm-java-standalone.jar -h server -u 'DOMAIN\user' -pf pw.txt command sort < data.txt ``` -Forwarding engages when input is already waiting on a non-console standard input at startup — -which a `< file` redirection or a normal pipe always satisfies. An interactive terminal is never -consumed, even when only the *output* is redirected (`... command hostname > result.txt`). 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`. +Forwarding engages automatically when the standard input is detectably redirected: it is a +seekable file (any `< file` redirection, including an empty file or the null device), or bytes +are already waiting on it at startup (a normal pipe). An interactive terminal is never consumed, +even when only the *output* is redirected (`... command hostname > result.txt`). The one +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`. ## Interactive shell diff --git a/src/test/java/org/metricshub/winrm/cli/InteractiveShellTest.java b/src/test/java/org/metricshub/winrm/cli/InteractiveShellTest.java index 1ebb9ec..0639523 100644 --- a/src/test/java/org/metricshub/winrm/cli/InteractiveShellTest.java +++ b/src/test/java/org/metricshub/winrm/cli/InteractiveShellTest.java @@ -360,13 +360,19 @@ void aFireHoseOfInputIsForwardedInBoundedBatches() throws Exception { } assertEquals(0, exitCode); - // Two bounded batches instead of one unbounded drain: the output side polled in between. + // Two bounded batches instead of one unbounded drain: the oversized record was SPLIT at + // the round budget — the output side polled in between — and nothing was lost or + // reordered. final List chunks = server.stdinChunks(); assertEquals(2, chunks.size()); - assertArrayEquals((hugeLine + "\r\n").getBytes(StandardCharsets.UTF_8), chunks.get(0).data()); + assertEquals(InteractiveShell.MAX_INPUT_CHARS_PER_ROUND, chunks.get(0).data().length); assertFalse(chunks.get(0).end()); - assertArrayEquals("exit\r\n".getBytes(StandardCharsets.UTF_8), chunks.get(1).data()); assertTrue(chunks.get(1).end()); + final ByteArrayOutputStream reassembled = new ByteArrayOutputStream(); + for (final FakeWsmanServer.StdinChunk chunk : chunks) { + reassembled.writeBytes(chunk.data()); + } + assertArrayEquals((hugeLine + "\r\nexit\r\n").getBytes(StandardCharsets.UTF_8), reassembled.toByteArray()); } @Test diff --git a/src/test/java/org/metricshub/winrm/cli/WinRmCliTest.java b/src/test/java/org/metricshub/winrm/cli/WinRmCliTest.java index 6d579fa..bafa4c0 100644 --- a/src/test/java/org/metricshub/winrm/cli/WinRmCliTest.java +++ b/src/test/java/org/metricshub/winrm/cli/WinRmCliTest.java @@ -169,20 +169,16 @@ void pipedLocalStandardInputIsForwardedToTheCommandButAConsoleIsNot() throws Exc @Test void detectsRedirectedStdinByProbingTheInputItself() { - // An attached console is definitely interactive, whatever the input holds. - assertFalse(WinRmCli.stdinLooksRedirected(true, new java.io.ByteArrayInputStream(new byte[] { 1 }))); + // Bytes already waiting on a non-console stdin: a pipe or a redirected file. + assertTrue(WinRmCli.stdinHasAvailableInput(new java.io.ByteArrayInputStream(new byte[] { 1 }))); - // No console + bytes already waiting: a pipe or a redirected file. - assertTrue(WinRmCli.stdinLooksRedirected(false, new java.io.ByteArrayInputStream(new byte[] { 1 }))); - - // No console but nothing waiting: typically an interactive terminal whose OUTPUT is - // redirected (System.console() is null then too) — consuming it would hang the CLI. - assertFalse(WinRmCli.stdinLooksRedirected(false, new java.io.ByteArrayInputStream(new byte[0]))); + // Nothing waiting: typically an interactive terminal whose OUTPUT is redirected + // (System.console() is null then too) — consuming it would hang the CLI. + assertFalse(WinRmCli.stdinHasAvailableInput(new java.io.ByteArrayInputStream(new byte[0]))); // A probe failure counts as not redirected: never risk blocking on a terminal. assertFalse( - WinRmCli.stdinLooksRedirected( - false, + WinRmCli.stdinHasAvailableInput( new java.io.InputStream() { @Override public int read() { @@ -198,6 +194,26 @@ public int available() throws java.io.IOException { ); } + @Test + void explicitStdinOptionForcesForwardingAndRequiresTheCommandSubcommand() throws Exception { + // --stdin forwards even when the local input looks interactive (the undetectable cases: + // an empty redirection, a pipe whose producer starts slowly). + final java.io.ByteArrayInputStream local = new java.io.ByteArrayInputStream(new byte[0]); + final FakeRemote remote = new FakeRemote(); + final Invocation invocation = invoke( + concat(REQUIRED, "--stdin", "command", "sort"), + args -> remote, + new WinRmCli.LocalInput(true, local) + ); + assertEquals(0, invocation.exitCode); + assertEquals(local, remote.forwardedStdin); + + // The option is meaningless outside the command subcommand. + final Invocation rejected = invoke(concat(REQUIRED, "--stdin", "wql", "SELECT 1"), args -> failingRemote()); + assertEquals(WinRmCli.EXIT_USAGE, rejected.exitCode); + assertTrue(rejected.stderr.contains("--stdin requires the command subcommand")); + } + @Test void mapsUsageTimeoutConnectionAuthenticationAndProtocolFailures() throws Exception { assertEquals( From 71e299dcc84b422c98d780b2ccdae701a99eefe6 Mon Sep 17 00:00:00 2001 From: Bertrand Martin Date: Wed, 29 Jul 2026 01:36:29 +0200 Subject: [PATCH 08/17] Address Codex round 7: lazy stdin encoder; bound shell input records at the reader - The stdin ChunkEncoder is now created on first actual input rather than with the RemoteProcess: a legal decode-only charset (x-JISAutoDetect, ISO-2022-CN) throws on Charset.newEncoder(), which previously failed start() AFTER the cursor was acquired - leaking the running command and the client's serial connection - even for callers that only read output. The failure now hits exactly the flush that actually needs an encoder, and a bare close of the untouched writer stays silent. - The shell's local reader thread no longer materializes whole lines (BufferedReader.readLine() would heap out on a giant newline-free record before any downstream bound applied): it reads and queues bounded pieces (4 KiB), normalizing every line-ending flavor (LF, CRLF, lone CR) to the CRLF the remote cmd.exe expects. The pump's InputSource seam moves from lines to terminator-included pieces accordingly, so the byte-bounded backpressure (256-piece queue) actually bounds memory by characters. Co-Authored-By: Claude Fable 5 --- .../org/metricshub/winrm/RemoteProcess.java | 23 ++- .../winrm/cli/InteractiveShell.java | 145 +++++++++++------- .../metricshub/winrm/CommandStdinTest.java | 36 +++++ .../winrm/cli/InteractiveShellTest.java | 92 ++++++----- 4 files changed, 203 insertions(+), 93 deletions(-) diff --git a/src/main/java/org/metricshub/winrm/RemoteProcess.java b/src/main/java/org/metricshub/winrm/RemoteProcess.java index dcd16db..03c1e2d 100644 --- a/src/main/java/org/metricshub/winrm/RemoteProcess.java +++ b/src/main/java/org/metricshub/winrm/RemoteProcess.java @@ -85,10 +85,14 @@ public final class RemoteProcess implements AutoCloseable { private final CommandCursor cursor; private final String hostname; private final Duration timeout; + private final Charset charset; private final ChunkDecoder stdoutDecoder; private final ChunkDecoder stderrDecoder; - private final ChunkEncoder stdinEncoder; + + // Created on first use (see sendStdin): a legal decode-only charset (e.g. x-JISAutoDetect) + // has no encoder, and must not fail a process that only ever READS output. + private ChunkEncoder stdinEncoder; // Decoded output that has arrived but has not been read yet, per channel. private final StringBuilder stdoutPending = new StringBuilder(); @@ -120,9 +124,9 @@ public final class RemoteProcess implements AutoCloseable { this.cursor = cursor; this.hostname = hostname; this.timeout = timeout; + this.charset = charset; this.stdoutDecoder = new ChunkDecoder(charset); this.stderrDecoder = new ChunkDecoder(charset); - this.stdinEncoder = new ChunkEncoder(charset); this.stdout = new BufferedReader(new ChannelReader(stdoutPending)); this.stderr = new BufferedReader(new ChannelReader(stderrPending)); this.stdin = new BufferedWriter(new StdinWriter()); @@ -420,8 +424,19 @@ private synchronized void sendStdin(final boolean end) { } // Stateful encoding: a charset mark is emitted once (not per flush) and a surrogate pair // split across two flushes is withheld until complete, exactly as one whole-string encode. - final byte[] bytes = stdinEncoder.encode(stdinPending.toString(), end); - stdinPending.setLength(0); + // The encoder is created on first actual input: a decode-only charset throws on + // newEncoder(), which must only ever hit a caller actually writing input — never a process + // that only reads output, and never a bare close of the untouched writer. + final byte[] bytes; + if (stdinEncoder == null && stdinPending.length() == 0) { + bytes = new byte[0]; + } else { + if (stdinEncoder == null) { + stdinEncoder = new ChunkEncoder(charset); + } + bytes = stdinEncoder.encode(stdinPending.toString(), end); + stdinPending.setLength(0); + } if (finished) { if (end) { stdinClosed = true; diff --git a/src/main/java/org/metricshub/winrm/cli/InteractiveShell.java b/src/main/java/org/metricshub/winrm/cli/InteractiveShell.java index 69059d8..d6ec2b6 100644 --- a/src/main/java/org/metricshub/winrm/cli/InteractiveShell.java +++ b/src/main/java/org/metricshub/winrm/cli/InteractiveShell.java @@ -26,6 +26,7 @@ import java.io.InputStream; import java.io.InputStreamReader; import java.io.PrintStream; +import java.io.Reader; import java.nio.charset.Charset; import java.time.Duration; import java.util.concurrent.BlockingQueue; @@ -39,15 +40,17 @@ * its exit code. *

* The pump is single-threaded on the wire — the WinRM connection stays strictly serial. - * Each round of the loop forwards the queued local input (one WSMan Send), polls the remote - * output for a short bounded wait, and writes whatever arrived to the local streams; the poll - * cadence is the interaction latency. An idle session never trips the inactivity timeout: every - * poll round trip completes with output or the protocol's "nothing yet" answer. + * Each round of the loop forwards the queued local input (one WSMan Send, bounded per round), + * polls the remote output for a short bounded wait, and writes whatever arrived to the local + * streams; the poll cadence is the interaction latency. An idle session never trips the + * inactivity timeout: every poll round trip completes with output or the protocol's "nothing + * yet" answer. *

- * The only helper thread reads the local input into a queue, because a blocking read of + * The only helper thread reads the local input into a bounded queue, because a blocking read of * {@code System.in} must not stall the pump; it never touches the connection. Input is - * line-oriented (like {@code winrs}): lines are forwarded once the local terminal hands - * them out, i.e. after Enter. + * line-oriented (like {@code winrs}): a terminal hands lines out after Enter, and they + * are forwarded CRLF-terminated. Redirected input flows through the same path in bounded pieces, + * so even a giant newline-free record streams through flat memory. *

* Local end of input (Ctrl+Z then Enter on Windows, Ctrl+D elsewhere) closes the remote stdin — * the final Send is flagged {@code End} — after which {@code cmd.exe} exits on its own. Setting @@ -67,34 +70,41 @@ final class InteractiveShell { /** * How much input one round forwards at most (one Send's worth of characters). Redirected - * input can queue lines much faster than the wire consumes them: the bound keeps the local + * input can queue pieces much faster than the wire consumes them: the bound keeps the local * buffering flat and, above all, keeps the rounds alternating — a fire hose of input must not * starve the output side of the pump. */ static final int MAX_INPUT_CHARS_PER_ROUND = 32 * 1024; /** - * How many lines the local reader thread may queue ahead of the pump before it blocks — + * Largest single piece the local reader thread queues. A record longer than this (a giant + * newline-free payload in a redirected input) is queued in pieces of this size instead of + * being materialized whole, so the memory bound holds by CHARACTERS, not by lines. + */ + static final int MAX_QUEUED_PIECE_CHARS = 4 * 1024; + + /** + * How many pieces the local reader thread may queue ahead of the pump before it blocks — * backpressure toward the local stdin, so a redirected file streams through bounded memory * instead of being swallowed whole. */ - private static final int INPUT_QUEUE_CAPACITY = 1_024; + private static final int INPUT_QUEUE_CAPACITY = 256; private InteractiveShell() {} /** - * The local input lines available to the pump, without ever blocking it. Seam between the - * pump's deterministic protocol loop and the helper thread that feeds it in production. + * The local input available to the pump, without ever blocking it. Seam between the pump's + * deterministic protocol loop and the helper thread that feeds it in production. */ - interface LineSource { + interface InputSource { /** - * @return the next queued local input line (without its line terminator), or {@code null} - * when none is available right now + * @return the next queued piece of local input — line terminators already normalized to + * CRLF and included — or {@code null} when nothing is available right now */ - String nextLine(); + String nextPiece(); /** - * @return whether the local input reached its end and every queued line was consumed + * @return whether the local input reached its end and every queued piece was consumed */ boolean endOfInput(); } @@ -103,7 +113,7 @@ interface LineSource { * Bridge the process to the given local streams until the remote command exits. * * @param process the running remote shell - * @param localInput the local input to forward, read line by line on a helper thread + * @param localInput the local input to forward, read on a helper thread * @param out where the remote standard output goes * @param err where the remote standard error goes * @param interruptRequested set externally (e.g. by a Ctrl+C handler) to forward a @@ -120,11 +130,11 @@ static int run( final AtomicBoolean interruptRequested, final long pollMillis ) throws IOException { - final QueuedLineSource lines = new QueuedLineSource(); - final Thread reader = new Thread(() -> lines.readAll(localInput), "winrm-shell-stdin"); + final QueuedInputSource pieces = new QueuedInputSource(); + final Thread reader = new Thread(() -> pieces.readAll(localInput), "winrm-shell-stdin"); reader.setDaemon(true); reader.start(); - return bridge(process, lines, out, err, interruptRequested, pollMillis); + return bridge(process, pieces, out, err, interruptRequested, pollMillis); } /** @@ -133,7 +143,7 @@ static int run( * the local streams — until the remote command completes. * * @param process the running remote shell - * @param lines the local input lines + * @param pieces the local input pieces * @param out where the remote standard output goes * @param err where the remote standard error goes * @param interruptRequested checked (and cleared) every round; forwards a {@code ctrl_c} Signal @@ -143,14 +153,14 @@ static int run( */ static int bridge( final RemoteProcess process, - final LineSource lines, + final InputSource pieces, final PrintStream out, final PrintStream err, final AtomicBoolean interruptRequested, final long pollMillis ) throws IOException { - // The tail of a record the current round could not fit entirely (an oversized line): - // forwarded first on the next rounds, before any new line is pulled. + // The tail of a piece the current round could not fit entirely: forwarded first on the + // next rounds, before any new piece is pulled. final StringBuilder pendingInput = new StringBuilder(); boolean eofForwarded = false; boolean completed = false; @@ -160,7 +170,7 @@ static int bridge( } if (!eofForwarded) { try { - eofForwarded = forwardLocalInput(lines, process.stdin(), pendingInput); + eofForwarded = forwardLocalInput(pieces, process.stdin(), pendingInput); } catch (final IllegalStateException e) { // The command completed while this input was being queued (the final Receive // beat it): nothing can consume it anymore. Stop forwarding — the next poll @@ -182,16 +192,14 @@ static int bridge( /** * Forward the queued local input — at most {@link #MAX_INPUT_CHARS_PER_ROUND} characters per - * round — as one flushed write (one WSMan Send). Lines are terminated with CRLF — what the - * remote {@code cmd.exe} console expects — and a record longer than one round's budget is + * round — as one flushed write (one WSMan Send). A piece exceeding one round's budget is * split: the surplus stays in {@code pendingInput} and leads the next round, so even a giant - * newline-free record keeps the rounds (and the output side) alternating. Once the local - * input ends and every pending character is out, the remote stdin is closed (the final Send - * carries {@code End="true"}) and {@code true} is returned: no further input will be - * forwarded. + * record keeps the rounds (and the output side) alternating. Once the local input ends and + * every pending character is out, the remote stdin is closed (the final Send carries + * {@code End="true"}) and {@code true} is returned: no further input will be forwarded. */ private static boolean forwardLocalInput( - final LineSource lines, + final InputSource pieces, final BufferedWriter stdin, final StringBuilder pendingInput ) throws IOException { @@ -206,13 +214,13 @@ private static boolean forwardLocalInput( wrote = true; continue; } - final String line = lines.nextLine(); - if (line == null) { + final String piece = pieces.nextPiece(); + if (piece == null) { break; } - pendingInput.append(line).append("\r\n"); + pendingInput.append(piece); } - if (pendingInput.length() == 0 && lines.endOfInput()) { + if (pendingInput.length() == 0 && pieces.endOfInput()) { // Closing flushes the written characters too: they travel with the End mark, one // single Send. stdin.close(); @@ -242,35 +250,59 @@ private static void forward(final BufferedReader channel, final PrintStream targ } /** - * The production {@link LineSource}: a queue fed by the helper thread reading the local - * standard input, drained by the pump without ever blocking. + * The production {@link InputSource}: a bounded queue fed by the helper thread reading the + * local standard input, drained by the pump without ever blocking. The reader normalizes + * every line ending (LF, CRLF, lone CR) to the CRLF the remote {@code cmd.exe} expects, and + * never materializes more than one bounded piece at a time — a record longer than + * {@link #MAX_QUEUED_PIECE_CHARS} is queued in slices, so memory stays bounded by characters, + * not by lines. */ - static final class QueuedLineSource implements LineSource { + static final class QueuedInputSource implements InputSource { - /** One queued item: a line, or the end of the local input when {@code line} is null. */ + /** One queued item: a piece of input, or the end of the local input when {@code null}. */ private static final class Item { - private final String line; + private final String piece; - private Item(final String line) { - this.line = line; + private Item(final String piece) { + this.piece = piece; } } private final BlockingQueue queue = new LinkedBlockingQueue<>(INPUT_QUEUE_CAPACITY); private boolean ended; - /** Feed the queue from the local input, line by line, ending with the EOF marker. */ + /** Feed the queue from the local input, piece by piece, ending with the EOF marker. */ void readAll(final InputStream localInput) { // The local console charset: what the terminal actually feeds System.in with. The // reader is deliberately never closed — the stream is the caller's (System.in). - final BufferedReader reader = new BufferedReader( - new InputStreamReader(localInput, Charset.defaultCharset()) - ); + final Reader reader = new BufferedReader(new InputStreamReader(localInput, Charset.defaultCharset())); + final StringBuilder piece = new StringBuilder(); try { - String line; - while ((line = reader.readLine()) != null) { - queue.put(new Item(line)); + boolean skipLoneLineFeed = false; + int read; + while ((read = reader.read()) != -1) { + final char character = (char) read; + if (skipLoneLineFeed) { + skipLoneLineFeed = false; + if (character == '\n') { + // The LF of a CRLF pair: its CR already emitted the line ending. + continue; + } + } + if (character == '\n' || character == '\r') { + skipLoneLineFeed = character == '\r'; + piece.append("\r\n"); + put(piece); + } else { + piece.append(character); + if (piece.length() >= MAX_QUEUED_PIECE_CHARS) { + put(piece); + } + } + } + if (piece.length() > 0) { + put(piece); } queue.put(new Item(null)); } catch (final IOException e) { @@ -281,6 +313,11 @@ void readAll(final InputStream localInput) { } } + private void put(final StringBuilder piece) throws InterruptedException { + queue.put(new Item(piece.toString())); + piece.setLength(0); + } + private void putSilently(final Item item) { try { queue.put(item); @@ -290,7 +327,7 @@ private void putSilently(final Item item) { } @Override - public String nextLine() { + public String nextPiece() { if (ended) { return null; } @@ -298,11 +335,11 @@ public String nextLine() { if (item == null) { return null; } - if (item.line == null) { + if (item.piece == null) { ended = true; return null; } - return item.line; + return item.piece; } @Override diff --git a/src/test/java/org/metricshub/winrm/CommandStdinTest.java b/src/test/java/org/metricshub/winrm/CommandStdinTest.java index 50a2489..cc5c3d2 100644 --- a/src/test/java/org/metricshub/winrm/CommandStdinTest.java +++ b/src/test/java/org/metricshub/winrm/CommandStdinTest.java @@ -479,6 +479,42 @@ void stdinEncodingIsStatefulAcrossFlushes() throws Exception { assertTrue(chunks.get(2).end()); } + @Test + void aDecodeOnlyCharsetDoesNotBreakAProcessThatNeverWrites() throws Exception { + // x-JISAutoDetect decodes but cannot encode (Charset.newEncoder() throws): it must only + // ever fail a caller actually WRITING input, never a process that just reads output. + org.junit.jupiter.api.Assumptions.assumeTrue(java.nio.charset.Charset.isSupported("x-JISAutoDetect")); + + enqueueStartup(); + server + .enqueue( + 200, + envelope( + receiveResponse( + stream("stdout", COMMAND_ID, "plain ascii\r\n".getBytes(StandardCharsets.US_ASCII)), + done(COMMAND_ID, 0) + ) + ) + ) + .enqueue(200, envelope(signalResponse())); + enqueueShellDeletion(server); + + try (WinRMClient client = builder().build()) { + try ( + RemoteProcess process = client.command("run.exe") + .charset(java.nio.charset.Charset.forName("x-JISAutoDetect")) + .start()) { + assertEquals("plain ascii", process.stdout().readLine()); + assertEquals(0, process.waitFor()); + + // Writing, on the other hand, has no encoder to work with: the failure is the + // writer's, reported on the flush that actually needs it. + process.stdin().write("input"); + assertThrows(UnsupportedOperationException.class, () -> process.stdin().flush()); + } + } + } + @Test void cursorRejectsInputAfterTheEndMark() throws Exception { enqueueStartup(); diff --git a/src/test/java/org/metricshub/winrm/cli/InteractiveShellTest.java b/src/test/java/org/metricshub/winrm/cli/InteractiveShellTest.java index 0639523..d2d3ede 100644 --- a/src/test/java/org/metricshub/winrm/cli/InteractiveShellTest.java +++ b/src/test/java/org/metricshub/winrm/cli/InteractiveShellTest.java @@ -53,7 +53,7 @@ /** * Headless tests of the interactive {@code shell} pump (issue #136, phase 2) against - * {@link FakeWsmanServer}, with a scripted {@link InteractiveShell.LineSource} making every round + * {@link FakeWsmanServer}, with a scripted {@link InteractiveShell.InputSource} making every round * deterministic: input line → Send on the wire, scripted output → local stdout, local EOF → * {@code End="true"}, Ctrl+C → {@code ctrl_c} Signal, and exit-code propagation. */ @@ -93,29 +93,29 @@ private void enqueueStartup() { server.enqueue(200, envelope(resourceCreated(SHELL_ID))).enqueue(200, envelope(commandResponse(COMMAND_ID))); } - /** A line source whose {@code nextLine()} answers are fully scripted, including "not yet". */ - private static final class ScriptedLines implements InteractiveShell.LineSource { + /** An input source whose {@code nextPiece()} answers are fully scripted, including "not yet". */ + private static final class ScriptedPieces implements InteractiveShell.InputSource { private final Deque answers = new ArrayDeque<>(); private boolean ended; - /** The given lines, one per {@code nextLine()} call, then the end of the local input. */ - private static ScriptedLines endingAfter(final String... lines) { - final ScriptedLines scripted = new ScriptedLines(); - for (final String line : lines) { - scripted.answers.addLast(line); + /** The given pieces, one per {@code nextPiece()} call, then the end of the local input. */ + private static ScriptedPieces endingAfter(final String... pieces) { + final ScriptedPieces scripted = new ScriptedPieces(); + for (final String piece : pieces) { + scripted.answers.addLast(piece); } scripted.ended = true; return scripted; } /** A local input that never produces anything and never ends (a silent terminal). */ - private static ScriptedLines silent() { - return new ScriptedLines(); + private static ScriptedPieces silent() { + return new ScriptedPieces(); } @Override - public String nextLine() { + public String nextPiece() { return answers.pollFirst(); } @@ -152,7 +152,7 @@ void bridgesInputLinesAndOutputAndPropagatesTheExitCode() throws Exception { try (RemoteProcess process = client.command("cmd.exe").start()) { exitCode = InteractiveShell.bridge( process, - ScriptedLines.endingAfter("MODE PREPARE"), + ScriptedPieces.endingAfter("MODE PREPARE\r\n"), new PrintStream(stdout, true, "UTF-8"), new PrintStream(stderr, true, "UTF-8"), new AtomicBoolean(), @@ -194,7 +194,7 @@ void forwardsCtrlCAsTheCtrlCSignalAndTheSessionSurvivesIt() throws Exception { try (RemoteProcess process = client.command("cmd.exe").start()) { exitCode = InteractiveShell.bridge( process, - ScriptedLines.silent(), + ScriptedPieces.silent(), new PrintStream(stdout, true, "UTF-8"), new PrintStream(new ByteArrayOutputStream(), true, "UTF-8"), interruptRequested, @@ -242,14 +242,14 @@ void echoesOutputArrivingBeforeAnyInput() throws Exception { final PrintStream out = new PrintStream(stdout, true, "UTF-8"); exitCode = InteractiveShell.bridge( process, - new InteractiveShell.LineSource() { + new InteractiveShell.InputSource() { private int round; @Override - public String nextLine() { + public String nextPiece() { round++; // Round 1 (and the drain call right after "exit"): nothing queued. - return round == 2 ? "exit" : null; + return round == 2 ? "exit\r\n" : null; } @Override @@ -291,13 +291,13 @@ void inputQueuedWhileTheFinalReceiveWasInFlightDoesNotHideTheExitCode() throws E // A line shows up right AFTER the final Receive: it can no longer be consumed, and it must // not turn the session into a failure — the exit code wins. - final InteractiveShell.LineSource lateLine = new InteractiveShell.LineSource() { + final InteractiveShell.InputSource latePiece = new InteractiveShell.InputSource() { private int round; @Override - public String nextLine() { + public String nextPiece() { round++; - return round == 2 ? "too late" : null; + return round == 2 ? "too late\r\n" : null; } @Override @@ -310,7 +310,7 @@ public boolean endOfInput() { try (RemoteProcess process = client.command("cmd.exe").start()) { exitCode = InteractiveShell.bridge( process, - lateLine, + latePiece, new PrintStream(new ByteArrayOutputStream(), true, "UTF-8"), new PrintStream(new ByteArrayOutputStream(), true, "UTF-8"), new AtomicBoolean(), @@ -350,7 +350,7 @@ void aFireHoseOfInputIsForwardedInBoundedBatches() throws Exception { try (RemoteProcess process = client.command("cmd.exe").start()) { exitCode = InteractiveShell.bridge( process, - ScriptedLines.endingAfter(hugeLine, "exit"), + ScriptedPieces.endingAfter(hugeLine + "\r\n", "exit\r\n"), new PrintStream(new ByteArrayOutputStream(), true, "UTF-8"), new PrintStream(new ByteArrayOutputStream(), true, "UTF-8"), new AtomicBoolean(), @@ -376,20 +376,42 @@ void aFireHoseOfInputIsForwardedInBoundedBatches() throws Exception { } @Test - void queuedLineSourceDeliversLinesInOrderThenTheEndOfInput() { - // The production LineSource behind InteractiveShell.run: fed by the helper thread reading + void queuedInputSourceNormalizesLineEndingsAndReportsTheEnd() { + // The production InputSource behind InteractiveShell.run: fed by the helper thread reading // the local standard input. Here the whole stream is read synchronously (readAll returns - // once the input ends), making the outcome deterministic. - final InteractiveShell.QueuedLineSource lines = new InteractiveShell.QueuedLineSource(); - lines.readAll(new ByteArrayInputStream("first\nsecond\r\nexit\n".getBytes(StandardCharsets.UTF_8))); - - assertFalse(lines.endOfInput(), "queued lines must be consumed before the end of input is reported"); - assertEquals("first", lines.nextLine()); - assertEquals("second", lines.nextLine()); - assertFalse(lines.endOfInput()); - assertEquals("exit", lines.nextLine()); - assertNull(lines.nextLine()); - assertTrue(lines.endOfInput()); - assertNull(lines.nextLine()); + // once the input ends), making the outcome deterministic. Every line-ending flavor (LF, + // CRLF, lone CR) becomes the CRLF the remote cmd.exe expects, and a final unterminated + // record is delivered as-is. + final InteractiveShell.QueuedInputSource pieces = new InteractiveShell.QueuedInputSource(); + pieces.readAll(new ByteArrayInputStream("first\nsecond\r\nthird\rtail".getBytes(StandardCharsets.UTF_8))); + + assertFalse(pieces.endOfInput(), "queued pieces must be consumed before the end of input is reported"); + assertEquals("first\r\n", pieces.nextPiece()); + assertEquals("second\r\n", pieces.nextPiece()); + assertEquals("third\r\n", pieces.nextPiece()); + assertFalse(pieces.endOfInput()); + assertEquals("tail", pieces.nextPiece()); + assertNull(pieces.nextPiece()); + assertTrue(pieces.endOfInput()); + assertNull(pieces.nextPiece()); + } + + @Test + void queuedInputSourceSlicesANewlineFreeRecordInsteadOfMaterializingIt() { + // A giant record without any newline (minified JSON, base64) must be queued in bounded + // slices: the memory bound holds by characters, never by lines. + final char[] record = new char[InteractiveShell.MAX_QUEUED_PIECE_CHARS + 500]; + java.util.Arrays.fill(record, 'x'); + final InteractiveShell.QueuedInputSource pieces = new InteractiveShell.QueuedInputSource(); + pieces.readAll(new ByteArrayInputStream((new String(record) + "\n").getBytes(StandardCharsets.UTF_8))); + + final String first = pieces.nextPiece(); + final String second = pieces.nextPiece(); + assertEquals(InteractiveShell.MAX_QUEUED_PIECE_CHARS, first.length()); + assertEquals(500 + 2, second.length()); + assertTrue(second.endsWith("\r\n")); + assertEquals(new String(record) + "\r\n", first + second); + assertNull(pieces.nextPiece()); + assertTrue(pieces.endOfInput()); } } From bf875997e5bea4958eff018d61577838ce283e32 Mon Sep 17 00:00:00 2001 From: Bertrand Martin Date: Wed, 29 Jul 2026 01:43:39 +0200 Subject: [PATCH 09/17] Address Codex round 8: the last stdin call wins stdin() (no argument) now discards previously pre-supplied input, so a reusable request switched to interactive mode actually behaves interactively: start() no longer pre-sends the stale source, and execute() rejects the declaration as documented. Co-Authored-By: Claude Fable 5 --- src/main/java/org/metricshub/winrm/CommandRequest.java | 4 +++- src/test/java/org/metricshub/winrm/CommandStdinTest.java | 4 ++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/src/main/java/org/metricshub/winrm/CommandRequest.java b/src/main/java/org/metricshub/winrm/CommandRequest.java index 8fd16aa..3f222c3 100644 --- a/src/main/java/org/metricshub/winrm/CommandRequest.java +++ b/src/main/java/org/metricshub/winrm/CommandRequest.java @@ -197,11 +197,13 @@ public CommandRequest stdin(final String text) { * Only meaningful with {@link #start()} — {@link #execute()} cannot take interactive input and * rejects a request configured this way. Without any {@code stdin} call, the historical * console semantics are kept (what a command that never reads input, and the interactive CLI - * shell, want). + * shell, want). Like every {@code stdin} variant, the LAST call wins: any previously + * pre-supplied input is discarded. * * @return this request */ public CommandRequest stdin() { + this.stdinSource = null; this.pipeStdin = true; return this; } diff --git a/src/test/java/org/metricshub/winrm/CommandStdinTest.java b/src/test/java/org/metricshub/winrm/CommandStdinTest.java index cc5c3d2..264ac7e 100644 --- a/src/test/java/org/metricshub/winrm/CommandStdinTest.java +++ b/src/test/java/org/metricshub/winrm/CommandStdinTest.java @@ -542,6 +542,10 @@ void interactiveStdinDeclarationIsRejectedByExecute() { // execute() cannot take interactive input: the misconfiguration is rejected before any // request leaves the client — a command waiting on a never-fed pipe would hang instead. assertThrows(IllegalStateException.class, () -> client.command("sort").stdin().execute()); + + // The last stdin call wins: the no-argument declaration discards previously + // pre-supplied input, so this request is interactive too — and rejected the same way. + assertThrows(IllegalStateException.class, () -> client.command("sort").stdin("data\n").stdin().execute()); } assertEquals(0, server.decryptedRequests().size()); } From f244e14cf132f88f8931090da70845496c87f57a Mon Sep 17 00:00:00 2001 From: Bertrand Martin Date: Wed, 29 Jul 2026 12:13:21 +0200 Subject: [PATCH 10/17] Make the interactive shell echo-free: cmd.exe /Q over pipe-mode stdin The shell subcommand now runs `cmd.exe /Q` (cmd's command echo off) over pipe-mode stdin (WINRS_CONSOLEMODE_STDIN=FALSE, the winrs -noecho equivalent): the forwarded input is never repeated back by the remote side - the local terminal already shows what the user types, and the output stream carries the prompts and command output only. Pipe-mode stdin also turns the local end-of-input into a real EOF: cmd.exe exits on it, so a piped session ends cleanly without an explicit `exit`. Verified live against a real Windows Server 2008 R2 host: no input echo, prompts and output intact, exit-code propagation unchanged, and an EOF-only session terminates with the shell's exit code. Co-Authored-By: Claude Fable 5 --- .../org/metricshub/winrm/cli/WinRmCli.java | 19 ++++-- src/site/markdown/cli.md | 11 +++- .../metricshub/winrm/cli/WinRmCliTest.java | 58 +++++++++++++++++++ 3 files changed, 81 insertions(+), 7 deletions(-) diff --git a/src/main/java/org/metricshub/winrm/cli/WinRmCli.java b/src/main/java/org/metricshub/winrm/cli/WinRmCli.java index 777a5eb..d6ac445 100644 --- a/src/main/java/org/metricshub/winrm/cli/WinRmCli.java +++ b/src/main/java/org/metricshub/winrm/cli/WinRmCli.java @@ -551,6 +551,12 @@ int shell(long timeout, InputStream localInput, PrintStream out, PrintStream err /** The real remote operations: the streaming terminals of the fluent {@link WinRMClient}. */ static final class FluentRemoteOperations implements RemoteOperations { + /** + * What the {@code shell} subcommand runs remotely: {@code cmd.exe} with its command echo + * off ({@code /Q}) — piped-in command lines are not repeated in the output. + */ + static final String SHELL_COMMAND = "cmd.exe /Q"; + private final WinRMClient client; FluentRemoteOperations(final WinRMClient client) { @@ -591,12 +597,17 @@ public int shell( final PrintStream err, final AtomicBoolean interruptRequested ) throws Exception { - // The remote side of the bridge: cmd.exe with console-mode stdin, exactly like winrs. - // The timeout bounds each protocol round trip; an idle session never trips it, because - // every poll completes with output or the protocol's "nothing yet" answer. + // The remote side of the bridge: cmd.exe with its command echo off (/Q) over pipe-mode + // stdin (the winrs -noecho equivalent), so the session never echoes the forwarded input + // back — the local terminal already shows what the user types, and the output stream + // carries prompts and command output only. Pipe-mode stdin also makes the local + // end-of-input a real EOF: cmd.exe exits on it. The timeout bounds each protocol round + // trip; an idle session never trips it, because every poll completes with output or the + // protocol's "nothing yet" answer. try ( - RemoteProcess process = client.command("cmd.exe") + RemoteProcess process = client.command(SHELL_COMMAND) .timeout(Duration.ofMillis(timeout)) + .stdin() .start()) { return InteractiveShell .run(process, localInput, out, err, interruptRequested, InteractiveShell.DEFAULT_POLL_MILLIS); diff --git a/src/site/markdown/cli.md b/src/site/markdown/cli.md index ca4e0d7..359ba1c 100644 --- a/src/site/markdown/cli.md +++ b/src/site/markdown/cli.md @@ -120,9 +120,14 @@ java -jar winrm-java-standalone.jar -h server.example.net -u 'DOMAIN\user' -pf p ``` `shell` starts `cmd.exe` on the remote host and bridges it to the local terminal until the remote -shell exits (type `exit`, or send end-of-input: Ctrl+Z then Enter on Windows, Ctrl+D elsewhere). -The remote exit code is propagated through the usual [exit-code contract](#Exit_codes). - +shell exits (type `exit`, or send end-of-input: Ctrl+Z then Enter on Windows, Ctrl+D elsewhere — +the remote `cmd.exe` exits on the EOF). The remote exit code is propagated through the usual +[exit-code contract](#Exit_codes). + +* **Echo is off** — the remote shell runs `cmd.exe /Q` over pipe-mode standard input (the + `winrs -noecho` equivalent), so the input you forward is never repeated back by the remote + side: your terminal already shows what you type, and the output stream carries the prompts and + the command output only. * **Line-oriented, like `winrs`** — input is line-buffered by the local terminal and forwarded when you press Enter. There is no raw-terminal/PTY mode (with zero dependencies there is none in pure Java): full-screen programs, cmd.exe line editing, tab completion, and ANSI cursor control diff --git a/src/test/java/org/metricshub/winrm/cli/WinRmCliTest.java b/src/test/java/org/metricshub/winrm/cli/WinRmCliTest.java index bafa4c0..e96ed7c 100644 --- a/src/test/java/org/metricshub/winrm/cli/WinRmCliTest.java +++ b/src/test/java/org/metricshub/winrm/cli/WinRmCliTest.java @@ -38,6 +38,7 @@ import java.util.concurrent.TimeoutException; import java.util.function.Consumer; import org.junit.jupiter.api.Test; +import org.metricshub.winrm.light.FakeWsmanResponses; import org.metricshub.winrm.light.FakeWsmanServer; class WinRmCliTest { @@ -122,6 +123,63 @@ void shellSubcommandBridgesTheRemoteShellAndPropagatesItsExitCode() throws Excep assertTrue(remote.closed); } + @Test + void shellRunsAQuietCmdOverPipeModeStdin() throws Exception { + // Full stack against the in-process WSMan server, through the CLI's real connect factory: + // the shell must be echo-free — cmd.exe started with /Q (no command echo) over pipe-mode + // stdin (the winrs -noecho equivalent: input is never echoed by the remote console). + try (FakeWsmanServer server = new FakeWsmanServer("FAKE", "user", "secret")) { + enqueueShellCreation(server); + server + .enqueue(200, FakeWsmanResponses.envelope(FakeWsmanResponses.commandResponse("CMD-1"))) + .enqueue( + 200, + FakeWsmanResponses.envelope(FakeWsmanResponses.receiveResponse("", FakeWsmanResponses.done("CMD-1", 0))) + ) + .enqueue(200, FakeWsmanResponses.envelope(FakeWsmanResponses.signalResponse())); + enqueueShellDeletion(server); + + // A local input that never delivers anything: the pump only ever polls, keeping the + // scripted exchange deterministic (the daemon reader thread blocks forever). + final java.io.InputStream never = new java.io.InputStream() { + @Override + public int read() { + try { + new java.util.concurrent.CountDownLatch(1).await(); + } catch (final InterruptedException e) { + Thread.currentThread().interrupt(); + } + return -1; + } + }; + + final Invocation invocation = invoke( + new String[] + { + "-h", + "127.0.0.1", + "-P", + String.valueOf(server.port()), + "-u", + "FAKE\\user", + "-p", + "secret", + "-t", + "10000", + "shell" + }, + WinRmCli::connect, + new WinRmCli.LocalInput(true, never) + ); + + assertEquals(0, invocation.exitCode); + final List requests = server.decryptedRequests(); + final String command = requests.get(1); + assertTrue(command.contains("cmd.exe /Q"), command); + assertTrue(command.contains("FALSE"), command); + } + } + @Test void shellTakesNoArgument() throws Exception { final Invocation invocation = invoke(concat(REQUIRED, "shell", "cmd.exe"), args -> failingRemote()); From a3f81a559ad30aa8557070824792baffa0fedcb5 Mon Sep 17 00:00:00 2001 From: Bertrand Martin Date: Wed, 29 Jul 2026 12:30:09 +0200 Subject: [PATCH 11/17] Address Codex round 9: real stdin charset; propagate local read failures - The shell's local input is no longer decoded with Charset.defaultCharset(), which is UTF-8 on modern JDKs even when a Windows console feeds System.in with its OEM code page (mangling non-ASCII commands). The real encoding is probed in order: stdin.encoding (recent JDKs), sun.stdin.encoding (Windows consoles on older JDKs), Console.charset() (Java 17+, reached reflectively), native.encoding (Java 18+), then the process default. - A local stdin read failure no longer masquerades as a normal end of input (which closed the remote stdin, let the shell run the truncated input, and reported success): the failure travels through the queue and fails the bridge, so the CLI exits nonzero with the actual error. Co-Authored-By: Claude Fable 5 --- .../winrm/cli/InteractiveShell.java | 84 ++++++++++++++++--- .../winrm/cli/InteractiveShellTest.java | 59 ++++++++++++- 2 files changed, 129 insertions(+), 14 deletions(-) diff --git a/src/main/java/org/metricshub/winrm/cli/InteractiveShell.java b/src/main/java/org/metricshub/winrm/cli/InteractiveShell.java index d6ec2b6..0f7bd2d 100644 --- a/src/main/java/org/metricshub/winrm/cli/InteractiveShell.java +++ b/src/main/java/org/metricshub/winrm/cli/InteractiveShell.java @@ -22,6 +22,7 @@ import java.io.BufferedReader; import java.io.BufferedWriter; +import java.io.Console; import java.io.IOException; import java.io.InputStream; import java.io.InputStreamReader; @@ -32,6 +33,7 @@ import java.util.concurrent.BlockingQueue; import java.util.concurrent.LinkedBlockingQueue; import java.util.concurrent.atomic.AtomicBoolean; +import java.util.function.UnaryOperator; import org.metricshub.winrm.RemoteProcess; /** @@ -100,8 +102,10 @@ interface InputSource { /** * @return the next queued piece of local input — line terminators already normalized to * CRLF and included — or {@code null} when nothing is available right now + * @throws IOException when reading the local input failed: the session must fail rather + * than pass the truncated input off as the complete one */ - String nextPiece(); + String nextPiece() throws IOException; /** * @return whether the local input reached its end and every queued piece was consumed @@ -109,6 +113,48 @@ interface InputSource { boolean endOfInput(); } + /** + * The charset the local standard input is actually encoded with. {@code Charset.defaultCharset()} + * is wrong for a Windows console on modern JDKs: the JVM default is UTF-8 there while the + * console feeds {@code System.in} with its OEM code page. The JDK exposes the real answer + * through {@code stdin.encoding} (recent JDKs), {@code sun.stdin.encoding} (Windows consoles + * on older JDKs), {@code Console.charset()} (Java 17+, reached reflectively — this code + * targets Java 11), and {@code native.encoding} (Java 18+), tried in that order before + * falling back to the process default. + */ + static Charset localInputCharset(final UnaryOperator systemProperty, final Console console) { + for (final String property : new String[] { "stdin.encoding", "sun.stdin.encoding" }) { + final Charset charset = charsetOf(systemProperty.apply(property)); + if (charset != null) { + return charset; + } + } + if (console != null) { + try { + final Object charset = Console.class.getMethod("charset").invoke(console); + if (charset instanceof Charset) { + return (Charset) charset; + } + } catch (final ReflectiveOperationException | RuntimeException ignored) { + // Java 11-16: no Console.charset() — keep probing. + } + } + final Charset nativeCharset = charsetOf(systemProperty.apply("native.encoding")); + return nativeCharset != null ? nativeCharset : Charset.defaultCharset(); + } + + /** The charset of the given name, or {@code null} when absent, unknown, or unsupported. */ + private static Charset charsetOf(final String name) { + if (name == null) { + return null; + } + try { + return Charset.forName(name); + } catch (final RuntimeException e) { + return null; + } + } + /** * Bridge the process to the given local streams until the remote command exits. * @@ -259,24 +305,36 @@ private static void forward(final BufferedReader channel, final PrintStream targ */ static final class QueuedInputSource implements InputSource { - /** One queued item: a piece of input, or the end of the local input when {@code null}. */ + /** + * One queued item: a piece of input, the normal end of the local input ({@code piece} and + * {@code failure} both null), or a local read failure to surface to the pump. + */ private static final class Item { private final String piece; + private final IOException failure; - private Item(final String piece) { + private Item(final String piece, final IOException failure) { this.piece = piece; + this.failure = failure; } } private final BlockingQueue queue = new LinkedBlockingQueue<>(INPUT_QUEUE_CAPACITY); private boolean ended; - /** Feed the queue from the local input, piece by piece, ending with the EOF marker. */ + /** + * Feed the queue from the local input, piece by piece, ending with the EOF marker — or + * with the read failure itself, so the pump fails the session instead of passing the + * truncated input off as the complete one. + */ void readAll(final InputStream localInput) { - // The local console charset: what the terminal actually feeds System.in with. The - // reader is deliberately never closed — the stream is the caller's (System.in). - final Reader reader = new BufferedReader(new InputStreamReader(localInput, Charset.defaultCharset())); + // The local input charset: what the terminal (or the redirection) actually feeds + // System.in with. The reader is deliberately never closed — the stream is the + // caller's (System.in). + final Reader reader = new BufferedReader( + new InputStreamReader(localInput, localInputCharset(System::getProperty, System.console())) + ); final StringBuilder piece = new StringBuilder(); try { boolean skipLoneLineFeed = false; @@ -304,17 +362,16 @@ void readAll(final InputStream localInput) { if (piece.length() > 0) { put(piece); } - queue.put(new Item(null)); + queue.put(new Item(null, null)); } catch (final IOException e) { - // The local input died: treat it as its end. - putSilently(new Item(null)); + putSilently(new Item(null, e)); } catch (final InterruptedException e) { Thread.currentThread().interrupt(); } } private void put(final StringBuilder piece) throws InterruptedException { - queue.put(new Item(piece.toString())); + queue.put(new Item(piece.toString(), null)); piece.setLength(0); } @@ -327,7 +384,7 @@ private void putSilently(final Item item) { } @Override - public String nextPiece() { + public String nextPiece() throws IOException { if (ended) { return null; } @@ -337,6 +394,9 @@ public String nextPiece() { } if (item.piece == null) { ended = true; + if (item.failure != null) { + throw new IOException("Reading the local standard input failed", item.failure); + } return null; } return item.piece; diff --git a/src/test/java/org/metricshub/winrm/cli/InteractiveShellTest.java b/src/test/java/org/metricshub/winrm/cli/InteractiveShellTest.java index d2d3ede..931a0da 100644 --- a/src/test/java/org/metricshub/winrm/cli/InteractiveShellTest.java +++ b/src/test/java/org/metricshub/winrm/cli/InteractiveShellTest.java @@ -376,7 +376,7 @@ void aFireHoseOfInputIsForwardedInBoundedBatches() throws Exception { } @Test - void queuedInputSourceNormalizesLineEndingsAndReportsTheEnd() { + void queuedInputSourceNormalizesLineEndingsAndReportsTheEnd() throws Exception { // The production InputSource behind InteractiveShell.run: fed by the helper thread reading // the local standard input. Here the whole stream is read synchronously (readAll returns // once the input ends), making the outcome deterministic. Every line-ending flavor (LF, @@ -397,7 +397,62 @@ void queuedInputSourceNormalizesLineEndingsAndReportsTheEnd() { } @Test - void queuedInputSourceSlicesANewlineFreeRecordInsteadOfMaterializingIt() { + void aLocalReadFailureFailsTheSourceInsteadOfEndingIt() throws Exception { + // A failing local stdin must NOT read as a normal end of input: the remote shell would + // execute the truncated input and the CLI would report success despite the local error. + final java.io.InputStream failing = new java.io.InputStream() { + private int calls; + + @Override + public int read() throws java.io.IOException { + calls++; + if (calls <= 3) { + return "ok\n".charAt(calls - 1); + } + throw new java.io.IOException("disk error"); + } + }; + final InteractiveShell.QueuedInputSource pieces = new InteractiveShell.QueuedInputSource(); + pieces.readAll(failing); + + assertEquals("ok\r\n", pieces.nextPiece()); + final java.io.IOException failure = org.junit.jupiter.api.Assertions.assertThrows( + java.io.IOException.class, + pieces::nextPiece + ); + assertEquals("disk error", failure.getCause().getMessage()); + assertTrue(pieces.endOfInput()); + } + + @Test + void localInputCharsetProbesTheJdkSignalsBeforeTheProcessDefault() { + // stdin.encoding (recent JDKs) wins over everything. + assertEquals( + StandardCharsets.ISO_8859_1, + InteractiveShell.localInputCharset(name -> "stdin.encoding".equals(name) ? "ISO-8859-1" : null, null) + ); + // sun.stdin.encoding: what a Windows console reports on older JDKs. + assertEquals( + StandardCharsets.US_ASCII, + InteractiveShell.localInputCharset(name -> "sun.stdin.encoding".equals(name) ? "US-ASCII" : null, null) + ); + // An unknown name is skipped and the probing continues down to native.encoding. + assertEquals( + StandardCharsets.UTF_16BE, + InteractiveShell.localInputCharset( + name -> "stdin.encoding".equals(name) ? "no-such-charset" : "native.encoding".equals(name) ? "UTF-16BE" : null, + null + ) + ); + // No signal at all: the process default. + assertEquals( + java.nio.charset.Charset.defaultCharset(), + InteractiveShell.localInputCharset(name -> null, null) + ); + } + + @Test + void queuedInputSourceSlicesANewlineFreeRecordInsteadOfMaterializingIt() throws Exception { // A giant record without any newline (minified JSON, base64) must be queued in bounded // slices: the memory bound holds by characters, never by lines. final char[] record = new char[InteractiveShell.MAX_QUEUED_PIECE_CHARS + 500]; From e0477bad0b67371c3a6bdd4d73dacb6acb609d21 Mon Sep 17 00:00:00 2001 From: Bertrand Martin Date: Wed, 29 Jul 2026 12:44:04 +0200 Subject: [PATCH 12/17] Address Codex round 10: pump tests exercise the production shell configuration The ctrl_c-requires-console-mode claim is refuted by a live probe against a real Windows Server 2008 R2 host: with WINRS_CONSOLEMODE_STDIN=FALSE, the WSMan ctrl_c Signal interrupted a 29-second ping after 0.6 s ("Control-C" in the output) and the session kept running - the Signal is delivered as a console ctrl event to the command's process group regardless of the stdin mode option. Production keeps pipe-mode stdin (echo-free, real EOF). The valid part of the finding: the pump tests started a plain cmd.exe with console-mode stdin instead of what the shell subcommand actually runs. They now start the production configuration (cmd.exe /Q over pipe-mode stdin) through a shared helper. Co-Authored-By: Claude Fable 5 --- .../winrm/cli/InteractiveShellTest.java | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/src/test/java/org/metricshub/winrm/cli/InteractiveShellTest.java b/src/test/java/org/metricshub/winrm/cli/InteractiveShellTest.java index 931a0da..2d5341d 100644 --- a/src/test/java/org/metricshub/winrm/cli/InteractiveShellTest.java +++ b/src/test/java/org/metricshub/winrm/cli/InteractiveShellTest.java @@ -93,6 +93,15 @@ private void enqueueStartup() { server.enqueue(200, envelope(resourceCreated(SHELL_ID))).enqueue(200, envelope(commandResponse(COMMAND_ID))); } + /** + * The exact remote process the production shell subcommand starts: {@code cmd.exe /Q} over + * pipe-mode stdin — the pump tests must exercise the production configuration (the ctrl_c + * Signal under pipe-mode stdin is live-verified against a real Windows host). + */ + private static RemoteProcess shellProcess(final WinRMClient client) { + return client.command(WinRmCli.FluentRemoteOperations.SHELL_COMMAND).stdin().start(); + } + /** An input source whose {@code nextPiece()} answers are fully scripted, including "not yet". */ private static final class ScriptedPieces implements InteractiveShell.InputSource { @@ -149,7 +158,7 @@ void bridgesInputLinesAndOutputAndPropagatesTheExitCode() throws Exception { final ByteArrayOutputStream stderr = new ByteArrayOutputStream(); final int exitCode; try (WinRMClient client = client()) { - try (RemoteProcess process = client.command("cmd.exe").start()) { + try (RemoteProcess process = shellProcess(client)) { exitCode = InteractiveShell.bridge( process, ScriptedPieces.endingAfter("MODE PREPARE\r\n"), @@ -191,7 +200,7 @@ void forwardsCtrlCAsTheCtrlCSignalAndTheSessionSurvivesIt() throws Exception { final AtomicBoolean interruptRequested = new AtomicBoolean(true); final int exitCode; try (WinRMClient client = client()) { - try (RemoteProcess process = client.command("cmd.exe").start()) { + try (RemoteProcess process = shellProcess(client)) { exitCode = InteractiveShell.bridge( process, ScriptedPieces.silent(), @@ -238,7 +247,7 @@ void echoesOutputArrivingBeforeAnyInput() throws Exception { final ByteArrayOutputStream stdout = new ByteArrayOutputStream(); final int exitCode; try (WinRMClient client = client()) { - try (RemoteProcess process = client.command("cmd.exe").start()) { + try (RemoteProcess process = shellProcess(client)) { final PrintStream out = new PrintStream(stdout, true, "UTF-8"); exitCode = InteractiveShell.bridge( process, @@ -307,7 +316,7 @@ public boolean endOfInput() { }; final int exitCode; try (WinRMClient client = client()) { - try (RemoteProcess process = client.command("cmd.exe").start()) { + try (RemoteProcess process = shellProcess(client)) { exitCode = InteractiveShell.bridge( process, latePiece, @@ -347,7 +356,7 @@ void aFireHoseOfInputIsForwardedInBoundedBatches() throws Exception { final int exitCode; try (WinRMClient client = client()) { - try (RemoteProcess process = client.command("cmd.exe").start()) { + try (RemoteProcess process = shellProcess(client)) { exitCode = InteractiveShell.bridge( process, ScriptedPieces.endingAfter(hugeLine + "\r\n", "exit\r\n"), From 6566878cdfa07d89e4e57356d604a6069b9d7f61 Mon Sep 17 00:00:00 2001 From: Bertrand Martin Date: Wed, 29 Jul 2026 13:48:28 +0200 Subject: [PATCH 13/17] Fix mangled non-ASCII input in the interactive shell: console-mode stdin, ANSI-encoded MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Typing "echo testé" in the shell produced "test????". Diagnosed against a real Windows host by probing every combination of stdin mode and input encoding: - cmd.exe reading its COMMAND LINES from a PIPED stdin under console code page 65001 decodes them one byte at a time, so every non-ASCII byte becomes U+FFFD. No client-side encoding fixes it: UTF-8, CP1252, CP850 and CP437 all come back corrupted. This is what the echo-suppression change (pipe mode) exposed. - With CONSOLE-mode stdin the WinRM service converts the bytes itself, using the remote machine's ANSI code page: the same line encoded in windows-1252 round-trips intact on a French host, and output stays UTF-8 (full Unicode). - DATA piped to an ordinary program (sort, findstr) is unaffected in either mode - it never goes through cmd's parser - so the stdin(...) builders keep pipe semantics and UTF-8, verified intact. The shell therefore goes back to console-mode stdin and encodes what the user types with the remote ANSI code page, queried once per session from Win32_OperatingSystem.CodeSet (fallback windows-1252). Echo stays off: cmd.exe /Q alone suppresses it, and console-mode stdin never echoes. Console mode has no end of input, so the pump now translates the local EOF into an "exit" command; the remote exit code still propagates. New CommandRequest.stdinCharset(Charset) carries the asymmetry: output follows the console code page (UTF-8), console-mode input follows the ANSI one. Verified live on Windows Server 2008 R2: "echo testé français" echoes back intact, no input echo, EOF-only session exits 0, "exit 9" propagates 9. Co-Authored-By: Claude Fable 5 --- .../winrm/CommandCursor.java_634230367473300 | 199 ++++++++++++++++++ .../org/metricshub/winrm/CommandRequest.java | 42 +++- .../org/metricshub/winrm/RemoteProcess.java | 7 +- .../winrm/cli/InteractiveShell.java | 34 ++- .../org/metricshub/winrm/cli/WinRmCli.java | 78 ++++++- src/site/markdown/cli.md | 19 +- src/site/markdown/commands.md | 25 +++ .../winrm/cli/InteractiveShellTest.java | 21 +- .../metricshub/winrm/cli/WinRmCliTest.java | 21 +- 9 files changed, 399 insertions(+), 47 deletions(-) create mode 100644 src/main/java/org/metricshub/winrm/CommandCursor.java_634230367473300 diff --git a/src/main/java/org/metricshub/winrm/CommandCursor.java_634230367473300 b/src/main/java/org/metricshub/winrm/CommandCursor.java_634230367473300 new file mode 100644 index 0000000..b43063a --- /dev/null +++ b/src/main/java/org/metricshub/winrm/CommandCursor.java_634230367473300 @@ -0,0 +1,199 @@ +package org.metricshub.winrm; + +/*- + * ╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲ + * WinRM Java Client + * ჻჻჻჻჻჻ + * Copyright (C) 2023 - 2026 MetricsHub + * ჻჻჻჻჻჻ + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * ╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱ + */ + +import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; +import java.util.concurrent.TimeoutException; +import org.metricshub.winrm.exceptions.WindowsRemoteException; + +/** + * A cursor over the raw output of a running remote command, returned by + * {@link WindowsRemoteExecutor#startCommand(String, String, long)}. Each {@link #next()} is one + * WSMan Receive round trip yielding the output bytes exactly as the server handed them out — + * undecoded, because a multibyte character can be split across chunks; decode with a stateful + * {@link java.nio.charset.CharsetDecoder} (or accumulate the bytes and decode once at the end). + *

+ * The cursor owns the executor's serial connection until the command completes or the cursor is + * closed: no other operation can run on the same executor while the cursor is open. Completion + * (a {@code null} return from {@link #next()}) sends the protocol's terminate Signal and releases + * the connection on its own; closing earlier sends the same Signal, which actually stops the + * still-running remote command. Always close the cursor — use try-with-resources. + *

+ * A cursor is not thread-safe: advance and close it from one thread at a time. + */ +public interface CommandCursor extends AutoCloseable { + /** + * Block until the remote command produces output (or completes), for at most one + * per-round-trip timeout. + * + * @return the next chunk of raw output — possibly empty — or {@code null} once the command has + * completed; the exit code is then available from {@link #exitCode()} + * @throws TimeoutException when the command produces no output for a whole per-round-trip + * timeout (the inactivity timeout of the stream) + * @throws WindowsRemoteException for any other failure while receiving + */ + Chunk next() throws TimeoutException, WindowsRemoteException; + + /** + * Bounded variant of {@link #next()}: block at most the given wait for output. When the + * command produces nothing in that window, an empty chunk is returned — a bounded poll + * expiring is not a failure, and the cursor remains fully usable — unlike {@link #next()}, + * whose whole per-round-trip timeout counts as the stream's inactivity limit. Deadline-bounded + * waits (e.g. {@code RemoteProcess.waitFor(Duration)}) are built on this. + *

+ * The default implementation does not bound the wait: it delegates to {@link #next()}. + * + * @param maxWaitMillis how long to block at most, capped by the cursor's per-round-trip timeout + * @return the next chunk of raw output — empty when the wait elapsed first — or {@code null} + * once the command has completed + * @throws TimeoutException when the server does not even answer the bounded request + * @throws WindowsRemoteException for any other failure while receiving + */ + default Chunk poll(final long maxWaitMillis) throws TimeoutException, WindowsRemoteException { + return next(); + } + + /** + * Cadence variant of {@link #poll(long)}: ask the server to answer within + * {@code askMillis} — the polling cadence — while allowing the answer itself up to + * {@code maxWaitMillis} to arrive. A polling consumer (e.g. an interactive session pump) + * wants short idle rounds, but must not fail the stream when a loaded or distant server + * takes longer than one cadence to get its answer across; {@link #poll(long)} is exactly + * this call with {@code askMillis == maxWaitMillis}. + *

+ * The default implementation delegates to {@link #poll(long)} with the full wait. + * + * @param askMillis when the server should answer at the latest — with output when it has + * any, with the protocol's "nothing yet" otherwise + * @param maxWaitMillis how long to block at most, capped by the cursor's per-round-trip + * timeout + * @return the next chunk of raw output — empty when nothing arrived — or {@code null} once + * the command has completed + * @throws TimeoutException when the server does not even answer the bounded request + * @throws WindowsRemoteException for any other failure while receiving + */ + default Chunk poll(final long askMillis, final long maxWaitMillis) throws TimeoutException, WindowsRemoteException { + return poll(maxWaitMillis); + } + + /** + * Feed standard input to the running command — the WSMan Send operation, carrying the bytes to + * the command's {@code stdin} stream. Input larger than one envelope's worth is split into + * several Send requests automatically. A Send is an ordinary request on the executor's serial + * connection: it alternates with {@link #next()}/{@link #poll(long)} on the caller's thread, + * it never runs concurrently with them. + *

+ * The default implementation throws {@link UnsupportedOperationException}: only executors that + * support command input (such as the built-in lightweight backend) implement this method. + * + * @param data the input bytes (possibly empty — with {@code end}, a pure end-of-input Send) + * @param end {@code true} to mark the end of input: the command's stdin then reaches EOF, and + * no further input may be sent + * @throws IllegalStateException when the command has already completed or the cursor is closed + * @throws TimeoutException when the server does not answer the Send in time + * @throws WindowsRemoteException for any other failure while sending + */ + default void send(final byte[] data, final boolean end) throws TimeoutException, WindowsRemoteException { + throw new UnsupportedOperationException(getClass().getName() + " does not support command input."); + } + + /** + * Interrupt the command the way a console Ctrl+C would — the WSMan Signal operation with the + * {@code ctrl_c} code. Unlike {@link #close()}'s terminate Signal, it interrupts the command's + * child process without ending the command itself: the cursor stays fully usable. A no-op once + * the command has completed or the cursor is closed. + *

+ * The default implementation throws {@link UnsupportedOperationException}: only executors that + * support it (such as the built-in lightweight backend) implement this method. + * + * @throws TimeoutException when the server does not answer the Signal in time + * @throws WindowsRemoteException for any other failure while signaling + */ + default void interrupt() throws TimeoutException, WindowsRemoteException { + throw new UnsupportedOperationException(getClass().getName() + " does not support command interruption."); + } + + /** + * Get the command's exit code. + * + * @return the exit code + * @throws IllegalStateException when the command has not completed yet — completion is + * observed as a {@code null} return from {@link #next()} + */ + int exitCode(); + + /** + * Terminate the command (when it is still running) with the WinRM terminate Signal and release + * the executor's connection. Idempotent; a no-op when the command already completed. After an + * early close, {@link #next()} returns {@code null} without touching the connection again (and + * no exit code is available, since the command never completed). May throw an unchecked + * {@link org.metricshub.winrm.exceptions.WinRMClientException} when the Signal itself fails — + * the remote command may then still be running. + */ + @Override + void close(); + + /** One Receive response's worth of raw output bytes, split by stream. */ + final class Chunk { + + private final byte[] stdout; + private final byte[] stderr; + + /** + * Create a chunk over the given stream bytes (not copied: a chunk is a transient carrier + * between the protocol loop and the decoder, not a retained value). + * + * @param stdout the raw stdout bytes of this chunk (possibly empty, never null) + * @param stderr the raw stderr bytes of this chunk (possibly empty, never null) + */ + @SuppressFBWarnings(value = "EI_EXPOSE_REP2", justification = "Chunks are transient carriers on the output hot path; defensive copies " + + + "would double the allocation for no benefit") + public Chunk(final byte[] stdout, final byte[] stderr) { + this.stdout = stdout; + this.stderr = stderr; + } + + /** + * Get the raw stdout bytes of this chunk. + * + * @return the stdout bytes, possibly empty + */ + @SuppressFBWarnings(value = "EI_EXPOSE_REP", justification = "Chunks are transient carriers on the output hot path; defensive copies " + + + "would double the allocation for no benefit") + public byte[] stdout() { + return stdout; + } + + /** + * Get the raw stderr bytes of this chunk. + * + * @return the stderr bytes, possibly empty + */ + @SuppressFBWarnings(value = "EI_EXPOSE_REP", justification = "Chunks are transient carriers on the output hot path; defensive copies " + + + "would double the allocation for no benefit") + public byte[] stderr() { + return stderr; + } + } +} diff --git a/src/main/java/org/metricshub/winrm/CommandRequest.java b/src/main/java/org/metricshub/winrm/CommandRequest.java index 3f222c3..cf09591 100644 --- a/src/main/java/org/metricshub/winrm/CommandRequest.java +++ b/src/main/java/org/metricshub/winrm/CommandRequest.java @@ -53,6 +53,7 @@ public final class CommandRequest { private String workingDirectory; private Duration timeout; private Charset charset; + private Charset stdinCharset; private final List uploads = new ArrayList<>(); private Consumer stdoutConsumer; private Consumer stderrConsumer; @@ -122,6 +123,27 @@ public CommandRequest charset(final Charset charset) { return this; } + /** + * Set the charset used to encode the command's standard input, when it differs from the + * output charset. Default: the output charset (see {@link #charset(Charset)}). + *

+ * The two directions are not symmetric on Windows. Output follows the shell's console code + * page, which this client pins to UTF-8. Input written to a command created with + * console-mode stdin is converted by the WinRM service with the remote machine's + * ANSI code page instead — so an interactive session (the CLI's {@code shell} + * subcommand) must encode what it sends with that code page, whatever the console code page + * is. Input handed to a command created with pipe semantics (any {@code stdin(...)} on this + * request) reaches the process unconverted and needs no override. + * + * @param charset the input charset + * @return this request + */ + public CommandRequest stdinCharset(final Charset charset) { + Utils.checkNonNull(charset, "charset"); + this.stdinCharset = charset; + return this; + } + /** * Copy local files to the remote host (through the WinRM connection itself) before running * the command. Each reference to a local file path inside the command line is rewritten to @@ -377,7 +399,7 @@ public RemoteProcess start() { .startCommand(prepared.command, prepared.workingDirectory, timeoutMillis, !pipeStdin); if (stdinSource != null) { try { - feedStdin(cursor, prepared.charset); + feedStdin(cursor, prepared.stdinCharset); } catch (final Exception e) { // The input could not be delivered: terminate the command and release the client's // connection before reporting — a failed start must not leave an open handle @@ -390,7 +412,14 @@ public RemoteProcess start() { throw e; } } - return new RemoteProcess(cursor, prepared.charset, client.hostname(), timeout, stdinSource != null); + return new RemoteProcess( + cursor, + prepared.charset, + prepared.stdinCharset, + client.hostname(), + timeout, + stdinSource != null + ); } catch (final TimeoutException e) { throw timeoutException(e); } catch (final IOException e) { @@ -406,11 +435,13 @@ private static final class Prepared { final String command; final String workingDirectory; final Charset charset; + final Charset stdinCharset; - Prepared(final String command, final String workingDirectory, final Charset charset) { + Prepared(final String command, final String workingDirectory, final Charset charset, final Charset stdinCharset) { this.command = command; this.workingDirectory = workingDirectory; this.charset = charset; + this.stdinCharset = stdinCharset; } } @@ -440,7 +471,8 @@ private Prepared prepare(final long timeoutMillis, final long start) } final Charset actualCharset = charset != null ? charset : WindowsRemoteExecutor.SHELL_OUTPUT_CHARSET; - return new Prepared(actualCommand, actualWorkingDirectory, actualCharset); + final Charset actualStdinCharset = stdinCharset != null ? stdinCharset : actualCharset; + return new Prepared(actualCommand, actualWorkingDirectory, actualCharset, actualStdinCharset); } /** @@ -459,7 +491,7 @@ private CommandResult drainWithCallbacks(final Prepared prepared, final long tim CommandCursor cursor = client.executor() .startCommand(prepared.command, prepared.workingDirectory, timeoutMillis, !pipeStdin)) { if (stdinSource != null) { - feedStdin(cursor, prepared.charset); + feedStdin(cursor, prepared.stdinCharset); } CommandCursor.Chunk chunk; while ((chunk = cursor.next()) != null) { diff --git a/src/main/java/org/metricshub/winrm/RemoteProcess.java b/src/main/java/org/metricshub/winrm/RemoteProcess.java index 03c1e2d..9933666 100644 --- a/src/main/java/org/metricshub/winrm/RemoteProcess.java +++ b/src/main/java/org/metricshub/winrm/RemoteProcess.java @@ -85,7 +85,7 @@ public final class RemoteProcess implements AutoCloseable { private final CommandCursor cursor; private final String hostname; private final Duration timeout; - private final Charset charset; + private final Charset stdinCharset; private final ChunkDecoder stdoutDecoder; private final ChunkDecoder stderrDecoder; @@ -117,6 +117,7 @@ public final class RemoteProcess implements AutoCloseable { RemoteProcess( final CommandCursor cursor, final Charset charset, + final Charset stdinCharset, final String hostname, final Duration timeout, final boolean stdinAlreadySupplied @@ -124,7 +125,7 @@ public final class RemoteProcess implements AutoCloseable { this.cursor = cursor; this.hostname = hostname; this.timeout = timeout; - this.charset = charset; + this.stdinCharset = stdinCharset; this.stdoutDecoder = new ChunkDecoder(charset); this.stderrDecoder = new ChunkDecoder(charset); this.stdout = new BufferedReader(new ChannelReader(stdoutPending)); @@ -432,7 +433,7 @@ private synchronized void sendStdin(final boolean end) { bytes = new byte[0]; } else { if (stdinEncoder == null) { - stdinEncoder = new ChunkEncoder(charset); + stdinEncoder = new ChunkEncoder(stdinCharset); } bytes = stdinEncoder.encode(stdinPending.toString(), end); stdinPending.setLength(0); diff --git a/src/main/java/org/metricshub/winrm/cli/InteractiveShell.java b/src/main/java/org/metricshub/winrm/cli/InteractiveShell.java index 0f7bd2d..2a480ce 100644 --- a/src/main/java/org/metricshub/winrm/cli/InteractiveShell.java +++ b/src/main/java/org/metricshub/winrm/cli/InteractiveShell.java @@ -92,6 +92,9 @@ final class InteractiveShell { */ private static final int INPUT_QUEUE_CAPACITY = 256; + /** What terminates a line for the remote {@code cmd.exe}. */ + private static final String LINE_SEPARATOR = "\r\n"; + private InteractiveShell() {} /** @@ -165,6 +168,8 @@ private static Charset charsetOf(final String name) { * @param interruptRequested set externally (e.g. by a Ctrl+C handler) to forward a * {@code ctrl_c} Signal on the next round; cleared once forwarded * @param pollMillis the poll cadence in milliseconds + * @param endOfInputCommand what to send when the local input ends, for a remote command whose + * stdin has no end of input (console mode); {@code null} closes the remote stdin instead * @return the remote command's exit code * @throws IOException when forwarding the local input fails */ @@ -174,13 +179,14 @@ static int run( final PrintStream out, final PrintStream err, final AtomicBoolean interruptRequested, - final long pollMillis + final long pollMillis, + final String endOfInputCommand ) throws IOException { final QueuedInputSource pieces = new QueuedInputSource(); final Thread reader = new Thread(() -> pieces.readAll(localInput), "winrm-shell-stdin"); reader.setDaemon(true); reader.start(); - return bridge(process, pieces, out, err, interruptRequested, pollMillis); + return bridge(process, pieces, out, err, interruptRequested, pollMillis, endOfInputCommand); } /** @@ -194,6 +200,8 @@ static int run( * @param err where the remote standard error goes * @param interruptRequested checked (and cleared) every round; forwards a {@code ctrl_c} Signal * @param pollMillis the poll cadence in milliseconds + * @param endOfInputCommand what to send when the local input ends, for a remote command whose + * stdin has no end of input (console mode); {@code null} closes the remote stdin instead * @return the remote command's exit code * @throws IOException when forwarding the local input fails */ @@ -203,7 +211,8 @@ static int bridge( final PrintStream out, final PrintStream err, final AtomicBoolean interruptRequested, - final long pollMillis + final long pollMillis, + final String endOfInputCommand ) throws IOException { // The tail of a piece the current round could not fit entirely: forwarded first on the // next rounds, before any new piece is pulled. @@ -216,7 +225,7 @@ static int bridge( } if (!eofForwarded) { try { - eofForwarded = forwardLocalInput(pieces, process.stdin(), pendingInput); + eofForwarded = forwardLocalInput(pieces, process.stdin(), pendingInput, endOfInputCommand); } catch (final IllegalStateException e) { // The command completed while this input was being queued (the final Receive // beat it): nothing can consume it anymore. Stop forwarding — the next poll @@ -247,7 +256,8 @@ static int bridge( private static boolean forwardLocalInput( final InputSource pieces, final BufferedWriter stdin, - final StringBuilder pendingInput + final StringBuilder pendingInput, + final String endOfInputCommand ) throws IOException { int budget = MAX_INPUT_CHARS_PER_ROUND; boolean wrote = false; @@ -267,9 +277,17 @@ private static boolean forwardLocalInput( pendingInput.append(piece); } if (pendingInput.length() == 0 && pieces.endOfInput()) { - // Closing flushes the written characters too: they travel with the End mark, one - // single Send. - stdin.close(); + if (endOfInputCommand == null) { + // Closing flushes the written characters too: they travel with the End mark, one + // single Send. + stdin.close(); + } else { + // Console-mode stdin has no end of input: the remote command would wait forever + // for the next line, so the local EOF is translated into the command that ends it. + stdin.write(endOfInputCommand); + stdin.write(LINE_SEPARATOR); + stdin.flush(); + } return true; } if (wrote) { diff --git a/src/main/java/org/metricshub/winrm/cli/WinRmCli.java b/src/main/java/org/metricshub/winrm/cli/WinRmCli.java index d6ac445..f92371d 100644 --- a/src/main/java/org/metricshub/winrm/cli/WinRmCli.java +++ b/src/main/java/org/metricshub/winrm/cli/WinRmCli.java @@ -33,6 +33,7 @@ import java.net.NoRouteToHostException; import java.net.SocketException; import java.net.UnknownHostException; +import java.nio.charset.Charset; import java.time.Duration; import java.util.List; import java.util.Locale; @@ -553,10 +554,21 @@ static final class FluentRemoteOperations implements RemoteOperations { /** * What the {@code shell} subcommand runs remotely: {@code cmd.exe} with its command echo - * off ({@code /Q}) — piped-in command lines are not repeated in the output. + * off ({@code /Q}), so a forwarded command line is never repeated in the output. */ static final String SHELL_COMMAND = "cmd.exe /Q"; + /** What the shell sends when the local input ends: console-mode stdin has no EOF. */ + static final String SHELL_EXIT_COMMAND = "exit"; + + /** + * The code page assumed for the interactive shell's input when the remote machine cannot + * be asked: Windows-1252, the most widespread Windows ANSI code page. ASCII — the whole + * command vocabulary — is identical in every ANSI code page, so only non-ASCII characters + * of an unqueryable host are at risk. + */ + static final Charset DEFAULT_SHELL_INPUT_CHARSET = Charset.forName("windows-1252"); + private final WinRMClient client; FluentRemoteOperations(final WinRMClient client) { @@ -597,21 +609,65 @@ public int shell( final PrintStream err, final AtomicBoolean interruptRequested ) throws Exception { - // The remote side of the bridge: cmd.exe with its command echo off (/Q) over pipe-mode - // stdin (the winrs -noecho equivalent), so the session never echoes the forwarded input - // back — the local terminal already shows what the user types, and the output stream - // carries prompts and command output only. Pipe-mode stdin also makes the local - // end-of-input a real EOF: cmd.exe exits on it. The timeout bounds each protocol round - // trip; an idle session never trips it, because every poll completes with output or the - // protocol's "nothing yet" answer. + // The remote side of the bridge: cmd.exe with its command echo off (/Q) over the + // default CONSOLE-mode stdin, which never echoes what it receives either — so the + // output stream carries prompts and command output only, and the local terminal alone + // shows what the user types. + // + // Console mode is also the only mode that carries non-ASCII command lines: cmd.exe + // parses a PIPED stdin one byte at a time under console code page 65001 (the page this + // client pins for correct UTF-8 output), turning every non-ASCII byte into U+FFFD. In + // console mode the WinRM service converts the bytes itself, with the remote machine's + // ANSI code page — hence the stdinCharset below. It has no end of input, so the local + // EOF is translated into an "exit" command by the pump. + // + // The timeout bounds each protocol round trip; an idle session never trips it, because + // every poll completes with output or the protocol's "nothing yet" answer. try ( RemoteProcess process = client.command(SHELL_COMMAND) .timeout(Duration.ofMillis(timeout)) - .stdin() + .stdinCharset(remoteAnsiCharset(timeout)) .start()) { - return InteractiveShell - .run(process, localInput, out, err, interruptRequested, InteractiveShell.DEFAULT_POLL_MILLIS); + return InteractiveShell.run( + process, + localInput, + out, + err, + interruptRequested, + InteractiveShell.DEFAULT_POLL_MILLIS, + SHELL_EXIT_COMMAND + ); + } + } + + /** + * The remote machine's ANSI code page, which the WinRM service uses to convert console-mode + * standard input. {@code Win32_OperatingSystem.CodeSet} reports exactly that value (this is + * the ANSI page — the reason it is the wrong answer for decoding OUTPUT, which follows the + * console page instead). A host that cannot answer falls back to + * {@link #DEFAULT_SHELL_INPUT_CHARSET}: an unusable code page must degrade non-ASCII input, + * never prevent the session from opening. + */ + private Charset remoteAnsiCharset(final long timeout) { + try { + final List rows; + try ( + Stream stream = client + .wql("SELECT CodeSet FROM Win32_OperatingSystem") + .timeout(Duration.ofMillis(timeout)) + .stream()) { + rows = stream.collect(java.util.stream.Collectors.toList()); + } + if (!rows.isEmpty()) { + final String codeSet = rows.get(0).string("CodeSet"); + if (codeSet != null && !codeSet.trim().isEmpty()) { + return Charset.forName("windows-" + codeSet.trim()); + } + } + } catch (final RuntimeException ignored) { + // No WMI access, an exotic code page, an unsupported charset: fall back below. } + return DEFAULT_SHELL_INPUT_CHARSET; } @Override diff --git a/src/site/markdown/cli.md b/src/site/markdown/cli.md index 359ba1c..cb37732 100644 --- a/src/site/markdown/cli.md +++ b/src/site/markdown/cli.md @@ -120,14 +120,21 @@ java -jar winrm-java-standalone.jar -h server.example.net -u 'DOMAIN\user' -pf p ``` `shell` starts `cmd.exe` on the remote host and bridges it to the local terminal until the remote -shell exits (type `exit`, or send end-of-input: Ctrl+Z then Enter on Windows, Ctrl+D elsewhere — -the remote `cmd.exe` exits on the EOF). The remote exit code is propagated through the usual +shell exits (type `exit`, or send end-of-input — Ctrl+Z then Enter on Windows, Ctrl+D elsewhere — +which the session turns into an `exit`). The remote exit code is propagated through the usual [exit-code contract](#Exit_codes). -* **Echo is off** — the remote shell runs `cmd.exe /Q` over pipe-mode standard input (the - `winrs -noecho` equivalent), so the input you forward is never repeated back by the remote - side: your terminal already shows what you type, and the output stream carries the prompts and - the command output only. +* **Echo is off** — the remote shell runs `cmd.exe /Q`, so the input you forward is never + repeated back: your terminal already shows what you type, and the output stream carries the + prompts and the command output only. +* **Non-ASCII input is limited to the remote machine's ANSI code page.** Command output is + UTF-8 and carries any character (the shell's console code page is 65001), but the *input* + direction is asymmetric on Windows: what you type is converted by the WinRM service with the + remote machine's **ANSI** code page (queried once per session from + `Win32_OperatingSystem.CodeSet`, falling back to Windows-1252 when the host cannot answer). So + `é` reaches a Western-European host fine, while a character outside its ANSI code page does + not. This is the same limitation `winrs` has, and it applies to typed command lines only — + `command` with piped input (see above) transfers bytes unconverted and is unaffected. * **Line-oriented, like `winrs`** — input is line-buffered by the local terminal and forwarded when you press Enter. There is no raw-terminal/PTY mode (with zero dependencies there is none in pure Java): full-screen programs, cmd.exe line editing, tab completion, and ANSI cursor control diff --git a/src/site/markdown/commands.md b/src/site/markdown/commands.md index 8f94fa5..7407705 100644 --- a/src/site/markdown/commands.md +++ b/src/site/markdown/commands.md @@ -49,6 +49,7 @@ Everything between `command(...)` and `execute()` is optional: | `upload(Path...)` | none | Local files to copy to the host before running (see below). | | `stdin(String)` / `stdin(Path)` / `stdin(InputStream)` | none | Standard input fed to the command — the remote equivalent of a `< file` redirection (see below). | | `stdin()` | console semantics | Declare interactive input through `RemoteProcess.stdin()` (with `start()`): pipe semantics without pre-supplied content (see below). | +| `stdinCharset(Charset)` | the output charset | The charset used to *encode* standard input, when it differs from the output charset (see below). | | `onStdout(Consumer)` / `onStderr(Consumer)` | none | Callbacks receiving each chunk of output live while `execute()` runs (see below). | ## The result @@ -154,6 +155,30 @@ floods its output in the meantime) hangs both sides until the inactivity timeout a console Ctrl+C: it interrupts the command's child process without terminating the command or the process handle. +### Input encoding + +Input encoding is not symmetric with output encoding, because Windows treats the two directions +differently: + +* **Pipe semantics** (any `stdin(...)`, including the no-argument form) — the bytes reach the + process **unconverted**. They are encoded with the request's charset (UTF-8 by default), which + is what a program reading a UTF-8 stream expects, and `stdin(Path)`/`stdin(InputStream)` send + the bytes verbatim. +* **Console semantics** (no `stdin` declaration — a command started with `start()` and written to + through `RemoteProcess.stdin()`) — the WinRM service converts the bytes with the remote + machine's **ANSI** code page before handing them to the console. Encode accordingly with + `stdinCharset(...)`; the CLI's `shell` subcommand queries `Win32_OperatingSystem.CodeSet` once + per session for exactly this. + +Output is unaffected either way: it follows the shell's console code page, which this client +pins to UTF-8. + +> A remote `cmd.exe` reading its **command lines** from a *piped* stdin cannot handle non-ASCII +> at all under console code page 65001 — Windows decodes that input one byte at a time, turning +> every non-ASCII byte into `U+FFFD`. That is why an interactive session must use console +> semantics. Data piped to an ordinary program (`sort`, `findstr`, your own executable) is not +> affected: it never goes through cmd's parser. + ### Tailing the output of a blocking execution When you only want to *observe* the output live — logging, progress reporting — but still want the diff --git a/src/test/java/org/metricshub/winrm/cli/InteractiveShellTest.java b/src/test/java/org/metricshub/winrm/cli/InteractiveShellTest.java index 2d5341d..73e763f 100644 --- a/src/test/java/org/metricshub/winrm/cli/InteractiveShellTest.java +++ b/src/test/java/org/metricshub/winrm/cli/InteractiveShellTest.java @@ -95,11 +95,11 @@ private void enqueueStartup() { /** * The exact remote process the production shell subcommand starts: {@code cmd.exe /Q} over - * pipe-mode stdin — the pump tests must exercise the production configuration (the ctrl_c - * Signal under pipe-mode stdin is live-verified against a real Windows host). + * console-mode stdin — the only mode that carries non-ASCII command lines (cmd.exe mangles a + * piped stdin under console code page 65001), live-verified against a real Windows host. */ private static RemoteProcess shellProcess(final WinRMClient client) { - return client.command(WinRmCli.FluentRemoteOperations.SHELL_COMMAND).stdin().start(); + return client.command(WinRmCli.FluentRemoteOperations.SHELL_COMMAND).start(); } /** An input source whose {@code nextPiece()} answers are fully scripted, including "not yet". */ @@ -165,7 +165,8 @@ void bridgesInputLinesAndOutputAndPropagatesTheExitCode() throws Exception { new PrintStream(stdout, true, "UTF-8"), new PrintStream(stderr, true, "UTF-8"), new AtomicBoolean(), - POLL_MILLIS + POLL_MILLIS, + null ); } } @@ -207,7 +208,8 @@ void forwardsCtrlCAsTheCtrlCSignalAndTheSessionSurvivesIt() throws Exception { new PrintStream(stdout, true, "UTF-8"), new PrintStream(new ByteArrayOutputStream(), true, "UTF-8"), interruptRequested, - POLL_MILLIS + POLL_MILLIS, + null ); } } @@ -269,7 +271,8 @@ public boolean endOfInput() { out, new PrintStream(new ByteArrayOutputStream(), true, "UTF-8"), new AtomicBoolean(), - POLL_MILLIS + POLL_MILLIS, + null ); } } @@ -323,7 +326,8 @@ public boolean endOfInput() { new PrintStream(new ByteArrayOutputStream(), true, "UTF-8"), new PrintStream(new ByteArrayOutputStream(), true, "UTF-8"), new AtomicBoolean(), - POLL_MILLIS + POLL_MILLIS, + null ); } } @@ -363,7 +367,8 @@ void aFireHoseOfInputIsForwardedInBoundedBatches() throws Exception { new PrintStream(new ByteArrayOutputStream(), true, "UTF-8"), new PrintStream(new ByteArrayOutputStream(), true, "UTF-8"), new AtomicBoolean(), - POLL_MILLIS + POLL_MILLIS, + null ); } } diff --git a/src/test/java/org/metricshub/winrm/cli/WinRmCliTest.java b/src/test/java/org/metricshub/winrm/cli/WinRmCliTest.java index e96ed7c..6c14109 100644 --- a/src/test/java/org/metricshub/winrm/cli/WinRmCliTest.java +++ b/src/test/java/org/metricshub/winrm/cli/WinRmCliTest.java @@ -124,11 +124,19 @@ void shellSubcommandBridgesTheRemoteShellAndPropagatesItsExitCode() throws Excep } @Test - void shellRunsAQuietCmdOverPipeModeStdin() throws Exception { - // Full stack against the in-process WSMan server, through the CLI's real connect factory: - // the shell must be echo-free — cmd.exe started with /Q (no command echo) over pipe-mode - // stdin (the winrs -noecho equivalent: input is never echoed by the remote console). + void shellRunsAQuietCmdOverConsoleModeStdin() throws Exception { + // Full stack against the in-process WSMan server, through the CLI's real connect factory. + // The shell must be echo-free — cmd.exe started with /Q, and console-mode stdin never + // echoes what it receives either — AND use console mode, the only stdin mode that carries + // non-ASCII command lines (cmd.exe mangles a piped stdin under console code page 65001). + // The ANSI code page probe precedes the shell: its answer encodes what the user types. try (FakeWsmanServer server = new FakeWsmanServer("FAKE", "user", "secret")) { + server.enqueue( + 200, + FakeWsmanResponses.envelope( + FakeWsmanResponses.enumerationDone(FakeWsmanResponses.instance("Win32_OperatingSystem", "CodeSet", "1252")) + ) + ); enqueueShellCreation(server); server .enqueue(200, FakeWsmanResponses.envelope(FakeWsmanResponses.commandResponse("CMD-1"))) @@ -174,9 +182,10 @@ public int read() { assertEquals(0, invocation.exitCode); final List requests = server.decryptedRequests(); - final String command = requests.get(1); + assertTrue(requests.get(0).contains("Win32_OperatingSystem"), requests.get(0)); + final String command = requests.get(2); assertTrue(command.contains("cmd.exe /Q"), command); - assertTrue(command.contains("FALSE"), command); + assertTrue(command.contains("TRUE"), command); } } From 674b9a96af06cf33ed490d79a882815c921230c7 Mon Sep 17 00:00:00 2001 From: Bertrand Martin Date: Wed, 29 Jul 2026 13:48:47 +0200 Subject: [PATCH 14/17] Remove a formatter temp file committed by accident Co-Authored-By: Claude Fable 5 --- .../winrm/CommandCursor.java_634230367473300 | 199 ------------------ 1 file changed, 199 deletions(-) delete mode 100644 src/main/java/org/metricshub/winrm/CommandCursor.java_634230367473300 diff --git a/src/main/java/org/metricshub/winrm/CommandCursor.java_634230367473300 b/src/main/java/org/metricshub/winrm/CommandCursor.java_634230367473300 deleted file mode 100644 index b43063a..0000000 --- a/src/main/java/org/metricshub/winrm/CommandCursor.java_634230367473300 +++ /dev/null @@ -1,199 +0,0 @@ -package org.metricshub.winrm; - -/*- - * ╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲ - * WinRM Java Client - * ჻჻჻჻჻჻ - * Copyright (C) 2023 - 2026 MetricsHub - * ჻჻჻჻჻჻ - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - * ╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱ - */ - -import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; -import java.util.concurrent.TimeoutException; -import org.metricshub.winrm.exceptions.WindowsRemoteException; - -/** - * A cursor over the raw output of a running remote command, returned by - * {@link WindowsRemoteExecutor#startCommand(String, String, long)}. Each {@link #next()} is one - * WSMan Receive round trip yielding the output bytes exactly as the server handed them out — - * undecoded, because a multibyte character can be split across chunks; decode with a stateful - * {@link java.nio.charset.CharsetDecoder} (or accumulate the bytes and decode once at the end). - *

- * The cursor owns the executor's serial connection until the command completes or the cursor is - * closed: no other operation can run on the same executor while the cursor is open. Completion - * (a {@code null} return from {@link #next()}) sends the protocol's terminate Signal and releases - * the connection on its own; closing earlier sends the same Signal, which actually stops the - * still-running remote command. Always close the cursor — use try-with-resources. - *

- * A cursor is not thread-safe: advance and close it from one thread at a time. - */ -public interface CommandCursor extends AutoCloseable { - /** - * Block until the remote command produces output (or completes), for at most one - * per-round-trip timeout. - * - * @return the next chunk of raw output — possibly empty — or {@code null} once the command has - * completed; the exit code is then available from {@link #exitCode()} - * @throws TimeoutException when the command produces no output for a whole per-round-trip - * timeout (the inactivity timeout of the stream) - * @throws WindowsRemoteException for any other failure while receiving - */ - Chunk next() throws TimeoutException, WindowsRemoteException; - - /** - * Bounded variant of {@link #next()}: block at most the given wait for output. When the - * command produces nothing in that window, an empty chunk is returned — a bounded poll - * expiring is not a failure, and the cursor remains fully usable — unlike {@link #next()}, - * whose whole per-round-trip timeout counts as the stream's inactivity limit. Deadline-bounded - * waits (e.g. {@code RemoteProcess.waitFor(Duration)}) are built on this. - *

- * The default implementation does not bound the wait: it delegates to {@link #next()}. - * - * @param maxWaitMillis how long to block at most, capped by the cursor's per-round-trip timeout - * @return the next chunk of raw output — empty when the wait elapsed first — or {@code null} - * once the command has completed - * @throws TimeoutException when the server does not even answer the bounded request - * @throws WindowsRemoteException for any other failure while receiving - */ - default Chunk poll(final long maxWaitMillis) throws TimeoutException, WindowsRemoteException { - return next(); - } - - /** - * Cadence variant of {@link #poll(long)}: ask the server to answer within - * {@code askMillis} — the polling cadence — while allowing the answer itself up to - * {@code maxWaitMillis} to arrive. A polling consumer (e.g. an interactive session pump) - * wants short idle rounds, but must not fail the stream when a loaded or distant server - * takes longer than one cadence to get its answer across; {@link #poll(long)} is exactly - * this call with {@code askMillis == maxWaitMillis}. - *

- * The default implementation delegates to {@link #poll(long)} with the full wait. - * - * @param askMillis when the server should answer at the latest — with output when it has - * any, with the protocol's "nothing yet" otherwise - * @param maxWaitMillis how long to block at most, capped by the cursor's per-round-trip - * timeout - * @return the next chunk of raw output — empty when nothing arrived — or {@code null} once - * the command has completed - * @throws TimeoutException when the server does not even answer the bounded request - * @throws WindowsRemoteException for any other failure while receiving - */ - default Chunk poll(final long askMillis, final long maxWaitMillis) throws TimeoutException, WindowsRemoteException { - return poll(maxWaitMillis); - } - - /** - * Feed standard input to the running command — the WSMan Send operation, carrying the bytes to - * the command's {@code stdin} stream. Input larger than one envelope's worth is split into - * several Send requests automatically. A Send is an ordinary request on the executor's serial - * connection: it alternates with {@link #next()}/{@link #poll(long)} on the caller's thread, - * it never runs concurrently with them. - *

- * The default implementation throws {@link UnsupportedOperationException}: only executors that - * support command input (such as the built-in lightweight backend) implement this method. - * - * @param data the input bytes (possibly empty — with {@code end}, a pure end-of-input Send) - * @param end {@code true} to mark the end of input: the command's stdin then reaches EOF, and - * no further input may be sent - * @throws IllegalStateException when the command has already completed or the cursor is closed - * @throws TimeoutException when the server does not answer the Send in time - * @throws WindowsRemoteException for any other failure while sending - */ - default void send(final byte[] data, final boolean end) throws TimeoutException, WindowsRemoteException { - throw new UnsupportedOperationException(getClass().getName() + " does not support command input."); - } - - /** - * Interrupt the command the way a console Ctrl+C would — the WSMan Signal operation with the - * {@code ctrl_c} code. Unlike {@link #close()}'s terminate Signal, it interrupts the command's - * child process without ending the command itself: the cursor stays fully usable. A no-op once - * the command has completed or the cursor is closed. - *

- * The default implementation throws {@link UnsupportedOperationException}: only executors that - * support it (such as the built-in lightweight backend) implement this method. - * - * @throws TimeoutException when the server does not answer the Signal in time - * @throws WindowsRemoteException for any other failure while signaling - */ - default void interrupt() throws TimeoutException, WindowsRemoteException { - throw new UnsupportedOperationException(getClass().getName() + " does not support command interruption."); - } - - /** - * Get the command's exit code. - * - * @return the exit code - * @throws IllegalStateException when the command has not completed yet — completion is - * observed as a {@code null} return from {@link #next()} - */ - int exitCode(); - - /** - * Terminate the command (when it is still running) with the WinRM terminate Signal and release - * the executor's connection. Idempotent; a no-op when the command already completed. After an - * early close, {@link #next()} returns {@code null} without touching the connection again (and - * no exit code is available, since the command never completed). May throw an unchecked - * {@link org.metricshub.winrm.exceptions.WinRMClientException} when the Signal itself fails — - * the remote command may then still be running. - */ - @Override - void close(); - - /** One Receive response's worth of raw output bytes, split by stream. */ - final class Chunk { - - private final byte[] stdout; - private final byte[] stderr; - - /** - * Create a chunk over the given stream bytes (not copied: a chunk is a transient carrier - * between the protocol loop and the decoder, not a retained value). - * - * @param stdout the raw stdout bytes of this chunk (possibly empty, never null) - * @param stderr the raw stderr bytes of this chunk (possibly empty, never null) - */ - @SuppressFBWarnings(value = "EI_EXPOSE_REP2", justification = "Chunks are transient carriers on the output hot path; defensive copies " - + - "would double the allocation for no benefit") - public Chunk(final byte[] stdout, final byte[] stderr) { - this.stdout = stdout; - this.stderr = stderr; - } - - /** - * Get the raw stdout bytes of this chunk. - * - * @return the stdout bytes, possibly empty - */ - @SuppressFBWarnings(value = "EI_EXPOSE_REP", justification = "Chunks are transient carriers on the output hot path; defensive copies " - + - "would double the allocation for no benefit") - public byte[] stdout() { - return stdout; - } - - /** - * Get the raw stderr bytes of this chunk. - * - * @return the stderr bytes, possibly empty - */ - @SuppressFBWarnings(value = "EI_EXPOSE_REP", justification = "Chunks are transient carriers on the output hot path; defensive copies " - + - "would double the allocation for no benefit") - public byte[] stderr() { - return stderr; - } - } -} From 8087838597202cc1cda95eede463bc3bb18b4730 Mon Sep 17 00:00:00 2001 From: Bertrand Martin Date: Wed, 29 Jul 2026 14:15:58 +0200 Subject: [PATCH 15/17] Run the interactive shell under a single-byte code page: the real cause of the mangled input MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous fix was wrong about the mechanism and only helped on Windows Server 2008 R2. Probing both a 2008 R2 and a 2022 host, in both stdin modes, with four input encodings each, gives one consistent answer: the culprit is console code page 65001 itself. A remote cmd.exe decodes the command lines it reads from its standard input ONE BYTE AT A TIME under 65001, so every non-ASCII byte becomes U+FFFD whatever the client sends - console mode only appeared to work on 2008 R2 because that version's service converted the bytes with the ANSI page first. Under ANY single-byte console code page (1252, 850, 437) every combination round-trips intact on both hosts. The shell therefore runs under the remote machine's ANSI code page, queried once per session from Win32_OperatingSystem.CodeSet (fallback 1252), with the matching charset on both directions. That page must be set when the shell is created, so the console code page is now a client-level setting (WinRMClient.Builder.consoleCodePage, plumbed to WINRS_CODEPAGE) and the CLI opens the session on a second connection once the probe has answered. Pipe-mode stdin comes back with it: under a single-byte page it carries non-ASCII fine, cmd.exe /Q keeps the echo off, and the local end-of-input is a real EOF again (no synthetic "exit" needed). Everything else keeps code page 65001 and UTF-8: wql, command, and data piped to a program never go through cmd's command-line parser. Verified live on Windows Server 2022 (French) and 2008 R2: "echo testé français" round-trips intact, banner correct, no echo, EOF exits, exit codes propagate. Co-Authored-By: Claude Fable 5 --- .../org/metricshub/winrm/WinRMClient.java | 26 +++- .../winrm/cli/InteractiveShell.java | 40 ++++-- .../org/metricshub/winrm/cli/WinRmCli.java | 125 +++++++++++------- .../org/metricshub/winrm/light/Envelopes.java | 16 ++- .../winrm/light/LightWinRMService.java | 33 ++++- .../metricshub/winrm/light/WsmanClient.java | 7 +- .../winrm/service/WinRMExecutorFactory.java | 31 ++++- src/site/markdown/cli.md | 17 +-- src/site/markdown/commands.md | 20 +-- .../winrm/cli/InteractiveShellTest.java | 78 ++++++++++- .../metricshub/winrm/cli/WinRmCliTest.java | 14 +- 11 files changed, 317 insertions(+), 90 deletions(-) diff --git a/src/main/java/org/metricshub/winrm/WinRMClient.java b/src/main/java/org/metricshub/winrm/WinRMClient.java index e986b62..7c8a7b1 100644 --- a/src/main/java/org/metricshub/winrm/WinRMClient.java +++ b/src/main/java/org/metricshub/winrm/WinRMClient.java @@ -240,6 +240,7 @@ public static final class Builder { private List authentication; private Path ticketCache; private boolean trustAllCertificates; + private int consoleCodePage; private SSLContext sslContext; private Duration timeout = DEFAULT_TIMEOUT; @@ -391,6 +392,28 @@ public Builder sslContext(final SSLContext sslContext) { * @param timeout the timeout (at least one millisecond) * @return this builder */ + /** + * Set the console code page of the remote command shell. Default: 65001 (UTF-8), which makes + * command output UTF-8 whatever the remote locale — the right choice for reading output and + * for piping data to a program. + *

+ * An interactive session needs a different setting: under code page 65001 a remote + * {@code cmd.exe} decodes the command lines it reads from its standard input one byte at a + * time, so every non-ASCII character is lost. Pin a single-byte code page (typically the + * remote machine's ANSI one, {@code Win32_OperatingSystem.CodeSet}) and use the matching + * charset for both directions, as the CLI's {@code shell} subcommand does. + * + * @param consoleCodePage the console code page (e.g. 1252), or 0 for the default + * @return this builder + */ + public Builder consoleCodePage(final int consoleCodePage) { + if (consoleCodePage < 0) { + throw new IllegalArgumentException("consoleCodePage must not be negative."); + } + this.consoleCodePage = consoleCodePage; + return this; + } + public Builder timeout(final Duration timeout) { this.timeout = checkPositive(timeout, "timeout"); return this; @@ -431,7 +454,8 @@ public WinRMClient build() { ticketCache, authentications, sslContext, - trustAllCertificates + trustAllCertificates, + consoleCodePage ); return new WinRMClient(executor, endpoint.getHostname(), endpoint.getNamespace(), timeout); } catch (final WinRMException e) { diff --git a/src/main/java/org/metricshub/winrm/cli/InteractiveShell.java b/src/main/java/org/metricshub/winrm/cli/InteractiveShell.java index 2a480ce..89c5a57 100644 --- a/src/main/java/org/metricshub/winrm/cli/InteractiveShell.java +++ b/src/main/java/org/metricshub/winrm/cli/InteractiveShell.java @@ -214,9 +214,9 @@ static int bridge( final long pollMillis, final String endOfInputCommand ) throws IOException { - // The tail of a piece the current round could not fit entirely: forwarded first on the - // next rounds, before any new piece is pulled. - final StringBuilder pendingInput = new StringBuilder(); + // What is still owed to the remote stdin, and whether the last character forwarded ended + // a line — the synthetic exit below must never be glued onto an unterminated record. + final OutgoingInput outgoing = new OutgoingInput(); boolean eofForwarded = false; boolean completed = false; while (!completed) { @@ -225,7 +225,7 @@ static int bridge( } if (!eofForwarded) { try { - eofForwarded = forwardLocalInput(pieces, process.stdin(), pendingInput, endOfInputCommand); + eofForwarded = forwardLocalInput(pieces, process.stdin(), outgoing, endOfInputCommand); } catch (final IllegalStateException e) { // The command completed while this input was being queued (the final Receive // beat it): nothing can consume it anymore. Stop forwarding — the next poll @@ -256,16 +256,18 @@ static int bridge( private static boolean forwardLocalInput( final InputSource pieces, final BufferedWriter stdin, - final StringBuilder pendingInput, + final OutgoingInput outgoing, final String endOfInputCommand ) throws IOException { int budget = MAX_INPUT_CHARS_PER_ROUND; boolean wrote = false; while (budget > 0) { - if (pendingInput.length() > 0) { - final int take = Math.min(budget, pendingInput.length()); - stdin.write(pendingInput.substring(0, take)); - pendingInput.delete(0, take); + if (outgoing.pending.length() > 0) { + final int take = Math.min(budget, outgoing.pending.length()); + final String slice = outgoing.pending.substring(0, take); + stdin.write(slice); + outgoing.pending.delete(0, take); + outgoing.atLineStart = LINE_SEPARATOR.endsWith(String.valueOf(slice.charAt(slice.length() - 1))); budget -= take; wrote = true; continue; @@ -274,9 +276,9 @@ private static boolean forwardLocalInput( if (piece == null) { break; } - pendingInput.append(piece); + outgoing.pending.append(piece); } - if (pendingInput.length() == 0 && pieces.endOfInput()) { + if (outgoing.pending.length() == 0 && pieces.endOfInput()) { if (endOfInputCommand == null) { // Closing flushes the written characters too: they travel with the End mark, one // single Send. @@ -284,6 +286,12 @@ private static boolean forwardLocalInput( } else { // Console-mode stdin has no end of input: the remote command would wait forever // for the next line, so the local EOF is translated into the command that ends it. + // A local input ending mid-line (no trailing newline) must get its line terminated + // first, or the exit would be glued onto it — "echo hiexit" runs, nothing exits, + // and an idle session never times out. + if (!outgoing.atLineStart) { + stdin.write(LINE_SEPARATOR); + } stdin.write(endOfInputCommand); stdin.write(LINE_SEPARATOR); stdin.flush(); @@ -296,6 +304,16 @@ private static boolean forwardLocalInput( return false; } + /** What the pump still owes the remote stdin, plus whether it stopped on a line boundary. */ + private static final class OutgoingInput { + + /** The tail of a piece the current round could not fit entirely. */ + private final StringBuilder pending = new StringBuilder(); + + /** Whether the last character forwarded ended a line (true before anything is sent). */ + private boolean atLineStart = true; + } + /** Write everything already buffered for the channel — never a protocol round trip. */ private static void forward(final BufferedReader channel, final PrintStream target) throws IOException { final char[] buffer = new char[4096]; diff --git a/src/main/java/org/metricshub/winrm/cli/WinRmCli.java b/src/main/java/org/metricshub/winrm/cli/WinRmCli.java index f92371d..64731c3 100644 --- a/src/main/java/org/metricshub/winrm/cli/WinRmCli.java +++ b/src/main/java/org/metricshub/winrm/cli/WinRmCli.java @@ -363,6 +363,15 @@ private static Object invokeObjectMethod(final Object proxy, final Method method } static RemoteOperations connect(final CliArguments arguments) { + return connect(arguments, 0); + } + + /** + * Connect, optionally pinning the remote command shell's console code page (0 keeps the + * default 65001). The interactive shell needs a single-byte code page — see + * {@link FluentRemoteOperations#shell}. + */ + static FluentRemoteOperations connect(final CliArguments arguments, final int consoleCodePage) { final WinRMClient.Builder builder = WinRMClient .builder(arguments.hostname()) .port(arguments.port()) @@ -376,6 +385,9 @@ static RemoteOperations connect(final CliArguments arguments) { // property, it does not leak to (or race with) anything else in the JVM. builder.trustAllCertificates(); } + if (consoleCodePage > 0) { + builder.consoleCodePage(consoleCodePage); + } final List authentications = arguments.authentications(); if (authentications != null && !authentications.isEmpty()) { builder.authentication( @@ -385,7 +397,7 @@ static RemoteOperations connect(final CliArguments arguments) { .toArray(AuthScheme[]::new) ); } - return new FluentRemoteOperations(builder.build()); + return new FluentRemoteOperations(builder.build(), codePage -> connect(arguments, codePage).client); } private static int remoteExitCode(final int exitCode, final PrintStream standardError) { @@ -558,21 +570,26 @@ static final class FluentRemoteOperations implements RemoteOperations { */ static final String SHELL_COMMAND = "cmd.exe /Q"; - /** What the shell sends when the local input ends: console-mode stdin has no EOF. */ - static final String SHELL_EXIT_COMMAND = "exit"; - /** - * The code page assumed for the interactive shell's input when the remote machine cannot - * be asked: Windows-1252, the most widespread Windows ANSI code page. ASCII — the whole - * command vocabulary — is identical in every ANSI code page, so only non-ASCII characters - * of an unqueryable host are at risk. + * The code page the interactive shell falls back to when the remote machine cannot be + * asked for its ANSI one: 1252, the most widespread Windows ANSI code page. ASCII — the + * whole command vocabulary — is identical in every ANSI code page, so only the non-ASCII + * characters of an unqueryable host are at risk. */ - static final Charset DEFAULT_SHELL_INPUT_CHARSET = Charset.forName("windows-1252"); + static final int DEFAULT_SHELL_CODE_PAGE = 1252; private final WinRMClient client; + private final java.util.function.IntFunction clientFactory; FluentRemoteOperations(final WinRMClient client) { + this(client, codePage -> { + throw new UnsupportedOperationException("This operations handle cannot open a second connection."); + }); + } + + FluentRemoteOperations(final WinRMClient client, final java.util.function.IntFunction clientFactory) { this.client = client; + this.clientFactory = clientFactory; } @Override @@ -609,46 +626,62 @@ public int shell( final PrintStream err, final AtomicBoolean interruptRequested ) throws Exception { - // The remote side of the bridge: cmd.exe with its command echo off (/Q) over the - // default CONSOLE-mode stdin, which never echoes what it receives either — so the - // output stream carries prompts and command output only, and the local terminal alone - // shows what the user types. + // An interactive shell cannot run under console code page 65001, this client's default: + // a remote cmd.exe decodes the command lines it reads from its standard input ONE BYTE + // AT A TIME under that page, so every non-ASCII character becomes U+FFFD (measured on + // Windows Server 2008 R2 and 2022, in both stdin modes and for every input encoding). + // Any single-byte code page works, so the session runs under the remote machine's ANSI + // one and both directions use the matching charset. // - // Console mode is also the only mode that carries non-ASCII command lines: cmd.exe - // parses a PIPED stdin one byte at a time under console code page 65001 (the page this - // client pins for correct UTF-8 output), turning every non-ASCII byte into U+FFFD. In - // console mode the WinRM service converts the bytes itself, with the remote machine's - // ANSI code page — hence the stdinCharset below. It has no end of input, so the local - // EOF is translated into an "exit" command by the pump. - // - // The timeout bounds each protocol round trip; an idle session never trips it, because - // every poll completes with output or the protocol's "nothing yet" answer. - try ( - RemoteProcess process = client.command(SHELL_COMMAND) - .timeout(Duration.ofMillis(timeout)) - .stdinCharset(remoteAnsiCharset(timeout)) - .start()) { - return InteractiveShell.run( - process, - localInput, - out, - err, - interruptRequested, - InteractiveShell.DEFAULT_POLL_MILLIS, - SHELL_EXIT_COMMAND - ); + // The code page is fixed when the shell is created, so it must be known BEFORE the + // first command: the probe runs on this connection (a WQL query creates no shell) and + // the session then opens its own connection with the page pinned. + final int codePage = remoteAnsiCodePage(timeout); + final Charset charset = charsetOfCodePage(codePage); + try (WinRMClient shellClient = clientFactory.apply(codePage)) { + // cmd.exe /Q: no command echo — the local terminal already shows what the user + // types. Pipe-mode stdin (stdin()) makes the local end-of-input a real EOF, which + // cmd.exe exits on. + try ( + RemoteProcess process = shellClient + .command(SHELL_COMMAND) + .timeout(Duration.ofMillis(timeout)) + .charset(charset) + .stdin() + .start()) { + return InteractiveShell.run( + process, + localInput, + out, + err, + interruptRequested, + InteractiveShell.DEFAULT_POLL_MILLIS, + null + ); + } + } + } + + /** The charset of a Windows code page number, falling back to the default shell page. */ + static Charset charsetOfCodePage(final int codePage) { + for (final String name : new String[] { "windows-" + codePage, "IBM" + codePage, "x-IBM" + codePage }) { + try { + return Charset.forName(name); + } catch (final RuntimeException ignored) { + // Try the next naming convention. + } } + return Charset.forName("windows-" + DEFAULT_SHELL_CODE_PAGE); } /** - * The remote machine's ANSI code page, which the WinRM service uses to convert console-mode - * standard input. {@code Win32_OperatingSystem.CodeSet} reports exactly that value (this is - * the ANSI page — the reason it is the wrong answer for decoding OUTPUT, which follows the - * console page instead). A host that cannot answer falls back to - * {@link #DEFAULT_SHELL_INPUT_CHARSET}: an unusable code page must degrade non-ASCII input, - * never prevent the session from opening. + * The remote machine's ANSI code page. {@code Win32_OperatingSystem.CodeSet} reports exactly + * that value (it is the ANSI page — the very reason it is the wrong answer for decoding + * command OUTPUT, which follows the console page instead). A host that cannot answer falls + * back to {@link #DEFAULT_SHELL_CODE_PAGE}: an unusable answer must degrade non-ASCII + * input, never prevent the session from opening. */ - private Charset remoteAnsiCharset(final long timeout) { + private int remoteAnsiCodePage(final long timeout) { try { final List rows; try ( @@ -661,13 +694,13 @@ private Charset remoteAnsiCharset(final long timeout) { if (!rows.isEmpty()) { final String codeSet = rows.get(0).string("CodeSet"); if (codeSet != null && !codeSet.trim().isEmpty()) { - return Charset.forName("windows-" + codeSet.trim()); + return Integer.parseInt(codeSet.trim()); } } } catch (final RuntimeException ignored) { - // No WMI access, an exotic code page, an unsupported charset: fall back below. + // No WMI access, or an answer that is not a code page number: fall back below. } - return DEFAULT_SHELL_INPUT_CHARSET; + return DEFAULT_SHELL_CODE_PAGE; } @Override diff --git a/src/main/java/org/metricshub/winrm/light/Envelopes.java b/src/main/java/org/metricshub/winrm/light/Envelopes.java index ab4ea97..3e0a734 100644 --- a/src/main/java/org/metricshub/winrm/light/Envelopes.java +++ b/src/main/java/org/metricshub/winrm/light/Envelopes.java @@ -141,10 +141,22 @@ static String release(final String url, final String namespace, final String con // --- Command shell ----------------------------------------------------- - static String createShell(final String url, final String workingDirectory, final long timeoutMs) { + /** + * Create a command shell. + * + * @param codePage the console code page of the shell ({@code WINRS_CODEPAGE}); 0 uses + * {@link #CODEPAGE_UTF8}, the default that makes every command's output UTF-8 + */ + static String createShell( + final String url, + final String workingDirectory, + final long timeoutMs, + final int codePage + ) { final String optionSet = "" + "TRUE" + - "" + CODEPAGE_UTF8 + "" + + "" + (codePage > 0 ? String.valueOf(codePage) : CODEPAGE_UTF8) + + "" + ""; final String workingDir = (workingDirectory == null || workingDirectory.trim().isEmpty()) ? "" diff --git a/src/main/java/org/metricshub/winrm/light/LightWinRMService.java b/src/main/java/org/metricshub/winrm/light/LightWinRMService.java index 95e9d9d..1d1fead 100644 --- a/src/main/java/org/metricshub/winrm/light/LightWinRMService.java +++ b/src/main/java/org/metricshub/winrm/light/LightWinRMService.java @@ -112,6 +112,36 @@ public static LightWinRMService createInstance( final List authentications, final SSLContext sslContext, final boolean trustAllCertificates + ) throws WinRMException { + return createInstance(winRMEndpoint, timeout, ticketCache, authentications, sslContext, trustAllCertificates, 0); + } + + /** + * Create a light WinRM executor with an explicit console code page for the command shell. + * + * @param winRMEndpoint endpoint with credentials (mandatory) + * @param timeout timeout in milliseconds (must be > 0) + * @param ticketCache Kerberos ticket cache path (used by the Kerberos scheme; {@code null} logs + * in with the password) + * @param authentications requested authentication schemes, tried in order (NTLM and/or Kerberos); + * {@code null}/empty means NTLM only + * @param sslContext the {@link SSLContext} providing the HTTPS socket factory (hostname + * verification stays on); {@code null} uses the default configuration + * @param trustAllCertificates when {@code true} (and no {@code sslContext} is given), trust every + * server certificate and skip hostname verification — insecure, testing only + * @param consoleCodePage the console code page of the command shell; 0 keeps the default 65001, + * which makes command output UTF-8 whatever the remote locale + * @return a new {@code LightWinRMService} + * @throws WinRMException on invalid arguments or an unsupported authentication request + */ + public static LightWinRMService createInstance( + final WinRMEndpoint winRMEndpoint, + final long timeout, + final java.nio.file.Path ticketCache, + final List authentications, + final SSLContext sslContext, + final boolean trustAllCertificates, + final int consoleCodePage ) throws WinRMException { Utils.checkNonNull(winRMEndpoint, "winRMEndpoint"); Utils.checkArgumentNotZeroOrNegative(timeout, "timeout"); @@ -148,7 +178,8 @@ public static LightWinRMService createInstance( sslSocketFactory, verifyHostname, authScheme, - winRMEndpoint.getRawUsername() + winRMEndpoint.getRawUsername(), + consoleCodePage ); return new LightWinRMService(winRMEndpoint, client); } diff --git a/src/main/java/org/metricshub/winrm/light/WsmanClient.java b/src/main/java/org/metricshub/winrm/light/WsmanClient.java index 9af0618..738ef68 100644 --- a/src/main/java/org/metricshub/winrm/light/WsmanClient.java +++ b/src/main/java/org/metricshub/winrm/light/WsmanClient.java @@ -78,6 +78,7 @@ final class WsmanClient implements AutoCloseable { private static final String WSMAN_NS = "http://schemas.dmtf.org/wbem/wsman/1/wsman.xsd"; private final long timeoutMs; + private final int consoleCodePage; private final String url; private final String rawUsername; private final AuthScheme auth; @@ -147,9 +148,11 @@ private static void checkNotCancelled() throws InterruptedException { final SSLSocketFactory sslSocketFactory, final boolean verifyHostname, final AuthScheme auth, - final String rawUsername + final String rawUsername, + final int consoleCodePage ) { this.timeoutMs = timeoutMs; + this.consoleCodePage = consoleCodePage; // A non-null socket factory selects HTTPS: TLS wraps the transport and the SOAP travels plaintext. this.url = (sslSocketFactory != null ? "https" : "http") + "://" + host + ":" + port + "/wsman"; this.rawUsername = rawUsername; @@ -877,7 +880,7 @@ public void close() throws Exception { private void createShell(final String workingDirectory, final long timeoutMs, final boolean failOnQuietTimeout) throws Exception { final Document doc = exchange( - Envelopes.createShell(url, workingDirectory, timeoutMs), + Envelopes.createShell(url, workingDirectory, timeoutMs, consoleCodePage), "Create shell", timeoutMs, failOnQuietTimeout diff --git a/src/main/java/org/metricshub/winrm/service/WinRMExecutorFactory.java b/src/main/java/org/metricshub/winrm/service/WinRMExecutorFactory.java index ab9c975..b25682c 100644 --- a/src/main/java/org/metricshub/winrm/service/WinRMExecutorFactory.java +++ b/src/main/java/org/metricshub/winrm/service/WinRMExecutorFactory.java @@ -77,6 +77,34 @@ public static WindowsRemoteExecutor createInstance( final List authentications, final SSLContext sslContext, final boolean trustAllCertificates + ) throws WinRMException { + return createInstance(winRMEndpoint, timeout, ticketCache, authentications, sslContext, trustAllCertificates, 0); + } + + /** + * Create a {@link WindowsRemoteExecutor} with an explicit console code page for the command + * shell. + * + * @param winRMEndpoint endpoint with credentials (mandatory) + * @param timeout timeout in milliseconds (must be > 0) + * @param ticketCache Kerberos ticket cache path (may be {@code null}) + * @param authentications requested authentication schemes (may be {@code null}) + * @param sslContext the {@link SSLContext} providing the HTTPS socket factory (hostname + * verification stays on); {@code null} uses the default configuration + * @param trustAllCertificates when {@code true} (and no {@code sslContext} is given), trust every + * server certificate and skip hostname verification — insecure, testing only + * @param consoleCodePage the console code page of the command shell; 0 keeps the default 65001 + * @return an executor backed by {@link LightWinRMService} + * @throws WinRMException for any problem creating the executor + */ + public static WindowsRemoteExecutor createInstance( + final WinRMEndpoint winRMEndpoint, + final long timeout, + final Path ticketCache, + final List authentications, + final SSLContext sslContext, + final boolean trustAllCertificates, + final int consoleCodePage ) throws WinRMException { return LightWinRMService.createInstance( winRMEndpoint, @@ -84,7 +112,8 @@ public static WindowsRemoteExecutor createInstance( ticketCache, authentications, sslContext, - trustAllCertificates + trustAllCertificates, + consoleCodePage ); } } diff --git a/src/site/markdown/cli.md b/src/site/markdown/cli.md index cb37732..ad3b3f0 100644 --- a/src/site/markdown/cli.md +++ b/src/site/markdown/cli.md @@ -127,14 +127,15 @@ which the session turns into an `exit`). The remote exit code is propagated thro * **Echo is off** — the remote shell runs `cmd.exe /Q`, so the input you forward is never repeated back: your terminal already shows what you type, and the output stream carries the prompts and the command output only. -* **Non-ASCII input is limited to the remote machine's ANSI code page.** Command output is - UTF-8 and carries any character (the shell's console code page is 65001), but the *input* - direction is asymmetric on Windows: what you type is converted by the WinRM service with the - remote machine's **ANSI** code page (queried once per session from - `Win32_OperatingSystem.CodeSet`, falling back to Windows-1252 when the host cannot answer). So - `é` reaches a Western-European host fine, while a character outside its ANSI code page does - not. This is the same limitation `winrs` has, and it applies to typed command lines only — - `command` with piped input (see above) transfers bytes unconverted and is unaffected. +* **The session runs under a single-byte code page**, the remote machine's ANSI one (queried + once per session from `Win32_OperatingSystem.CodeSet`, falling back to 1252 when the host + cannot answer); both what you type and what you see use it. This is deliberate: a remote + `cmd.exe` decodes the command lines it reads from its standard input **one byte at a time** + under code page 65001, so every non-ASCII character would be lost. `winrs` has the same + constraint. The practical limit is that characters outside the host's ANSI code page cannot be + typed or displayed in an interactive session — `é` on a Western-European host is fine. + The other subcommands are unaffected: `wql` and `command` keep code page 65001 and full UTF-8 + output, and piped input to `command` transfers bytes unconverted. * **Line-oriented, like `winrs`** — input is line-buffered by the local terminal and forwarded when you press Enter. There is no raw-terminal/PTY mode (with zero dependencies there is none in pure Java): full-screen programs, cmd.exe line editing, tab completion, and ANSI cursor control diff --git a/src/site/markdown/commands.md b/src/site/markdown/commands.md index 7407705..8139bc5 100644 --- a/src/site/markdown/commands.md +++ b/src/site/markdown/commands.md @@ -164,20 +164,22 @@ differently: process **unconverted**. They are encoded with the request's charset (UTF-8 by default), which is what a program reading a UTF-8 stream expects, and `stdin(Path)`/`stdin(InputStream)` send the bytes verbatim. -* **Console semantics** (no `stdin` declaration — a command started with `start()` and written to - through `RemoteProcess.stdin()`) — the WinRM service converts the bytes with the remote - machine's **ANSI** code page before handing them to the console. Encode accordingly with - `stdinCharset(...)`; the CLI's `shell` subcommand queries `Win32_OperatingSystem.CodeSet` once - per session for exactly this. +* **Console semantics** (no `stdin` declaration — a command started with `start()` and written + to through `RemoteProcess.stdin()`) — the WinRM service converts the bytes to console input + itself, using a code page that depends on the Windows version. Prefer pipe semantics, or set + `stdinCharset(...)` to match the session's console code page. Output is unaffected either way: it follows the shell's console code page, which this client pins to UTF-8. -> A remote `cmd.exe` reading its **command lines** from a *piped* stdin cannot handle non-ASCII +> A remote `cmd.exe` reading its **command lines** from standard input cannot handle non-ASCII > at all under console code page 65001 — Windows decodes that input one byte at a time, turning -> every non-ASCII byte into `U+FFFD`. That is why an interactive session must use console -> semantics. Data piped to an ordinary program (`sort`, `findstr`, your own executable) is not -> affected: it never goes through cmd's parser. +> every non-ASCII byte into `U+FFFD`, whatever the encoding used to send it. An interactive +> session must therefore run under a single-byte console code page: build the client with +> `consoleCodePage(...)` (the remote machine's ANSI page) and use the matching charset in both +> directions, as the CLI's `shell` subcommand does. Data piped to an ordinary program (`sort`, +> `findstr`, your own executable) is not affected: it never goes through cmd's parser, so the +> default code page 65001 and UTF-8 are right for it. ### Tailing the output of a blocking execution diff --git a/src/test/java/org/metricshub/winrm/cli/InteractiveShellTest.java b/src/test/java/org/metricshub/winrm/cli/InteractiveShellTest.java index 73e763f..91d9ca5 100644 --- a/src/test/java/org/metricshub/winrm/cli/InteractiveShellTest.java +++ b/src/test/java/org/metricshub/winrm/cli/InteractiveShellTest.java @@ -95,11 +95,18 @@ private void enqueueStartup() { /** * The exact remote process the production shell subcommand starts: {@code cmd.exe /Q} over - * console-mode stdin — the only mode that carries non-ASCII command lines (cmd.exe mangles a - * piped stdin under console code page 65001), live-verified against a real Windows host. + * pipe-mode stdin, with the session's single-byte code page on both directions (cmd.exe + * mangles non-ASCII command lines under console code page 65001 — live-verified on Windows + * Server 2008 R2 and 2022). */ private static RemoteProcess shellProcess(final WinRMClient client) { - return client.command(WinRmCli.FluentRemoteOperations.SHELL_COMMAND).start(); + return client + .command(WinRmCli.FluentRemoteOperations.SHELL_COMMAND) + .charset( + WinRmCli.FluentRemoteOperations.charsetOfCodePage(WinRmCli.FluentRemoteOperations.DEFAULT_SHELL_CODE_PAGE) + ) + .stdin() + .start(); } /** An input source whose {@code nextPiece()} answers are fully scripted, including "not yet". */ @@ -410,6 +417,71 @@ void queuedInputSourceNormalizesLineEndingsAndReportsTheEnd() throws Exception { assertNull(pieces.nextPiece()); } + @Test + void theSyntheticExitIsNeverGluedOntoAnUnterminatedRecord() throws Exception { + enqueueStartup(); + server + // one Send: the unterminated record, its missing line ending, and the exit + .enqueue(200, envelope(sendResponse())) + .enqueue(200, envelope(receiveResponse("", done(COMMAND_ID, 0)))) + .enqueue(200, envelope(signalResponse())); + enqueueShellDeletion(server); + + final int exitCode; + try (WinRMClient client = client()) { + try (RemoteProcess process = shellProcess(client)) { + // A local input ending mid-line (printf 'echo hi' with no trailing newline, or + // Ctrl+D typed mid-line): "echo hiexit" would run instead, and the session would + // hang forever — an idle shell deliberately never times out. + exitCode = InteractiveShell.bridge( + process, + ScriptedPieces.endingAfter("echo hi"), + new PrintStream(new ByteArrayOutputStream(), true, "UTF-8"), + new PrintStream(new ByteArrayOutputStream(), true, "UTF-8"), + new AtomicBoolean(), + POLL_MILLIS, + "exit" + ); + } + } + + assertEquals(0, exitCode); + final List chunks = server.stdinChunks(); + assertEquals(1, chunks.size()); + assertArrayEquals("echo hi\r\nexit\r\n".getBytes(StandardCharsets.UTF_8), chunks.get(0).data()); + // Console-mode stdin has no end of input: the exit command replaces the End mark. + assertFalse(chunks.get(0).end()); + } + + @Test + void theSyntheticExitFollowsAProperlyTerminatedRecordDirectly() throws Exception { + enqueueStartup(); + server + .enqueue(200, envelope(sendResponse())) + .enqueue(200, envelope(receiveResponse("", done(COMMAND_ID, 0)))) + .enqueue(200, envelope(signalResponse())); + enqueueShellDeletion(server); + + try (WinRMClient client = client()) { + try (RemoteProcess process = shellProcess(client)) { + InteractiveShell.bridge( + process, + ScriptedPieces.endingAfter("echo hi\r\n"), + new PrintStream(new ByteArrayOutputStream(), true, "UTF-8"), + new PrintStream(new ByteArrayOutputStream(), true, "UTF-8"), + new AtomicBoolean(), + POLL_MILLIS, + "exit" + ); + } + } + + // No spurious blank line before the exit when the record already ended one. + final List chunks = server.stdinChunks(); + assertEquals(1, chunks.size()); + assertArrayEquals("echo hi\r\nexit\r\n".getBytes(StandardCharsets.UTF_8), chunks.get(0).data()); + } + @Test void aLocalReadFailureFailsTheSourceInsteadOfEndingIt() throws Exception { // A failing local stdin must NOT read as a normal end of input: the remote shell would diff --git a/src/test/java/org/metricshub/winrm/cli/WinRmCliTest.java b/src/test/java/org/metricshub/winrm/cli/WinRmCliTest.java index 6c14109..4abe781 100644 --- a/src/test/java/org/metricshub/winrm/cli/WinRmCliTest.java +++ b/src/test/java/org/metricshub/winrm/cli/WinRmCliTest.java @@ -124,12 +124,12 @@ void shellSubcommandBridgesTheRemoteShellAndPropagatesItsExitCode() throws Excep } @Test - void shellRunsAQuietCmdOverConsoleModeStdin() throws Exception { + void shellRunsAQuietCmdUnderASingleByteCodePage() throws Exception { // Full stack against the in-process WSMan server, through the CLI's real connect factory. - // The shell must be echo-free — cmd.exe started with /Q, and console-mode stdin never - // echoes what it receives either — AND use console mode, the only stdin mode that carries - // non-ASCII command lines (cmd.exe mangles a piped stdin under console code page 65001). - // The ANSI code page probe precedes the shell: its answer encodes what the user types. + // The shell must be echo-free (cmd.exe /Q) and must NOT run under console code page 65001: + // a remote cmd.exe decodes the command lines it reads from stdin one byte at a time under + // that page, losing every non-ASCII character. The ANSI code page probe precedes the shell + // and its answer becomes the shell's console code page. try (FakeWsmanServer server = new FakeWsmanServer("FAKE", "user", "secret")) { server.enqueue( 200, @@ -183,9 +183,11 @@ public int read() { assertEquals(0, invocation.exitCode); final List requests = server.decryptedRequests(); assertTrue(requests.get(0).contains("Win32_OperatingSystem"), requests.get(0)); + final String create = requests.get(1); + assertTrue(create.contains("1252"), create); final String command = requests.get(2); assertTrue(command.contains("cmd.exe /Q"), command); - assertTrue(command.contains("TRUE"), command); + assertTrue(command.contains("FALSE"), command); } } From 991c6dc6b38372faf3eebc9bcb6244e567d757ef Mon Sep 17 00:00:00 2001 From: Bertrand Martin Date: Wed, 29 Jul 2026 14:54:23 +0200 Subject: [PATCH 16/17] Address Codex round 11: restore the timeout Javadoc; keep bounded polls within the configured timeout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - The consoleCodePage Javadoc had been inserted between the timeout Javadoc and timeout(Duration), leaving that public method undocumented. Moved back. - A bounded poll's budget is capped by the per-round-trip timeout STRICTLY again: raising it to the wire minimum made a poll outlast the inactivity tolerance its caller configured, contradicting both the CommandCursor and RemoteProcess contracts. A timeout below the protocol floor therefore keeps bounded polls local (no wire), which the Javadoc and the test now state explicitly - the unbounded paths (waitFor, the readers) still advance such a process, and the CLI's shell already rejects timeouts under 1 s. Re-verified live on Windows Server 2022: "echo testé français" intact, exit code propagated. Co-Authored-By: Claude Fable 5 --- .../org/metricshub/winrm/RemoteProcess.java | 10 ++++++---- .../org/metricshub/winrm/WinRMClient.java | 10 +++++----- .../metricshub/winrm/light/WsmanClient.java | 20 +++++++++---------- .../metricshub/winrm/StreamingApiTest.java | 19 +++++++++++------- 4 files changed, 33 insertions(+), 26 deletions(-) diff --git a/src/main/java/org/metricshub/winrm/RemoteProcess.java b/src/main/java/org/metricshub/winrm/RemoteProcess.java index 9933666..ea461d9 100644 --- a/src/main/java/org/metricshub/winrm/RemoteProcess.java +++ b/src/main/java/org/metricshub/winrm/RemoteProcess.java @@ -248,10 +248,12 @@ public synchronized int waitFor() { * The wait is the polling cadence, not a hard bound on the round trip: how long the * answer itself may take to arrive is governed by the request timeout (the inactivity * timeout), so one slow answer from a loaded server does not fail the stream — use - * {@link #waitFor(Duration)} when a hard deadline is needed. A wait too short for a network - * round trip — the WSMan service holds a bounded Receive for at least 500 ms before answering - * "nothing yet" — is waited out locally without touching the wire: the stream then does not - * advance. Use waits of about a second or more to actually poll the server. + * {@link #waitFor(Duration)} when a hard deadline is needed. The request timeout also + * caps the cadence, and a resulting budget too short for a network round trip — the + * WSMan service holds a bounded Receive for at least 500 ms before answering "nothing yet" — + * is waited out locally without touching the wire: the stream then does not advance. So poll + * with waits of about a second or more, on a request whose timeout is at least as long; + * a shorter request timeout leaves {@link #waitFor()} and the readers as the ways to advance. * * @param maxWait when the server should answer at the latest (at least one millisecond) * @return {@code true} when the command has completed — the exit code is then available from diff --git a/src/main/java/org/metricshub/winrm/WinRMClient.java b/src/main/java/org/metricshub/winrm/WinRMClient.java index 7c8a7b1..87b0ac2 100644 --- a/src/main/java/org/metricshub/winrm/WinRMClient.java +++ b/src/main/java/org/metricshub/winrm/WinRMClient.java @@ -392,6 +392,11 @@ public Builder sslContext(final SSLContext sslContext) { * @param timeout the timeout (at least one millisecond) * @return this builder */ + public Builder timeout(final Duration timeout) { + this.timeout = checkPositive(timeout, "timeout"); + return this; + } + /** * Set the console code page of the remote command shell. Default: 65001 (UTF-8), which makes * command output UTF-8 whatever the remote locale — the right choice for reading output and @@ -414,11 +419,6 @@ public Builder consoleCodePage(final int consoleCodePage) { return this; } - public Builder timeout(final Duration timeout) { - this.timeout = checkPositive(timeout, "timeout"); - return this; - } - /** * Build the client. This does not connect yet: the connection is established and * authenticated by the first operation. diff --git a/src/main/java/org/metricshub/winrm/light/WsmanClient.java b/src/main/java/org/metricshub/winrm/light/WsmanClient.java index 738ef68..0fd96ca 100644 --- a/src/main/java/org/metricshub/winrm/light/WsmanClient.java +++ b/src/main/java/org/metricshub/winrm/light/WsmanClient.java @@ -615,14 +615,15 @@ Chunk pollChunk(final long askMs, final long maxWaitMs) throws Exception { finishBounded(maxWaitMs); return null; } - // The per-round-trip timeout caps the poll — but never below the wire minimum: an - // inactivity timeout under the protocol floor would otherwise turn EVERY poll into a - // local sleep, and a poller could never observe the command's completion. - final long budget = Math.max(1, Math.min(maxWaitMs, Math.max(operationTimeoutMs, MIN_WIRE_POLL_MS))); + // The per-round-trip timeout caps the poll, strictly: a bounded wait must never outlast + // the inactivity tolerance its caller configured. + final long budget = Math.max(1, Math.min(maxWaitMs, operationTimeoutMs)); if (budget < MIN_WIRE_POLL_MS) { - // The CALLER asked for less than any network round trip can honor: waiting the - // budget out locally is the only way. The protocol advances on the next fetch or - // full-size poll. + // Less than any network round trip can honor — because the caller asked for it, or + // because the handle's own per-round-trip timeout is below the protocol's floor. + // Waiting the budget out locally is the only way to honor it; the protocol advances + // on the next full-size poll or on an unbounded fetch, whose socket budget is the + // same per-round-trip timeout. Thread.sleep(budget); return new Chunk(new byte[0], new byte[0]); } @@ -847,9 +848,8 @@ private void finishBounded(final long budgetMs) { * the completed command's state with the shell. */ private void terminateCompleted(final long budgetMs) { - // Same clamp as the bounded poll: an inactivity timeout under the wire minimum must not - // starve the best-effort Signal that a caller's comfortable budget could well afford. - final long budget = Math.max(1, Math.min(budgetMs, Math.max(operationTimeoutMs, MIN_WIRE_POLL_MS))); + // Same strict clamp as the bounded poll: the per-round-trip timeout caps this cleanup too. + final long budget = Math.max(1, Math.min(budgetMs, operationTimeoutMs)); if (budget < MIN_WIRE_POLL_MS) { return; } diff --git a/src/test/java/org/metricshub/winrm/StreamingApiTest.java b/src/test/java/org/metricshub/winrm/StreamingApiTest.java index 29fa283..a9fbb89 100644 --- a/src/test/java/org/metricshub/winrm/StreamingApiTest.java +++ b/src/test/java/org/metricshub/winrm/StreamingApiTest.java @@ -582,20 +582,25 @@ void aPollAnswerSlowerThanTheCadenceDoesNotFailTheStream() throws Exception { } @Test - void boundedPollsStillReachTheWireWithASubFloorInactivityTimeout() throws Exception { + void aSubFloorInactivityTimeoutKeepsBoundedPollsLocalAndTheProcessUsable() throws Exception { enqueueCommandStartup(); server .enqueue(200, envelope(receiveResponse(stdoutChunk("done\n"), done(COMMAND_ID, 4)))) .enqueue(200, envelope(signalResponse())); - // The process's inactivity timeout (500 ms) sits below the wire-poll minimum: the poll cap - // must not push a caller's comfortable wait under the protocol floor, or no poll would ever - // reach the wire and completion could never be observed. + // The per-round-trip timeout caps a bounded poll STRICTLY: a 500 ms inactivity timeout + // bounds the poll to 500 ms however long the caller's wait, and 500 ms is below what a + // round trip can honor (the server holds a bounded Receive for at least 500 ms, plus + // transit). Such a poll is therefore waited out locally, touching nothing... try (WinRMClient client = builder().timeout(Duration.ofMillis(500)).build()) { try (RemoteProcess process = client.command("run.exe").charset(StandardCharsets.UTF_8).start()) { - assertFalse(process.poll(Duration.ofSeconds(5)), "the Done chunk itself is not completion yet"); - assertTrue(process.poll(Duration.ofSeconds(5)), "completion must be observable"); - assertEquals(4, process.exitCode()); + final int requestsBefore = server.decryptedRequests().size(); + assertFalse(process.poll(Duration.ofSeconds(5)), "a sub-floor budget cannot observe completion"); + assertEquals(requestsBefore, server.decryptedRequests().size(), "no request may reach the wire"); + + // ...and the process stays fully usable: the unbounded path, whose socket budget is + // that same per-round-trip timeout, observes the completion. + assertEquals(4, process.waitFor()); assertEquals("done", process.stdout().readLine()); } } From 19d78873d02bbca79befbe9eb9f1854bb773a37d Mon Sep 17 00:00:00 2001 From: Bertrand Martin Date: Wed, 29 Jul 2026 15:20:14 +0200 Subject: [PATCH 17/17] Address Codex round 12: resolve the shell's code page and charset as one decision charsetOfCodePage() could fall back to windows-1252 while the shell was still created under the code page the host reported. The dangerous case is a host configured for UTF-8: it reports 65001 as its ANSI page, so the session would have run under exactly the code page an interactive cmd.exe cannot read command lines from - with a mismatched charset on top, corrupting both directions. sessionEncoding() now returns the code page AND the charset together, so they can never disagree, and refuses two answers by falling back on both counts: 65001 (unusable for a shell by construction) and any page this JVM has no charset for. Co-Authored-By: Claude Fable 5 --- .../org/metricshub/winrm/cli/WinRmCli.java | 70 +++++++++++++++---- .../winrm/cli/InteractiveShellTest.java | 4 +- .../metricshub/winrm/cli/WinRmCliTest.java | 32 +++++++++ 3 files changed, 93 insertions(+), 13 deletions(-) diff --git a/src/main/java/org/metricshub/winrm/cli/WinRmCli.java b/src/main/java/org/metricshub/winrm/cli/WinRmCli.java index 64731c3..2883c51 100644 --- a/src/main/java/org/metricshub/winrm/cli/WinRmCli.java +++ b/src/main/java/org/metricshub/winrm/cli/WinRmCli.java @@ -578,6 +578,9 @@ static final class FluentRemoteOperations implements RemoteOperations { */ static final int DEFAULT_SHELL_CODE_PAGE = 1252; + /** The UTF-8 code page: the client's default for commands, and unusable for a shell. */ + private static final int UTF8_CODE_PAGE = 65001; + private final WinRMClient client; private final java.util.function.IntFunction clientFactory; @@ -636,9 +639,8 @@ public int shell( // The code page is fixed when the shell is created, so it must be known BEFORE the // first command: the probe runs on this connection (a WQL query creates no shell) and // the session then opens its own connection with the page pinned. - final int codePage = remoteAnsiCodePage(timeout); - final Charset charset = charsetOfCodePage(codePage); - try (WinRMClient shellClient = clientFactory.apply(codePage)) { + final SessionEncoding encoding = sessionEncoding(remoteAnsiCodePage(timeout)); + try (WinRMClient shellClient = clientFactory.apply(encoding.codePage())) { // cmd.exe /Q: no command echo — the local terminal already shows what the user // types. Pipe-mode stdin (stdin()) makes the local end-of-input a real EOF, which // cmd.exe exits on. @@ -646,7 +648,7 @@ public int shell( RemoteProcess process = shellClient .command(SHELL_COMMAND) .timeout(Duration.ofMillis(timeout)) - .charset(charset) + .charset(encoding.charset()) .stdin() .start()) { return InteractiveShell.run( @@ -662,16 +664,60 @@ public int shell( } } - /** The charset of a Windows code page number, falling back to the default shell page. */ - static Charset charsetOfCodePage(final int codePage) { - for (final String name : new String[] { "windows-" + codePage, "IBM" + codePage, "x-IBM" + codePage }) { - try { - return Charset.forName(name); - } catch (final RuntimeException ignored) { - // Try the next naming convention. + /** + * The console code page an interactive session runs under, together with the charset that + * encodes and decodes it — resolved as ONE decision, so the shell can never be created + * under a page whose bytes the session would then misread. + */ + static final class SessionEncoding { + + private final int codePage; + private final Charset charset; + + SessionEncoding(final int codePage, final Charset charset) { + this.codePage = codePage; + this.charset = charset; + } + + int codePage() { + return codePage; + } + + Charset charset() { + return charset; + } + } + + /** + * Resolve the session's code page and charset from the code page the remote machine + * reports. Two answers are refused, and both fall back to {@link #DEFAULT_SHELL_CODE_PAGE} + * for BOTH the page and the charset: + *

    + *
  • 65001 — a host configured for UTF-8 reports it as its ANSI page, and 65001 is + * precisely the page an interactive {@code cmd.exe} cannot read command lines under;
  • + *
  • a page this JVM has no charset for — encoding the session with a charset that does + * not match the shell's page would corrupt both directions.
  • + *
+ * + * @param reportedCodePage the code page the remote machine reported + * @return the code page to pin and the charset to use with it, always consistent + */ + static SessionEncoding sessionEncoding(final int reportedCodePage) { + if (reportedCodePage != UTF8_CODE_PAGE) { + for (final String name : new String[] { + "windows-" + reportedCodePage, + "IBM" + reportedCodePage, + "x-IBM" + reportedCodePage, + "MS" + reportedCodePage + }) { + try { + return new SessionEncoding(reportedCodePage, Charset.forName(name)); + } catch (final RuntimeException ignored) { + // Try the next naming convention. + } } } - return Charset.forName("windows-" + DEFAULT_SHELL_CODE_PAGE); + return new SessionEncoding(DEFAULT_SHELL_CODE_PAGE, Charset.forName("windows-" + DEFAULT_SHELL_CODE_PAGE)); } /** diff --git a/src/test/java/org/metricshub/winrm/cli/InteractiveShellTest.java b/src/test/java/org/metricshub/winrm/cli/InteractiveShellTest.java index 91d9ca5..3f48cf6 100644 --- a/src/test/java/org/metricshub/winrm/cli/InteractiveShellTest.java +++ b/src/test/java/org/metricshub/winrm/cli/InteractiveShellTest.java @@ -103,7 +103,9 @@ private static RemoteProcess shellProcess(final WinRMClient client) { return client .command(WinRmCli.FluentRemoteOperations.SHELL_COMMAND) .charset( - WinRmCli.FluentRemoteOperations.charsetOfCodePage(WinRmCli.FluentRemoteOperations.DEFAULT_SHELL_CODE_PAGE) + WinRmCli.FluentRemoteOperations + .sessionEncoding(WinRmCli.FluentRemoteOperations.DEFAULT_SHELL_CODE_PAGE) + .charset() ) .stdin() .start(); diff --git a/src/test/java/org/metricshub/winrm/cli/WinRmCliTest.java b/src/test/java/org/metricshub/winrm/cli/WinRmCliTest.java index 4abe781..5960811 100644 --- a/src/test/java/org/metricshub/winrm/cli/WinRmCliTest.java +++ b/src/test/java/org/metricshub/winrm/cli/WinRmCliTest.java @@ -191,6 +191,38 @@ public int read() { } } + @Test + void theShellCodePageAndCharsetAreAlwaysResolvedTogether() { + // A page this JVM has a charset for is used as reported... + assertEquals(1252, WinRmCli.FluentRemoteOperations.sessionEncoding(1252).codePage()); + assertEquals( + java.nio.charset.Charset.forName("windows-1252"), + WinRmCli.FluentRemoteOperations.sessionEncoding(1252).charset() + ); + assertEquals(850, WinRmCli.FluentRemoteOperations.sessionEncoding(850).codePage()); + assertEquals( + java.nio.charset.Charset.forName("IBM850"), + WinRmCli.FluentRemoteOperations.sessionEncoding(850).charset() + ); + + // ...but 65001 — what a host configured for UTF-8 reports as its ANSI page — is precisely + // the page an interactive cmd.exe cannot read command lines under, so BOTH the page and + // the charset fall back rather than pinning the shell to a page the session cannot use. + assertEquals(1252, WinRmCli.FluentRemoteOperations.sessionEncoding(65001).codePage()); + assertEquals( + java.nio.charset.Charset.forName("windows-1252"), + WinRmCli.FluentRemoteOperations.sessionEncoding(65001).charset() + ); + + // An unknown page falls back on both counts too: a charset that does not match the shell's + // code page would corrupt the session in both directions. + assertEquals(1252, WinRmCli.FluentRemoteOperations.sessionEncoding(999_999).codePage()); + assertEquals( + java.nio.charset.Charset.forName("windows-1252"), + WinRmCli.FluentRemoteOperations.sessionEncoding(999_999).charset() + ); + } + @Test void shellTakesNoArgument() throws Exception { final Invocation invocation = invoke(concat(REQUIRED, "shell", "cmd.exe"), args -> failingRemote());