From 9e5bfe79f76668d9978abebb2a2bf78ff20947fd Mon Sep 17 00:00:00 2001 From: Bertrand Martin Date: Mon, 27 Jul 2026 22:18:30 +0200 Subject: [PATCH 01/13] Add streaming APIs: stream() and start() terminals on the fluent builders (#111) WQL queries and remote commands can now be consumed incrementally, as the WSMan responses arrive, with memory bounded by one page/chunk instead of the whole result: - WqlRequest.stream() returns a closeable, lazy Stream: rows are yielded as soon as they are parsed and the next WS-Enumeration Pull is issued only as the stream advances. Closing early sends a WS-Enumeration Release (new envelope) so the server frees the enumeration context; exhaustion releases the connection on its own. - CommandRequest.start() returns a Process-like RemoteProcess: stdout() and stderr() readers decoded incrementally with a stateful CharsetDecoder (multibyte characters split across Receive responses decode correctly), waitFor()/waitFor(Duration), exitCode(), and close() sending the terminate Signal that stops a still-running remote command. - CommandRequest.onStdout()/onStderr() callbacks tail the output live while execute() keeps its blocking contract and complete CommandResult. For the streaming terminals the request timeout is an inactivity timeout (longest tolerated server silence, enforced per round trip via the WSMan OperationTimeout and the socket read timeout), not an overall deadline; an open stream/process pins the client's serial connection until closed, like a JDBC ResultSet. Plumbing: WsmanClient now exposes stream-first primitives (WqlEnumeration, RemoteCommand) and the blocking wql()/executeCommand() are drains over them, so the two paths cannot drift; the connection lock became a Semaphore so a handle can be closed from another thread; WindowsRemoteExecutor gained default streamWql()/startCommand() methods returning the new public WqlCursor/CommandCursor SPI types, implemented by LightWinRMService. Closes #111 Co-Authored-By: Claude Fable 5 --- README.md | 38 ++ .../java/org/metricshub/winrm/AuthScheme.java | 2 +- .../org/metricshub/winrm/ChunkDecoder.java | 102 ++++ .../org/metricshub/winrm/CommandCursor.java | 118 +++++ .../org/metricshub/winrm/CommandRequest.java | 270 ++++++++-- .../org/metricshub/winrm/CommandResult.java | 2 +- .../org/metricshub/winrm/RemoteProcess.java | 246 +++++++++ .../org/metricshub/winrm/WinRMClient.java | 10 +- .../winrm/WindowsRemoteExecutor.java | 60 +++ .../java/org/metricshub/winrm/WqlCursor.java | 62 +++ .../java/org/metricshub/winrm/WqlRequest.java | 90 +++- .../java/org/metricshub/winrm/WqlResult.java | 2 +- .../java/org/metricshub/winrm/WqlRow.java | 2 +- .../WinRMAuthenticationException.java | 2 +- .../exceptions/WinRMClientException.java | 2 +- .../winrm/exceptions/WinRMFaultException.java | 2 +- .../exceptions/WinRMTimeoutException.java | 2 +- .../winrm/exceptions/WqlSyntaxException.java | 2 +- .../org/metricshub/winrm/light/Envelopes.java | 16 +- .../winrm/light/LightWinRMService.java | 133 ++++- .../metricshub/winrm/light/WsmanClient.java | 468 +++++++++++++++--- src/site/markdown/commands.md | 53 +- src/site/markdown/index.md | 5 + src/site/markdown/timeouts-and-errors.md | 12 + src/site/markdown/wql.md | 36 +- .../metricshub/winrm/ChunkDecoderTest.java | 93 ++++ .../metricshub/winrm/StreamingApiTest.java | 437 ++++++++++++++++ 27 files changed, 2121 insertions(+), 146 deletions(-) create mode 100644 src/main/java/org/metricshub/winrm/ChunkDecoder.java create mode 100644 src/main/java/org/metricshub/winrm/CommandCursor.java create mode 100644 src/main/java/org/metricshub/winrm/RemoteProcess.java create mode 100644 src/main/java/org/metricshub/winrm/WqlCursor.java create mode 100644 src/test/java/org/metricshub/winrm/ChunkDecoderTest.java create mode 100644 src/test/java/org/metricshub/winrm/StreamingApiTest.java diff --git a/README.md b/README.md index c98fd4b..f4506e3 100644 --- a/README.md +++ b/README.md @@ -73,6 +73,44 @@ Per-operation options: `namespace(...)`, `timeout(...)`, and for WQL enumeration from the remote code set by default), and `upload(Path...)` to copy local script files and rewrite the command to reference the remote copies. +### Streaming + +Both operations also have a streaming terminal for large result sets and long-running commands — +everything upstream (authentication, TLS, namespace, options) is shared with the blocking +terminals: + +```java +// WQL rows are pulled from the server page by page as the stream advances: +// memory stays bounded by one page (pageSize(int)), not by the whole result set. +try (Stream rows = client.wql("SELECT * FROM Win32_NTLogEvent").stream()) { + rows.filter(r -> "Error".equals(r.string("Type"))) + .limit(100) + .forEach(System.out::println); +} + +// Commands can be consumed while they run, java.lang.Process-style: +try (RemoteProcess p = client.command("wevtutil qe System /f:text").start()) { + try (BufferedReader out = p.stdout()) { + out.lines().forEach(System.out::println); + } + int exitCode = p.waitFor(); // or waitFor(Duration) for a deadline +} + +// Middle ground: tail the output live, keep the blocking terminal and its full result. +client.command("longRunningThing.exe") + .onStdout(chunk -> log.info(chunk)) + .onStderr(chunk -> log.warn(chunk)) + .execute(); +``` + +Streams and processes **must be closed** (try-with-resources): they hold the client's serial +connection while open, and closing early releases the server-side enumeration (WS-Enumeration +`Release`) or terminates the remote command (WinRM terminate `Signal`). For the streaming +terminals the configured timeout is an **inactivity** timeout — the longest silence tolerated from +the server between two responses — not an overall deadline, so long tails can stream indefinitely. +Output is decoded incrementally: a multibyte character split across protocol chunks is decoded +correctly. + The pre-existing static helpers (`WinRMWqlExecutor.executeWql(...)`, `WinRMCommandExecutor.execute(...)`) keep working unchanged — see **Legacy API** below. diff --git a/src/main/java/org/metricshub/winrm/AuthScheme.java b/src/main/java/org/metricshub/winrm/AuthScheme.java index cd9a99f..46b3c97 100644 --- a/src/main/java/org/metricshub/winrm/AuthScheme.java +++ b/src/main/java/org/metricshub/winrm/AuthScheme.java @@ -4,7 +4,7 @@ * ╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲ * WinRM Java Client * ჻჻჻჻჻჻ - * Copyright 2023 - 2026 MetricsHub + * 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. diff --git a/src/main/java/org/metricshub/winrm/ChunkDecoder.java b/src/main/java/org/metricshub/winrm/ChunkDecoder.java new file mode 100644 index 0000000..596135c --- /dev/null +++ b/src/main/java/org/metricshub/winrm/ChunkDecoder.java @@ -0,0 +1,102 @@ +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.nio.ByteBuffer; +import java.nio.CharBuffer; +import java.nio.charset.Charset; +import java.nio.charset.CharsetDecoder; +import java.nio.charset.CoderResult; +import java.nio.charset.CodingErrorAction; + +/** + * Incremental, stateful charset decoding for streamed command output. A multibyte character (e.g. + * UTF-8) can be split across WSMan Stream elements or Receive responses, so each chunk is decoded + * with a decoder that carries the partial-character bytes over to the next chunk — never a + * per-chunk {@code new String(bytes)}, which would corrupt the boundary bytes into replacement + * characters. Malformed and unmappable input is replaced, matching + * {@link String#String(byte[], Charset)}, so incrementally decoding a byte sequence yields the + * same text as decoding it in one piece. + */ +final class ChunkDecoder { + + private final CharsetDecoder decoder; + + // Undecoded tail bytes of the previous chunk — an incomplete multibyte character — replayed in + // front of the next chunk. + private byte[] pending = new byte[0]; + + ChunkDecoder(final Charset charset) { + this.decoder = charset + .newDecoder() + .onMalformedInput(CodingErrorAction.REPLACE) + .onUnmappableCharacter(CodingErrorAction.REPLACE); + } + + /** + * Decode the next chunk, returning the characters that are complete so far. An incomplete + * multibyte character at the end of the chunk is withheld until the next call completes it. + */ + String decode(final byte[] chunk) { + return decode(chunk, false); + } + + /** + * Flush the decoder at the end of the stream: a trailing incomplete character becomes a + * replacement character, exactly as a whole-buffer {@code new String(bytes)} would render it. + */ + String finish() { + return decode(new byte[0], true); + } + + private String decode(final byte[] chunk, final boolean endOfInput) { + final ByteBuffer in = ByteBuffer.allocate(pending.length + chunk.length); + in.put(pending); + in.put(chunk); + in.flip(); + + final StringBuilder text = new StringBuilder(); + final CharBuffer out = CharBuffer.allocate(Math.max(16, in.remaining() * 2)); + CoderResult result; + do { + result = decoder.decode(in, out, endOfInput); + out.flip(); + text.append(out); + out.clear(); + // With REPLACE in force the only non-underflow result is overflow: loop for more room. + } while (result.isOverflow()); + + if (endOfInput) { + do { + result = decoder.flush(out); + out.flip(); + text.append(out); + out.clear(); + } while (result.isOverflow()); + pending = new byte[0]; + } else { + // Whatever the decoder left in the input is an incomplete character: carry it over. + pending = new byte[in.remaining()]; + in.get(pending); + } + return text.toString(); + } +} diff --git a/src/main/java/org/metricshub/winrm/CommandCursor.java b/src/main/java/org/metricshub/winrm/CommandCursor.java new file mode 100644 index 0000000..1d17955 --- /dev/null +++ b/src/main/java/org/metricshub/winrm/CommandCursor.java @@ -0,0 +1,118 @@ +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; + + /** + * 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. 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 76164e9..8ab808e 100644 --- a/src/main/java/org/metricshub/winrm/CommandRequest.java +++ b/src/main/java/org/metricshub/winrm/CommandRequest.java @@ -4,7 +4,7 @@ * ╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲ * WinRM Java Client * ჻჻჻჻჻჻ - * Copyright 2023 - 2026 MetricsHub + * 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. @@ -26,7 +26,9 @@ import java.time.Duration; import java.util.ArrayList; import java.util.List; +import java.util.concurrent.ExecutionException; import java.util.concurrent.TimeoutException; +import java.util.function.Consumer; import java.util.stream.Collectors; import org.metricshub.winrm.exceptions.WinRMClientException; import org.metricshub.winrm.exceptions.WinRMTimeoutException; @@ -36,7 +38,8 @@ /** * A command being prepared for execution, created by {@link WinRMClient#command(String)}. * Every option has a sensible default; {@link #execute()} runs the command and returns its - * output and exit code. + * output and exit code, {@link #start()} returns a {@link RemoteProcess} whose output can be + * consumed while the command is still running. */ public final class CommandRequest { @@ -46,6 +49,8 @@ public final class CommandRequest { private Duration timeout; private Charset charset; private final List uploads = new ArrayList<>(); + private Consumer stdoutConsumer; + private Consumer stderrConsumer; /** * Create the request. @@ -75,8 +80,10 @@ public CommandRequest workingDirectory(final String workingDirectory) { } /** - * Set the timeout of this command — a wall-clock deadline covering file uploads, encoding - * detection, and the command itself. Default: the client's timeout. + * Set the timeout of this command. For {@link #execute()} it is a wall-clock deadline covering + * file uploads, encoding detection, and the command itself; for {@link #start()} it is an + * inactivity timeout — the longest silence tolerated from the server between two + * responses, with no overall deadline. Default: the client's timeout. * * @param timeout the timeout (at least one millisecond) * @return this request @@ -126,7 +133,48 @@ public CommandRequest upload(final Path... files) { } /** - * Execute the command and collect its complete output. + * 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 + * managing a {@link RemoteProcess}: tail the output live, but keep the blocking terminal and + * its complete {@link CommandResult}. + * + *

{@code
+	 * client.command("longRunningThing.exe")
+	 * 	.onStdout(chunk -> log.info(chunk))
+	 * 	.onStderr(chunk -> log.warn(chunk))
+	 * 	.execute();
+	 * }
+ * + * The callback is invoked on an internal worker thread (never concurrently), with output + * decoded incrementally: a chunk is a run of characters as the server delivered them, not + * necessarily whole lines. + * + * @param consumer the standard output consumer + * @return this request + */ + public CommandRequest onStdout(final Consumer consumer) { + Utils.checkNonNull(consumer, "consumer"); + this.stdoutConsumer = consumer; + return this; + } + + /** + * Register a callback receiving each chunk of standard error as it arrives, while + * {@link #execute()} is still running. Same contract as {@link #onStdout(Consumer)}. + * + * @param consumer the standard error consumer + * @return this request + */ + public CommandRequest onStderr(final Consumer consumer) { + Utils.checkNonNull(consumer, "consumer"); + this.stderrConsumer = consumer; + return this; + } + + /** + * Execute the command and collect its complete output. When {@link #onStdout(Consumer)} or + * {@link #onStderr(Consumer)} callbacks are registered, they additionally receive the output + * chunk by chunk while the command runs; the returned result is complete either way. * * @return the command result: stdout, stderr, exit code, and execution time * @throws org.metricshub.winrm.exceptions.WinRMTimeoutException when the timeout elapses first @@ -138,50 +186,194 @@ public CommandResult execute() { final long start = Utils.getCurrentTimeMillis(); final long timeoutMillis = WinRMClient.toMillis(timeout); try { - String actualCommand = commandLine; - String actualWorkingDirectory = workingDirectory; - - if (!uploads.isEmpty()) { - // Copy the files through the command shell and rewrite the command to reference the - // remote copies; the transfer commands create the shell, so the working directory no - // longer applies (the shell already exists when the real command runs). - final List localFiles = uploads.stream().map(Path::toString).collect(Collectors.toList()); - final String updatedCommand = ShellFileCopy.copyLocalFilesToRemote( - client.executor(), - commandLine, - localFiles, - TimeoutHelper.getRemainingTime(timeoutMillis, start, "No time left to copy the local files") + final Prepared prepared = prepare(timeoutMillis, start); + final long remaining = TimeoutHelper.getRemainingTime( + timeoutMillis, + start, + "No time left to execute the command" + ); + + if (stdoutConsumer == null && stderrConsumer == null) { + final WindowsRemoteCommandResult result = client + .executor() + .executeCommand(prepared.command, prepared.workingDirectory, prepared.charset, remaining); + + return new CommandResult( + result.getStdout(), + result.getStderr(), + result.getStatusCode(), + Duration.ofMillis(Utils.getCurrentTimeMillis() - start) ); - actualCommand = String.format("CMD.EXE /C (%s)", updatedCommand); - actualWorkingDirectory = null; } - final Charset actualCharset = charset != null ? charset : client.detectCharset(timeoutMillis, start); + // 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. + return Utils.execute(() -> drainWithCallbacks(prepared, remaining, start), remaining); + } catch (final TimeoutException e) { + throw timeoutException(e); + } catch (final IOException | WqlQuerySyntaxException e) { + throw new WinRMClientException(e.getMessage(), e); + } catch (final InterruptedException e) { + Thread.currentThread().interrupt(); + throw new WinRMClientException(e.getMessage(), e); + } catch (final ExecutionException e) { + throw translateExecutionFailure(e); + } catch (final WindowsRemoteException e) { + throw WinRMClient.translate(e); + } + } - final WindowsRemoteCommandResult result = client + /** + * Start the command and return a {@link RemoteProcess} handle over it — the streaming + * counterpart of {@link #execute()}: stdout and stderr can be consumed while the command is + * still running, and the handle exposes the eventual exit code. + * + *
{@code
+	 * try (RemoteProcess process = client.command("wevtutil qe System /f:text").start()) {
+	 * 	try (BufferedReader out = process.stdout()) {
+	 * 		out.lines().forEach(this::process);
+	 * 	}
+	 * 	int exitCode = process.waitFor();
+	 * }
+	 * }
+ *

+ * The process must be closed — use try-with-resources. It holds the client's serial + * connection until the command completes or the handle is closed; closing early terminates + * the remote command (WinRM terminate {@code Signal}). The timeout acts as an + * inactivity timeout — see {@link RemoteProcess}. File uploads and encoding detection + * run here, before the command starts. + * + * @return the running process handle, to use with try-with-resources + * @throws org.metricshub.winrm.exceptions.WinRMTimeoutException when the command startup times out + * @throws org.metricshub.winrm.exceptions.WinRMAuthenticationException when the credentials are rejected + * @throws org.metricshub.winrm.exceptions.WinRMFaultException when the remote service answers with a WSMan fault + * @throws org.metricshub.winrm.exceptions.WinRMClientException for any other failure + */ + public RemoteProcess start() { + final long start = Utils.getCurrentTimeMillis(); + final long timeoutMillis = WinRMClient.toMillis(timeout); + try { + final Prepared prepared = prepare(timeoutMillis, start); + // The full timeout, not the remaining time: for a streaming consumer it bounds each + // round trip (inactivity), not the overall exchange the preparation steps count against. + final CommandCursor cursor = client .executor() - .executeCommand( - actualCommand, - actualWorkingDirectory, - actualCharset, - TimeoutHelper.getRemainingTime(timeoutMillis, start, "No time left to execute the command") - ); - - return new CommandResult( - result.getStdout(), - result.getStderr(), - result.getStatusCode(), - Duration.ofMillis(Utils.getCurrentTimeMillis() - start) - ); + .startCommand(prepared.command, prepared.workingDirectory, timeoutMillis); + return new RemoteProcess(cursor, prepared.charset, client.hostname(), timeout); } catch (final TimeoutException e) { - throw new WinRMTimeoutException( - String.format("Command timed out after %s on %s", timeout, client.hostname()), - e - ); + throw timeoutException(e); } catch (final IOException | WqlQuerySyntaxException e) { throw new WinRMClientException(e.getMessage(), e); } catch (final WindowsRemoteException e) { throw WinRMClient.translate(e); } } + + /** The command, working directory and charset actually sent, after the preparation steps. */ + private static final class Prepared { + + final String command; + final String workingDirectory; + final Charset charset; + + Prepared(final String command, final String workingDirectory, final Charset charset) { + this.command = command; + this.workingDirectory = workingDirectory; + this.charset = charset; + } + } + + /** + * Run the preparation steps shared by {@link #execute()} and {@link #start()}: copy the local + * files to the remote host (rewriting the command line to reference the remote copies) and + * resolve the output charset. + */ + private Prepared prepare(final long timeoutMillis, final long start) + throws IOException, TimeoutException, WqlQuerySyntaxException, WindowsRemoteException { + String actualCommand = commandLine; + String actualWorkingDirectory = workingDirectory; + + if (!uploads.isEmpty()) { + // Copy the files through the command shell and rewrite the command to reference the + // remote copies; the transfer commands create the shell, so the working directory no + // longer applies (the shell already exists when the real command runs). + final List localFiles = uploads.stream().map(Path::toString).collect(Collectors.toList()); + final String updatedCommand = ShellFileCopy.copyLocalFilesToRemote( + client.executor(), + commandLine, + localFiles, + TimeoutHelper.getRemainingTime(timeoutMillis, start, "No time left to copy the local files") + ); + actualCommand = String.format("CMD.EXE /C (%s)", updatedCommand); + actualWorkingDirectory = null; + } + + final Charset actualCharset = charset != null ? charset : client.detectCharset(timeoutMillis, start); + return new Prepared(actualCommand, actualWorkingDirectory, actualCharset); + } + + /** + * Drain the streaming cursor on the worker thread {@link Utils#execute} provides, delivering + * each decoded chunk to the registered callbacks and accumulating the complete output for the + * final result. Incremental decoding with a carried-over decoder state yields exactly the text + * a whole-buffer decode would. + */ + private CommandResult drainWithCallbacks(final Prepared prepared, final long timeoutMillis, final long start) + throws Exception { + final ChunkDecoder stdoutDecoder = new ChunkDecoder(prepared.charset); + final ChunkDecoder stderrDecoder = new ChunkDecoder(prepared.charset); + final StringBuilder stdout = new StringBuilder(); + final StringBuilder stderr = new StringBuilder(); + try ( + CommandCursor cursor = client.executor() + .startCommand(prepared.command, prepared.workingDirectory, timeoutMillis)) { + CommandCursor.Chunk chunk; + while ((chunk = cursor.next()) != null) { + deliver(stdoutDecoder.decode(chunk.stdout()), stdout, stdoutConsumer); + deliver(stderrDecoder.decode(chunk.stderr()), stderr, stderrConsumer); + } + deliver(stdoutDecoder.finish(), stdout, stdoutConsumer); + deliver(stderrDecoder.finish(), stderr, stderrConsumer); + return new CommandResult( + stdout.toString(), + stderr.toString(), + cursor.exitCode(), + Duration.ofMillis(Utils.getCurrentTimeMillis() - start) + ); + } + } + + /** 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()) { + return; + } + accumulator.append(text); + if (consumer != null) { + consumer.accept(text); + } + } + + /** Unwrap a worker failure from the callback variant into the documented unchecked hierarchy. */ + private RuntimeException translateExecutionFailure(final ExecutionException e) { + final Throwable cause = e.getCause() != null ? e.getCause() : e; + if (cause instanceof TimeoutException) { + return timeoutException((TimeoutException) cause); + } + if (cause instanceof RuntimeException) { + return (RuntimeException) cause; + } + if (cause instanceof Exception) { + return WinRMClient.translate((Exception) cause); + } + return new WinRMClientException(cause.getMessage(), cause); + } + + private WinRMTimeoutException timeoutException(final TimeoutException cause) { + return new WinRMTimeoutException( + String.format("Command timed out after %s on %s", timeout, client.hostname()), + cause + ); + } } diff --git a/src/main/java/org/metricshub/winrm/CommandResult.java b/src/main/java/org/metricshub/winrm/CommandResult.java index 31ca87c..4cc0f7a 100644 --- a/src/main/java/org/metricshub/winrm/CommandResult.java +++ b/src/main/java/org/metricshub/winrm/CommandResult.java @@ -4,7 +4,7 @@ * ╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲ * WinRM Java Client * ჻჻჻჻჻჻ - * Copyright 2023 - 2026 MetricsHub + * 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. diff --git a/src/main/java/org/metricshub/winrm/RemoteProcess.java b/src/main/java/org/metricshub/winrm/RemoteProcess.java new file mode 100644 index 0000000..2f1dfee --- /dev/null +++ b/src/main/java/org/metricshub/winrm/RemoteProcess.java @@ -0,0 +1,246 @@ +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.BufferedReader; +import java.io.Reader; +import java.nio.charset.Charset; +import java.time.Duration; +import java.util.concurrent.TimeoutException; +import org.metricshub.winrm.exceptions.WinRMTimeoutException; +import org.metricshub.winrm.exceptions.WindowsRemoteException; + +/** + * A running remote command, created by {@link CommandRequest#start()} — the streaming counterpart + * of {@link CommandRequest#execute()}, shaped like {@link java.lang.Process}. Output can be + * consumed while the command is still running: + * + *

{@code
+ * try (RemoteProcess process = client.command("wevtutil qe System /f:text").start()) {
+ * 	try (BufferedReader out = process.stdout()) {
+ * 		out.lines().forEach(System.out::println);
+ * 	}
+ * 	int exitCode = process.waitFor();
+ * }
+ * }
+ *

+ * Lifecycle. The process owns the client's serial connection until the command completes or + * the process is closed: other operations on the same client block in the meantime (the same + * contract as a JDBC {@code ResultSet} on its connection). Closing before completion sends the + * WinRM terminate Signal, which stops the remote command. Always close the process — use + * try-with-resources. Closing the readers returned by {@link #stdout()}/{@link #stderr()} does + * not close the process. + *

+ * Reading. Both channels are fed by the same WSMan Receive loop: reading either channel (or + * calling {@link #waitFor()}) advances the loop, and output that arrives for the channel not being + * read is buffered in memory until it is read — so memory is bounded by the unread channel, not by + * the total output. Output is decoded incrementally with the request's charset; a multibyte + * character split across protocol chunks is decoded correctly. + *

+ * Timeout. The request timeout acts as an inactivity timeout: the longest silence + * tolerated from the server between two responses, not an overall deadline — a command may run + * (and stream) far longer than the timeout as long as it keeps producing output. Reads and waits + * 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. + *

+ * Failures during consumption are reported through the unchecked + * {@link org.metricshub.winrm.exceptions.WinRMClientException} hierarchy, including from the + * readers' {@code read()} methods. + */ +public final class RemoteProcess implements AutoCloseable { + + private final CommandCursor cursor; + private final String hostname; + private final Duration timeout; + + private final ChunkDecoder stdoutDecoder; + private final ChunkDecoder stderrDecoder; + + // Decoded output that has arrived but has not been read yet, per channel. + private final StringBuilder stdoutPending = new StringBuilder(); + private final StringBuilder stderrPending = new StringBuilder(); + + private final BufferedReader stdout; + private final BufferedReader stderr; + + private boolean finished; + private int exitCode; + + RemoteProcess(final CommandCursor cursor, final Charset charset, final String hostname, final Duration timeout) { + this.cursor = cursor; + this.hostname = hostname; + this.timeout = timeout; + this.stdoutDecoder = new ChunkDecoder(charset); + this.stderrDecoder = new ChunkDecoder(charset); + this.stdout = new BufferedReader(new ChannelReader(stdoutPending)); + this.stderr = new BufferedReader(new ChannelReader(stderrPending)); + } + + /** + * Get the standard output of the remote command, decoded incrementally: lines can be read + * while the command is still running. Always the same reader instance; closing it does not + * affect the process. + * + * @return the standard output reader + */ + public BufferedReader stdout() { + return stdout; + } + + /** + * Get the standard error of the remote command, decoded incrementally: lines can be read + * while the command is still running. Always the same reader instance; closing it does not + * affect the process. + * + * @return the standard error reader + */ + public BufferedReader stderr() { + return stderr; + } + + /** + * Wait for the command to complete, buffering any unread output in the meantime (read it + * afterward from {@link #stdout()}/{@link #stderr()}). + * + * @return the command's exit code + * @throws WinRMTimeoutException when the command stays silent for a whole inactivity timeout + * @throws org.metricshub.winrm.exceptions.WinRMClientException for any other failure + */ + public synchronized int waitFor() { + while (!finished) { + fetchOnce(); + } + return exitCode; + } + + /** + * Wait at most the given duration for the command to complete — an overall deadline, on top of + * the per-response inactivity timeout. The deadline is checked between protocol round trips, + * so the wait can overshoot by up to one inactivity timeout. Expiry does not affect the + * command: it keeps running, and the caller decides whether to keep waiting or {@link #close()}. + * + * @param deadline how long to wait (at least one millisecond) + * @return {@code true} when the command completed within the given duration — the exit code is + * then available from {@link #exitCode()} — {@code false} when the wait expired first + * @throws WinRMTimeoutException when the command stays silent for a whole inactivity timeout + * @throws org.metricshub.winrm.exceptions.WinRMClientException for any other failure + */ + public synchronized boolean waitFor(final Duration deadline) { + WinRMClient.checkPositive(deadline, "deadline"); + final long deadlineMillis = WinRMClient.toMillis(deadline); + final long start = Utils.getCurrentTimeMillis(); + while (!finished && Utils.getCurrentTimeMillis() - start < deadlineMillis) { + fetchOnce(); + } + return finished; + } + + /** + * Get the exit code of the completed command. + * + * @return the exit code + * @throws IllegalStateException when the command has not completed yet — wait for completion + * with {@link #waitFor()}, or read the output streams to their end first + */ + public synchronized int exitCode() { + if (!finished) { + throw new IllegalStateException("The command has not completed yet."); + } + return exitCode; + } + + /** + * Terminate the command (when it is still running) and release the client's connection. + * Idempotent; a no-op when the command already completed. Buffered output remains readable + * after closing. + */ + @Override + public synchronized void close() { + cursor.close(); + } + + /** One Receive round trip: decode what arrived into the per-channel buffers. Holds the monitor. */ + private void fetchOnce() { + final CommandCursor.Chunk chunk; + try { + chunk = cursor.next(); + } 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); + } + if (chunk == null) { + finished = true; + exitCode = cursor.exitCode(); + stdoutPending.append(stdoutDecoder.finish()); + stderrPending.append(stderrDecoder.finish()); + } else { + stdoutPending.append(stdoutDecoder.decode(chunk.stdout())); + stderrPending.append(stderrDecoder.decode(chunk.stderr())); + } + } + + /** 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) { + fetchOnce(); + } + if (pending.length() == 0) { + return -1; + } + final int count = Math.min(len, pending.length()); + pending.getChars(0, count, cbuf, off); + pending.delete(0, count); + return count; + } + + /** + * One output channel as a {@link Reader}. Reading drives the shared Receive loop; whatever + * arrives for the other channel in the meantime is buffered there. + */ + private final class ChannelReader extends Reader { + + private final StringBuilder pending; + + private ChannelReader(final StringBuilder pending) { + this.pending = pending; + } + + @Override + public int read(final char[] cbuf, final int off, final int len) { + if (len == 0) { + return 0; + } + return RemoteProcess.this.read(pending, cbuf, off, len); + } + + @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. + } + } +} diff --git a/src/main/java/org/metricshub/winrm/WinRMClient.java b/src/main/java/org/metricshub/winrm/WinRMClient.java index c04ea41..ee3e845 100644 --- a/src/main/java/org/metricshub/winrm/WinRMClient.java +++ b/src/main/java/org/metricshub/winrm/WinRMClient.java @@ -4,7 +4,7 @@ * ╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲ * WinRM Java Client * ჻჻჻჻჻჻ - * Copyright 2023 - 2026 MetricsHub + * 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. @@ -60,8 +60,14 @@ * } * } *

+ * Besides the blocking {@code execute()} terminals, both operations can stream: + * {@link WqlRequest#stream()} yields WQL rows lazily page by page, and + * {@link CommandRequest#start()} returns a {@link RemoteProcess} whose output is consumed while + * the command is still running. + *

* Thread-safety: a client may be shared between threads, but a WinRM connection is a serial - * channel — concurrent operations are executed one at a time. + * channel — concurrent operations are executed one at a time, and an open stream or process + * holds the connection until it is closed. *

* Failures are reported through the unchecked * {@link org.metricshub.winrm.exceptions.WinRMClientException} hierarchy; the legacy static diff --git a/src/main/java/org/metricshub/winrm/WindowsRemoteExecutor.java b/src/main/java/org/metricshub/winrm/WindowsRemoteExecutor.java index 8da56f4..32dfd34 100644 --- a/src/main/java/org/metricshub/winrm/WindowsRemoteExecutor.java +++ b/src/main/java/org/metricshub/winrm/WindowsRemoteExecutor.java @@ -86,6 +86,66 @@ default List> executeWql( ); } + /** + *

+ * Start a WQL enumeration and return a lazy {@link WqlCursor} over its rows: rows can be + * consumed as the WS-Enumeration pages arrive, and memory stays bounded by one page. + *

+ *

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

+ * + * @param namespace the WMI namespace to query, e.g. {@code ROOT\CIMV2} (required) + * @param wqlQuery the WQL query (required) + * @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 maxElements maximum number of rows per Enumerate/Pull response (throws an + * IllegalArgumentException if negative or zero); see {@link #DEFAULT_WQL_MAX_ELEMENTS} + * @param pullTimeout maximum time in milliseconds the server may hold a single Pull open before + * answering with the rows it has ({@code MaxTime}); 0 leaves it to the server default + * @return a cursor over the result rows, owning the executor's connection until exhausted or + * closed — always close it (try-with-resources) + * @throws TimeoutException when the server does not answer the initial Enumerate in time + * @throws WqlQuerySyntaxException if WQL query syntax is invalid + * @throws WindowsRemoteException For any problem encountered + */ + default WqlCursor streamWql( + final String namespace, + final String wqlQuery, + final long timeout, + final int maxElements, + final long pullTimeout + ) throws TimeoutException, WqlQuerySyntaxException, WindowsRemoteException { + throw new UnsupportedOperationException(getClass().getName() + " does not support streaming WQL enumeration."); + } + + /** + *

+ * Start a command on the remote host and return a {@link CommandCursor} over its raw output: + * chunks can be consumed as the WSMan Receive responses arrive, before the command exits. + *

+ *

+ * 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) + * @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) + throws TimeoutException, WindowsRemoteException { + throw new UnsupportedOperationException(getClass().getName() + " does not support streaming command execution."); + } + /** * Execute the command on the remote * diff --git a/src/main/java/org/metricshub/winrm/WqlCursor.java b/src/main/java/org/metricshub/winrm/WqlCursor.java new file mode 100644 index 0000000..c8936d3 --- /dev/null +++ b/src/main/java/org/metricshub/winrm/WqlCursor.java @@ -0,0 +1,62 @@ +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.util.Map; +import java.util.concurrent.TimeoutException; +import org.metricshub.winrm.exceptions.WindowsRemoteException; + +/** + * A lazily-advancing cursor over the rows of a WQL enumeration, returned by + * {@link WindowsRemoteExecutor#streamWql(String, String, long, int, long)}. Rows are parsed and + * served page by page: advancing past the current WS-Enumeration page issues the next Pull + * request, so memory stays bounded by one page rather than the whole result set. + *

+ * The cursor owns the executor's serial connection until it is exhausted or closed: no other + * operation can run on the same executor while the cursor is open (the same contract as a JDBC + * {@code ResultSet} on its connection). Exhaustion releases the connection on its own; closing + * before the end additionally sends a WS-Enumeration Release so the server frees the enumeration + * context immediately. 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 WqlCursor extends AutoCloseable { + /** + * Advance to the next row, issuing the next WS-Enumeration Pull when the current page is + * exhausted. + * + * @return the next row as an ordered property map, or {@code null} once the enumeration is + * exhausted + * @throws TimeoutException when the server stays silent for a whole per-round-trip timeout + * (the inactivity timeout of the stream) + * @throws WindowsRemoteException for any other failure while pulling + */ + Map next() throws TimeoutException, WindowsRemoteException; + + /** + * Release the enumeration and the executor's connection. When the enumeration is not + * exhausted, a best-effort WS-Enumeration Release tells the server to free the enumeration + * context. Idempotent, and never throws: releasing the context is a courtesy the server can + * also handle on its own timeout. + */ + @Override + void close(); +} diff --git a/src/main/java/org/metricshub/winrm/WqlRequest.java b/src/main/java/org/metricshub/winrm/WqlRequest.java index 3dd52e5..0d35ebe 100644 --- a/src/main/java/org/metricshub/winrm/WqlRequest.java +++ b/src/main/java/org/metricshub/winrm/WqlRequest.java @@ -4,7 +4,7 @@ * ╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲ * WinRM Java Client * ჻჻჻჻჻჻ - * Copyright 2023 - 2026 MetricsHub + * 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. @@ -23,8 +23,13 @@ import java.time.Duration; import java.util.List; import java.util.Map; +import java.util.Spliterator; +import java.util.Spliterators; import java.util.concurrent.TimeoutException; +import java.util.function.Consumer; import java.util.stream.Collectors; +import java.util.stream.Stream; +import java.util.stream.StreamSupport; import org.metricshub.winrm.exceptions.WinRMTimeoutException; import org.metricshub.winrm.exceptions.WindowsRemoteException; import org.metricshub.winrm.exceptions.WqlQuerySyntaxException; @@ -33,7 +38,7 @@ /** * A WQL query being prepared for execution, created by {@link WinRMClient#wql(String)}. * Every option has a sensible default; {@link #execute()} runs the query and returns the - * complete result. + * complete result, {@link #stream()} yields the rows lazily as they arrive. */ public final class WqlRequest { @@ -72,8 +77,10 @@ public WqlRequest namespace(final String namespace) { } /** - * Set the timeout of this query — a wall-clock deadline covering every WSMan round trip and - * result collection. Default: the client's timeout. + * Set the timeout of this query. For {@link #execute()} it is a wall-clock deadline covering + * every WSMan round trip and result collection; for {@link #stream()} it is an + * inactivity timeout — the longest silence tolerated from the server between two + * responses, with no overall deadline. Default: the client's timeout. * * @param timeout the timeout (at least one millisecond) * @return this request @@ -144,4 +151,79 @@ public WqlResult execute() { throw WinRMClient.translate(e); } } + + /** + * Execute the query and stream the rows lazily: each row is yielded as soon as it is parsed, + * and the next WS-Enumeration page is pulled from the server only as the stream advances — + * memory stays bounded by one page ({@link #pageSize(int)}) instead of the whole result set. + * + *

{@code
+	 * try (Stream rows = client.wql("SELECT * FROM Win32_NTLogEvent").stream()) {
+	 * 	rows.filter(row -> "Error".equals(row.string("Type"))).limit(100).forEach(this::process);
+	 * }
+	 * }
+ *

+ * The stream must be closed (same contract as {@link java.nio.file.Files#lines}) — use + * try-with-resources. It holds the client's serial connection while open: other operations on + * the same client block until it is closed or exhausted. Closing before the last row tells the + * server to release the enumeration immediately (WS-Enumeration {@code Release}). + *

+ * The timeout acts as an inactivity timeout — the longest silence tolerated from the + * server between two responses — not an overall deadline: consuming a large result can take + * arbitrarily long as long as the server keeps answering. The initial request is sent here; + * later pages are fetched during consumption, so the exceptions below can also be thrown from + * the stream's operations while iterating. + * + * @return a lazy, sequential stream of rows, to use with try-with-resources + * @throws WqlSyntaxException when the WQL query is invalid + * @throws org.metricshub.winrm.exceptions.WinRMTimeoutException when the inactivity timeout + * elapses + * @throws org.metricshub.winrm.exceptions.WinRMAuthenticationException when the credentials are rejected + * @throws org.metricshub.winrm.exceptions.WinRMFaultException when the remote service answers with a WSMan fault + * @throws org.metricshub.winrm.exceptions.WinRMClientException for any other failure + */ + public Stream stream() { + final long timeoutMillis = WinRMClient.toMillis(timeout); + final long pullTimeoutMillis = pullTimeout != null ? WinRMClient.toMillis(pullTimeout) : 0; + final WqlCursor cursor; + try { + cursor = client.executor().streamWql(namespace, query, timeoutMillis, pageSize, pullTimeoutMillis); + } catch (final TimeoutException e) { + throw timeoutException(e); + } catch (final WqlQuerySyntaxException e) { + throw new WqlSyntaxException(e.getMessage(), e); + } catch (final WindowsRemoteException e) { + throw WinRMClient.translate(e); + } + + final Spliterator spliterator = new Spliterators.AbstractSpliterator( + Long.MAX_VALUE, + Spliterator.ORDERED | Spliterator.NONNULL | Spliterator.IMMUTABLE + ) { + @Override + public boolean tryAdvance(final Consumer action) { + final Map row; + try { + row = cursor.next(); + } catch (final TimeoutException e) { + throw timeoutException(e); + } catch (final WindowsRemoteException e) { + throw WinRMClient.translate(e); + } + if (row == null) { + return false; + } + action.accept(new WqlRow(row)); + return true; + } + }; + return StreamSupport.stream(spliterator, false).onClose(cursor::close); + } + + private WinRMTimeoutException timeoutException(final TimeoutException cause) { + return new WinRMTimeoutException( + String.format("WQL query timed out after %s on %s", timeout, client.hostname()), + cause + ); + } } diff --git a/src/main/java/org/metricshub/winrm/WqlResult.java b/src/main/java/org/metricshub/winrm/WqlResult.java index e02787c..bf001a7 100644 --- a/src/main/java/org/metricshub/winrm/WqlResult.java +++ b/src/main/java/org/metricshub/winrm/WqlResult.java @@ -4,7 +4,7 @@ * ╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲ * WinRM Java Client * ჻჻჻჻჻჻ - * Copyright 2023 - 2026 MetricsHub + * 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. diff --git a/src/main/java/org/metricshub/winrm/WqlRow.java b/src/main/java/org/metricshub/winrm/WqlRow.java index 1582114..db24afb 100644 --- a/src/main/java/org/metricshub/winrm/WqlRow.java +++ b/src/main/java/org/metricshub/winrm/WqlRow.java @@ -4,7 +4,7 @@ * ╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲ * WinRM Java Client * ჻჻჻჻჻჻ - * Copyright 2023 - 2026 MetricsHub + * 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. diff --git a/src/main/java/org/metricshub/winrm/exceptions/WinRMAuthenticationException.java b/src/main/java/org/metricshub/winrm/exceptions/WinRMAuthenticationException.java index 4bdaf1b..d155cdd 100644 --- a/src/main/java/org/metricshub/winrm/exceptions/WinRMAuthenticationException.java +++ b/src/main/java/org/metricshub/winrm/exceptions/WinRMAuthenticationException.java @@ -4,7 +4,7 @@ * ╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲ * WinRM Java Client * ჻჻჻჻჻჻ - * Copyright 2023 - 2026 MetricsHub + * 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. diff --git a/src/main/java/org/metricshub/winrm/exceptions/WinRMClientException.java b/src/main/java/org/metricshub/winrm/exceptions/WinRMClientException.java index dbc2115..2758de3 100644 --- a/src/main/java/org/metricshub/winrm/exceptions/WinRMClientException.java +++ b/src/main/java/org/metricshub/winrm/exceptions/WinRMClientException.java @@ -4,7 +4,7 @@ * ╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲ * WinRM Java Client * ჻჻჻჻჻჻ - * Copyright 2023 - 2026 MetricsHub + * 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. diff --git a/src/main/java/org/metricshub/winrm/exceptions/WinRMFaultException.java b/src/main/java/org/metricshub/winrm/exceptions/WinRMFaultException.java index 45d393e..e72a00b 100644 --- a/src/main/java/org/metricshub/winrm/exceptions/WinRMFaultException.java +++ b/src/main/java/org/metricshub/winrm/exceptions/WinRMFaultException.java @@ -4,7 +4,7 @@ * ╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲ * WinRM Java Client * ჻჻჻჻჻჻ - * Copyright 2023 - 2026 MetricsHub + * 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. diff --git a/src/main/java/org/metricshub/winrm/exceptions/WinRMTimeoutException.java b/src/main/java/org/metricshub/winrm/exceptions/WinRMTimeoutException.java index 6656d73..357de45 100644 --- a/src/main/java/org/metricshub/winrm/exceptions/WinRMTimeoutException.java +++ b/src/main/java/org/metricshub/winrm/exceptions/WinRMTimeoutException.java @@ -4,7 +4,7 @@ * ╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲ * WinRM Java Client * ჻჻჻჻჻჻ - * Copyright 2023 - 2026 MetricsHub + * 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. diff --git a/src/main/java/org/metricshub/winrm/exceptions/WqlSyntaxException.java b/src/main/java/org/metricshub/winrm/exceptions/WqlSyntaxException.java index 781bf6c..da1c84d 100644 --- a/src/main/java/org/metricshub/winrm/exceptions/WqlSyntaxException.java +++ b/src/main/java/org/metricshub/winrm/exceptions/WqlSyntaxException.java @@ -4,7 +4,7 @@ * ╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲ * WinRM Java Client * ჻჻჻჻჻჻ - * Copyright 2023 - 2026 MetricsHub + * 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. diff --git a/src/main/java/org/metricshub/winrm/light/Envelopes.java b/src/main/java/org/metricshub/winrm/light/Envelopes.java index 8332c4b..11a650f 100644 --- a/src/main/java/org/metricshub/winrm/light/Envelopes.java +++ b/src/main/java/org/metricshub/winrm/light/Envelopes.java @@ -28,7 +28,7 @@ /** * WS-Management SOAP envelope templates — the only "WSDL" the light client needs. - * Covers Identify, WQL Enumerate/Pull, and the command shell lifecycle + * Covers Identify, WQL enumeration (Enumerate / Pull / Release), and the command shell lifecycle * (Create / Command / Receive / Signal / Delete). */ final class Envelopes { @@ -42,6 +42,7 @@ final class Envelopes { private static final String ACTION_ENUMERATE = "http://schemas.xmlsoap.org/ws/2004/09/enumeration/Enumerate"; private static final String ACTION_PULL = "http://schemas.xmlsoap.org/ws/2004/09/enumeration/Pull"; + private static final String ACTION_RELEASE = "http://schemas.xmlsoap.org/ws/2004/09/enumeration/Release"; private static final String ACTION_CREATE = "http://schemas.xmlsoap.org/ws/2004/09/transfer/Create"; private static final String ACTION_DELETE = "http://schemas.xmlsoap.org/ws/2004/09/transfer/Delete"; private static final String ACTION_COMMAND = RSP + "/Command"; @@ -102,6 +103,19 @@ static String pull( ""; } + static String release(final String url, final String namespace, final String context, final long timeoutMs) { + // WS-Enumeration Release: tells the server to discard an enumeration context that will not be + // pulled to its end, freeing the server-side operation slot immediately instead of waiting for + // its idle timeout. + return envelopeOpen(false) + + header(url, wmiResourceUri(namespace), ACTION_RELEASE, timeoutMs, null, null) + + "" + + "" + + escape(context) + + "" + + ""; + } + // --- Command shell ----------------------------------------------------- static String createShell(final String url, final String workingDirectory, final long timeoutMs) { diff --git a/src/main/java/org/metricshub/winrm/light/LightWinRMService.java b/src/main/java/org/metricshub/winrm/light/LightWinRMService.java index 2bc7fb0..a47daa4 100644 --- a/src/main/java/org/metricshub/winrm/light/LightWinRMService.java +++ b/src/main/java/org/metricshub/winrm/light/LightWinRMService.java @@ -31,11 +31,14 @@ import java.util.concurrent.atomic.AtomicBoolean; import javax.net.ssl.SSLContext; import javax.net.ssl.SSLSocketFactory; +import org.metricshub.winrm.CommandCursor; import org.metricshub.winrm.Utils; import org.metricshub.winrm.WinRMHttpProtocolEnum; import org.metricshub.winrm.WindowsRemoteCommandResult; import org.metricshub.winrm.WindowsRemoteExecutor; import org.metricshub.winrm.WmiHelper; +import org.metricshub.winrm.WqlCursor; +import org.metricshub.winrm.exceptions.WinRMClientException; import org.metricshub.winrm.exceptions.WinRMException; import org.metricshub.winrm.exceptions.WindowsRemoteException; import org.metricshub.winrm.exceptions.WqlQuerySyntaxException; @@ -214,17 +217,7 @@ public List> executeWql( final int maxElements, final long pullTimeout ) throws TimeoutException, WqlQuerySyntaxException, WindowsRemoteException { - checkNotClosed(); - Utils.checkNonNull(namespace, "namespace"); - Utils.checkNonNull(wqlQuery, "wqlQuery"); - if (!WmiHelper.isValidWql(wqlQuery)) { - throw new WqlQuerySyntaxException(wqlQuery); - } - Utils.checkArgumentNotZeroOrNegative(timeout, "timeout"); - Utils.checkArgumentNotZeroOrNegative(maxElements, "maxElements"); - if (pullTimeout < 0) { - throw new IllegalArgumentException("pullTimeout must not be negative."); - } + checkWqlArguments(namespace, wqlQuery, timeout, maxElements, pullTimeout); // Enforce the caller's timeout as a wall-clock deadline (throwing TimeoutException), matching // the CXF WinRMService and bounding the WSMan Pull loop. @@ -241,6 +234,124 @@ public List> executeWql( ); } + @Override + public WqlCursor streamWql( + final String namespace, + final String wqlQuery, + final long timeout, + final int maxElements, + final long pullTimeout + ) throws TimeoutException, WqlQuerySyntaxException, WindowsRemoteException { + checkWqlArguments(namespace, wqlQuery, timeout, maxElements, pullTimeout); + + // The initial Enumerate is sent here, on the caller's thread, so configuration and + // authentication failures surface immediately rather than on the first row. + final WsmanClient.WqlEnumeration enumeration = callStreaming( + () -> client.openWql(namespace, wqlQuery, timeout, maxElements, pullTimeout, true) + ); + return new WqlCursor() { + @Override + public Map next() throws TimeoutException, WindowsRemoteException { + final Map row = callStreaming(enumeration::next); + return row == null ? null : new LinkedHashMap<>(row); + } + + @Override + public void close() { + enumeration.close(); + } + }; + } + + /** Validate the arguments shared by the blocking and streaming WQL entry points. */ + private void checkWqlArguments( + final String namespace, + final String wqlQuery, + final long timeout, + final int maxElements, + final long pullTimeout + ) throws WqlQuerySyntaxException { + checkNotClosed(); + Utils.checkNonNull(namespace, "namespace"); + Utils.checkNonNull(wqlQuery, "wqlQuery"); + if (!WmiHelper.isValidWql(wqlQuery)) { + throw new WqlQuerySyntaxException(wqlQuery); + } + Utils.checkArgumentNotZeroOrNegative(timeout, "timeout"); + Utils.checkArgumentNotZeroOrNegative(maxElements, "maxElements"); + if (pullTimeout < 0) { + throw new IllegalArgumentException("pullTimeout must not be negative."); + } + } + + @Override + public CommandCursor startCommand(final String command, final String workingDirectory, final long timeout) + throws TimeoutException, WindowsRemoteException { + checkNotClosed(); + Utils.checkNonNull(command, "command"); + Utils.checkArgumentNotZeroOrNegative(timeout, "timeout"); + + // 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) + ); + return new CommandCursor() { + @Override + public Chunk next() throws TimeoutException, WindowsRemoteException { + final WsmanClient.RemoteCommand.Chunk chunk = callStreaming(remoteCommand::nextChunk); + return chunk == null ? null : new Chunk(chunk.stdout, chunk.stderr); + } + + @Override + public int exitCode() { + return remoteCommand.exitCode(); + } + + @Override + public void close() { + try { + remoteCommand.close(); + } catch (final RuntimeException e) { + // Typed protocol failures (e.g. a fault answering the terminate Signal) pass through. + throw e; + } catch (final InterruptedException e) { + // Closing on an already-cancelled thread: restore the flag, the connection permit + // has been released and the transport is torn down with the executor. + Thread.currentThread().interrupt(); + } catch (final Exception e) { + throw new WinRMClientException(e.getMessage(), e); + } + } + }; + } + + /** + * Run one streaming protocol step on the caller's thread, translating the raw client failures + * the way the blocking operations do. Unlike the blocking operations there is no worker thread + * and no wall-clock deadline: each round trip is bounded by the operation timeout (the WSMan + * OperationTimeout header and the socket read timeout), which acts as the inactivity timeout + * of the stream and surfaces as the {@link TimeoutException} this method lets through. + * + * @param step the protocol step to run + * @param the step's result type + * @return the step's result + * @throws TimeoutException when the step exceeds the inactivity timeout + * @throws WinRMException when the step fails with a checked failure + */ + private static T callStreaming(final Callable step) throws TimeoutException, WinRMException { + try { + return step.call(); + } catch (final TimeoutException | RuntimeException e) { + throw e; + } catch (final InterruptedException e) { + Thread.currentThread().interrupt(); + throw new WinRMException(e); + } catch (final Exception e) { + throw new WinRMException(e, e.getMessage()); + } + } + @Override public WindowsRemoteCommandResult executeCommand( 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 b2d4ac2..5265054 100644 --- a/src/main/java/org/metricshub/winrm/light/WsmanClient.java +++ b/src/main/java/org/metricshub/winrm/light/WsmanClient.java @@ -22,6 +22,7 @@ import java.io.ByteArrayInputStream; import java.io.ByteArrayOutputStream; +import java.net.SocketTimeoutException; import java.nio.charset.Charset; import java.nio.charset.StandardCharsets; import java.util.ArrayList; @@ -29,7 +30,8 @@ import java.util.LinkedHashMap; import java.util.List; import java.util.Map; -import java.util.concurrent.locks.ReentrantLock; +import java.util.concurrent.Semaphore; +import java.util.concurrent.TimeoutException; import javax.net.ssl.SSLSocketFactory; import javax.xml.XMLConstants; import javax.xml.parsers.DocumentBuilder; @@ -74,30 +76,34 @@ final class WsmanClient implements AutoCloseable { // The shell's working directory is pinned by the FIRST command on this connection and reused // whenever the shell must be (re)created — e.g. after the server reaped it — so a recreation // stays invisible to the caller instead of silently moving later commands to the default - // directory. Guarded by operationLock, like shellId. + // directory. Guarded by connectionPermit, like shellId. private String shellWorkingDirectory; private boolean shellWorkingDirectoryPinned; // A single NTLM connection is a serial channel: one socket, stateful RC4 ciphers with sequence // numbers, and a single shellId. Concurrent callers (e.g. one executor shared across // threads) MUST NOT interleave, or they read each other's responses and desync the cipher streams. - // Every high-level operation (wql/executeCommand) runs while holding this lock; close() only - // tries it, so it can still hard-close the transport to unblock an abandoned, timed-out worker. - private final ReentrantLock operationLock = new ReentrantLock(); + // Every high-level operation (wql/executeCommand) and every open streaming handle + // (WqlEnumeration/RemoteCommand) runs while holding this single permit; close() only tries it, + // so it can still hard-close the transport to unblock an abandoned, timed-out worker. A + // Semaphore rather than a ReentrantLock because a streaming handle may legitimately be advanced + // and closed by a different thread than the one that opened it (a lock could then not be + // released at all — unlock is owner-only). + private final Semaphore connectionPermit = new Semaphore(1); /** - * Acquire {@link #operationLock}, aborting when this task has been cancelled. A caller's + * Acquire {@link #connectionPermit}, aborting when this task has been cancelled. A caller's * wall-clock timeout can fire while its operation is still QUEUED behind another one on this * serial connection; the timeout path then cancels (interrupts) the worker thread, which must - * NOT go on to acquire the lock and execute the operation the caller was already told timed + * NOT go on to acquire the permit and execute the operation the caller was already told timed * out — a command would run its side effects after the failure was reported. Interruption * while waiting aborts the acquisition; an interrupt that arrived just before or during the * acquisition is detected right after it, before anything is sent. */ private void lockAbortably() throws InterruptedException { - operationLock.lockInterruptibly(); + connectionPermit.acquire(); if (Thread.interrupted()) { - operationLock.unlock(); + connectionPermit.release(); throw new InterruptedException("Operation abandoned: cancelled while waiting for the connection."); } } @@ -155,7 +161,8 @@ private static final class Decoded { } /** - * Run a WQL query and return the rows as ordered property maps. + * Run a WQL query and return the rows as ordered property maps. Implemented as "drain the + * stream" over {@link #openWql} so the blocking and streaming paths cannot drift apart. * * @param namespace the WMI namespace * @param query the WQL query @@ -171,33 +178,201 @@ List> wql( final int maxElements, final long maxTimeMs ) throws Exception { - // Serialize the whole enumeration (Enumerate + all Pulls) against any other operation sharing - // this connection; see operationLock. + final List> rows = new ArrayList<>(); + try (WqlEnumeration enumeration = openWql(namespace, query, operationTimeoutMs, maxElements, maxTimeMs, false)) { + Map row; + while ((row = enumeration.next()) != null) { + rows.add(row); + } + } + return rows; + } + + /** + * Start a WQL enumeration and return a lazy handle over its rows. The handle owns the + * connection (see {@link #connectionPermit}) until it is exhausted or closed: no other + * operation can run on this client while it is open. + * + * @param namespace the WMI namespace + * @param query the WQL query + * @param operationTimeoutMs each WSMan round trip's timeout, driving the OperationTimeout + * header and the socket read timeout — for a streaming consumer this is the inactivity + * timeout: the longest silence tolerated between two responses + * @param maxElements the WS-Enumeration MaxElements batch size for Enumerate and every Pull + * @param maxTimeMs the WS-Enumeration MaxTime for each Pull in milliseconds; 0 omits the element + * @param failOnQuietTimeout streaming mode: convert a server "no result yet" operation-timeout + * fault or a socket read timeout on Pull into a {@link TimeoutException} instead of + * letting the raw fault/IO failure surface (the blocking path is bounded by the caller's + * wall-clock deadline instead) + */ + WqlEnumeration openWql( + final String namespace, + final String query, + final long operationTimeoutMs, + final int maxElements, + final long maxTimeMs, + final boolean failOnQuietTimeout + ) throws Exception { + // Serialize the whole enumeration (Enumerate + all Pulls + Release) against any other + // operation sharing this connection; see connectionPermit. lockAbortably(); + boolean opened = false; try { transport.operationTimeout(toSocketTimeoutMillis(operationTimeoutMs)); // WMI namespaces are case-insensitive, but preserve the caller's case to match the CXF backend. final String ns = namespace.replace('\\', '/'); - final List> rows = new ArrayList<>(); + final WqlEnumeration enumeration = new WqlEnumeration( + ns, + operationTimeoutMs, + maxElements, + maxTimeMs, + failOnQuietTimeout + ); + enumeration.ingest( + expectOk(Envelopes.enumerateWql(url, ns, query, operationTimeoutMs, maxElements), "Enumerate") + ); + opened = true; + return enumeration; + } finally { + if (!opened) { + connectionPermit.release(); + } + } + } + + /** + * A lazily-advancing WQL enumeration: rows are served from the current WS-Enumeration page and + * the next Pull is issued only when the page runs out, so memory stays bounded by one page. + * Holds {@link #connectionPermit} from creation until exhaustion or {@link #close()}; closing + * before the end sends a WS-Enumeration Release so the server frees the enumeration context. + */ + final class WqlEnumeration implements AutoCloseable { + + private final String namespace; + private final long operationTimeoutMs; + private final int maxElements; + private final long maxTimeMs; + private final boolean failOnQuietTimeout; + + // The current page only: previous pages (rows and DOM) are unreachable once served. + private List> page = new ArrayList<>(); + private int cursor; + private String context; + private boolean endOfSequence; + private boolean finished; + + // Set when an advance failed: the connection state is then unknown (a fault, a half-read + // response, a cancellation), so close() must not push a Release into it — it releases the + // permit only, exactly like the pre-streaming code did on its error paths. + private boolean broken; + + private WqlEnumeration( + final String namespace, + final long operationTimeoutMs, + final int maxElements, + final long maxTimeMs, + final boolean failOnQuietTimeout + ) { + this.namespace = namespace; + this.operationTimeoutMs = operationTimeoutMs; + this.maxElements = maxElements; + this.maxTimeMs = maxTimeMs; + this.failOnQuietTimeout = failOnQuietTimeout; + } + + /** Absorb one Enumerate/Pull response: its rows become the current page. */ + private void ingest(final Document doc) { + page = new ArrayList<>(); + cursor = 0; + collectItems(doc, page); + endOfSequence = hasEnumerationElement(doc, "EndOfSequence"); + // Pull only while the server hands back a context (matching the CXF backend). + context = endOfSequence ? null : textNS(doc, WS_ENUMERATION_NS, "EnumerationContext"); + if (context == null || context.isEmpty()) { + endOfSequence = true; + } + } - Document doc = expectOk(Envelopes.enumerateWql(url, ns, query, operationTimeoutMs, maxElements), "Enumerate"); - collectItems(doc, rows); + /** + * The next row, or {@code null} once the enumeration is exhausted. Exhaustion releases the + * connection immediately (no Release is needed — the server discarded the context when it + * sent EndOfSequence), so a fully-consumed enumeration does not depend on {@link #close()}. + */ + Map next() throws Exception { + try { + return advance(); + } catch (final Exception e) { + broken = true; + throw e; + } + } - // Pull until the server signals EndOfSequence (matching the CXF backend). The aggregate - // timeout in LightWinRMService bounds a misbehaving server that never ends the sequence. - boolean endOfSequence = hasEnumerationElement(doc, "EndOfSequence"); - String context = endOfSequence ? null : textNS(doc, WS_ENUMERATION_NS, "EnumerationContext"); - while (!endOfSequence && context != null && !context.isEmpty()) { + private Map advance() throws Exception { + if (finished) { + return null; + } + while (cursor >= page.size()) { + if (endOfSequence) { + finished = true; + connectionPermit.release(); + return null; + } // Stop pulling once the caller has been told the operation timed out. checkNotCancelled(); - doc = expectOk(Envelopes.pull(url, ns, context, operationTimeoutMs, maxElements, maxTimeMs), "Pull"); - collectItems(doc, rows); - endOfSequence = hasEnumerationElement(doc, "EndOfSequence"); - context = endOfSequence ? null : textNS(doc, WS_ENUMERATION_NS, "EnumerationContext"); + final Decoded resp; + try { + resp = request(Envelopes.pull(url, namespace, context, operationTimeoutMs, maxElements, maxTimeMs)); + } catch (final SocketTimeoutException e) { + if (failOnQuietTimeout) { + throw inactivityTimeout("No response from the WinRM service", e); + } + throw e; + } + if (resp.status != 200) { + if (failOnQuietTimeout && FAULT_OPERATION_TIMEOUT.equals(wsmanFaultCode(resp.document))) { + // The server had no rows to hand out for a whole OperationTimeout: that IS the + // streaming inactivity timeout. + throw inactivityTimeout("The WQL enumeration produced no rows", null); + } + throw faultException("Pull", resp); + } + ingest(resp.document); + } + return page.get(cursor++); + } + + private TimeoutException inactivityTimeout(final String what, final Throwable cause) { + final TimeoutException timeout = new TimeoutException( + what + " within the " + operationTimeoutMs + " ms timeout." + ); + if (cause != null) { + timeout.initCause(cause); + } + return timeout; + } + + /** + * Release the enumeration: when the server still holds an enumeration context, a + * best-effort WS-Enumeration Release lets it free the context (and the operation slot it + * counts against server-side quotas) immediately. Always releases the connection; idempotent. + */ + @Override + public void close() { + if (finished) { + return; + } + finished = true; + try { + if (!broken && !endOfSequence && context != null && !context.isEmpty()) { + try { + request(Envelopes.release(url, namespace, context, operationTimeoutMs)); + } catch (final Exception ignored) { + // Best-effort cleanup: the server reaps an unreleased context on its own timeout. + } + } + } finally { + connectionPermit.release(); } - return rows; - } finally { - operationLock.unlock(); } } @@ -216,7 +391,12 @@ static final class CommandOutput { } /** - * Execute a command in the remote command shell, creating the shell on first use. + * Execute a command in the remote command shell, creating the shell on first use. Implemented + * as "drain the stream" over {@link #startCommand} so the blocking and streaming paths cannot + * drift apart: the raw stream BYTES are accumulated and decoded once at the end, because a + * multibyte character (e.g. UTF-8) can be split across Stream elements or Receive responses, + * and decoding each chunk independently would corrupt the boundary bytes into replacement + * characters. * * @param commandLine the command line to run * @param workingDirectory working directory of the shell (only honored when the shell is created) @@ -229,10 +409,50 @@ CommandOutput executeCommand( final String workingDirectory, final Charset charset, final long operationTimeoutMs + ) throws Exception { + final Charset cs = charset != null ? charset : StandardCharsets.UTF_8; + final ByteArrayOutputStream stdout = new ByteArrayOutputStream(); + final ByteArrayOutputStream stderr = new ByteArrayOutputStream(); + try (RemoteCommand command = startCommand(commandLine, workingDirectory, operationTimeoutMs, false)) { + RemoteCommand.Chunk chunk; + while ((chunk = command.nextChunk()) != null) { + stdout.write(chunk.stdout, 0, chunk.stdout.length); + stderr.write(chunk.stderr, 0, chunk.stderr.length); + } + return new CommandOutput( + new String(stdout.toByteArray(), cs), + new String(stderr.toByteArray(), cs), + command.exitCode() + ); + } + } + + /** + * Start a command in the remote command shell (creating the shell on first use) and return a + * handle over its raw output chunks. The handle owns the connection (see + * {@link #connectionPermit}) until the command completes or the handle is closed: no other + * operation can run on this client while it is open. + * + * @param commandLine the command line to run + * @param workingDirectory working directory of the shell (only honored when the shell is created) + * @param operationTimeoutMs each WSMan round trip's timeout, driving the OperationTimeout + * header and the socket read timeout — for a streaming consumer this is the inactivity + * timeout: the longest silence tolerated between two responses + * @param failOnQuietTimeout streaming mode: convert a server "no output yet" operation-timeout + * 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) + */ + RemoteCommand startCommand( + final String commandLine, + final String workingDirectory, + final long operationTimeoutMs, + final boolean failOnQuietTimeout ) throws Exception { // Serialize the whole shell lifecycle (Create + Command + Receive loop + Signal) against any - // other operation sharing this connection and the shellId field; see operationLock. + // other operation sharing this connection and the shellId field; see connectionPermit. lockAbortably(); + boolean opened = false; try { transport.operationTimeout(toSocketTimeoutMillis(operationTimeoutMs)); if (!shellWorkingDirectoryPinned) { @@ -242,13 +462,12 @@ CommandOutput executeCommand( if (shellId == null) { createShell(shellWorkingDirectory, operationTimeoutMs); } - final Charset cs = charset != null ? charset : StandardCharsets.UTF_8; // The caller's timeout may have fired while the Create response was being awaited (socket // reads do not observe interrupts): never START the command after the reported timeout. checkNotCancelled(); String commandId; try { - commandId = startCommand(commandLine, operationTimeoutMs); + commandId = sendCommand(commandLine, operationTimeoutMs); } catch (final WinRMFaultException e) { if (!FAULT_SHELL_NOT_FOUND.equals(e.getFaultCode())) { throw e; @@ -259,15 +478,139 @@ CommandOutput executeCommand( shellId = null; createShell(shellWorkingDirectory, operationTimeoutMs); checkNotCancelled(); - commandId = startCommand(commandLine, operationTimeoutMs); + commandId = sendCommand(commandLine, operationTimeoutMs); + } + opened = true; + return new RemoteCommand(commandId, operationTimeoutMs, failOnQuietTimeout); + } finally { + if (!opened) { + connectionPermit.release(); + } + } + } + + /** + * A running remote command: each {@link #nextChunk()} is one WSMan Receive round trip yielding + * the raw output bytes as the server handed them out, so memory stays bounded by one response. + * Holds {@link #connectionPermit} from creation until completion or {@link #close()}; both + * paths send the terminate Signal, exactly like the pre-streaming receive loop did — Signal + * after completion is part of the shell protocol, and Signal on early close is what actually + * stops the remote command. + */ + final class RemoteCommand implements AutoCloseable { + + /** One Receive response's worth of raw output bytes, split by stream. */ + final class Chunk { + + final byte[] stdout; + final byte[] stderr; + + Chunk(final byte[] stdout, final byte[] stderr) { + this.stdout = stdout; + this.stderr = stderr; + } + } + + private final String commandId; + private final long operationTimeoutMs; + private final boolean failOnQuietTimeout; + private Integer exitCode; + private boolean finished; + + private RemoteCommand(final String commandId, final long operationTimeoutMs, final boolean failOnQuietTimeout) { + this.commandId = commandId; + this.operationTimeoutMs = operationTimeoutMs; + this.failOnQuietTimeout = failOnQuietTimeout; + } + + /** + * The next chunk of raw output — one Receive round trip, possibly empty — or {@code null} + * once the command has completed. The {@code null} return has already sent the terminate + * Signal and released the connection, so a fully-drained command does not depend on + * {@link #close()}; the exit code is then available from {@link #exitCode()}. + */ + Chunk nextChunk() throws Exception { + if (exitCode != null) { + // The command completed with the previous chunk: Signal it and release the connection. + finish(); + return null; + } + final Decoded resp = receiveOutput(); + final ByteArrayOutputStream stdout = new ByteArrayOutputStream(); + final ByteArrayOutputStream stderr = new ByteArrayOutputStream(); + collectStreams(resp.document, stdout, stderr); + exitCode = doneExitCode(resp.document); + return new Chunk(stdout.toByteArray(), stderr.toByteArray()); + } + + /** Issue Receive until a usable response arrives, honoring the timeout mode. */ + private Decoded receiveOutput() throws Exception { + while (true) { + // A late non-final response (or an op-timeout fault) must not keep an abandoned worker + // re-issuing Receive — and holding the serial connection — until the remote command ends. + // Aborting here still sends the Signal (via close), which terminates the remote command. + checkNotCancelled(); + final Decoded resp; + try { + resp = request(Envelopes.receive(url, shellId, commandId, operationTimeoutMs)); + } catch (final SocketTimeoutException e) { + if (failOnQuietTimeout) { + final TimeoutException timeout = new TimeoutException( + "No response from the WinRM service within the " + operationTimeoutMs + " ms timeout." + ); + timeout.initCause(e); + throw timeout; + } + throw e; + } + if (resp.status == 200) { + return resp; + } + if (!FAULT_OPERATION_TIMEOUT.equals(wsmanFaultCode(resp.document))) { + throw faultException("Receive", resp); + } + // No output before OperationTimeout expired. The blocking path re-issues the Receive + // immediately (its caller's wall-clock deadline governs); for a streaming consumer + // that silence IS the inactivity timeout. + if (failOnQuietTimeout) { + throw new TimeoutException( + "The command produced no output within the " + operationTimeoutMs + " ms timeout." + ); + } + } + } + + /** + * The command's exit code, once {@link #nextChunk()} has returned {@code null}. + */ + int exitCode() { + if (exitCode == null) { + throw new IllegalStateException("The command has not completed yet."); + } + return exitCode; + } + + /** Signal the command (terminate) and release the connection; runs at most once. */ + private void finish() throws Exception { + if (finished) { + return; } + finished = true; try { - return receiveLoop(commandId, cs, operationTimeoutMs); - } finally { terminate(commandId, operationTimeoutMs); + } finally { + connectionPermit.release(); } - } finally { - operationLock.unlock(); + } + + /** + * Send the terminate Signal (stopping the remote command when it is still running) and + * release the connection. Idempotent; a no-op when the command already completed and was + * signaled by the final {@link #nextChunk()}. + */ + @Override + public void close() throws Exception { + finish(); } } @@ -284,7 +627,7 @@ private void createShell(final String workingDirectory, final long timeoutMs) th throw new IllegalStateException("Shell ID not found in Create response"); } - private String startCommand(final String commandLine, final long timeoutMs) throws Exception { + private String sendCommand(final String commandLine, final long timeoutMs) throws Exception { final Document doc = expectOk(Envelopes.command(url, shellId, commandLine, timeoutMs), "Command"); final String commandId = text(doc, "CommandId"); if (commandId == null) { @@ -293,39 +636,6 @@ private String startCommand(final String commandLine, final long timeoutMs) thro return commandId; } - private CommandOutput receiveLoop(final String commandId, final Charset charset, final long timeoutMs) - throws Exception { - // Accumulate the raw stream BYTES and decode once at the end: a multibyte character (e.g. UTF-8) - // can be split across Stream elements or Receive responses, and decoding each chunk independently - // would corrupt the boundary bytes into replacement characters. - final ByteArrayOutputStream stdout = new ByteArrayOutputStream(); - final ByteArrayOutputStream stderr = new ByteArrayOutputStream(); - while (true) { - // A late non-final response (or an op-timeout fault) must not keep an abandoned worker - // re-issuing Receive — and holding the serial connection — until the remote command ends. - // Aborting here still runs the finally-block Signal, which terminates the remote command. - checkNotCancelled(); - final Decoded resp = request(Envelopes.receive(url, shellId, commandId, timeoutMs)); - if (resp.status != 200) { - final String faultCode = wsmanFaultCode(resp.document); - // No output before OperationTimeout → re-issue Receive immediately. - if (FAULT_OPERATION_TIMEOUT.equals(faultCode)) { - continue; - } - throw faultException("Receive", resp); - } - collectStreams(resp.document, stdout, stderr); - final Integer exitCode = doneExitCode(resp.document); - if (exitCode != null) { - return new CommandOutput( - new String(stdout.toByteArray(), charset), - new String(stderr.toByteArray(), charset), - exitCode - ); - } - } - } - private void terminate(final String commandId, final long timeoutMs) throws Exception { final Decoded resp = request(Envelopes.signal(url, shellId, commandId, timeoutMs)); // A missing shell is fine here — the command already finished and the shell may be gone. @@ -368,8 +678,9 @@ private static WinRMFaultException faultException(final String operation, final /** * Send one SOAP request (authenticating the connection on first use via the {@link AuthScheme}) - * and decode the response. The caller must hold {@link #operationLock}; every path here is reached - * from a locked wql/executeCommand/close, so requests never interleave on the stateful connection. + * and decode the response. The caller must hold {@link #connectionPermit}; every path here is + * reached from an open enumeration/command handle (which owns the permit) or a permit-holding + * close, so requests never interleave on the stateful connection. */ private Decoded request(final String soap) throws Exception { // If the connection was dropped (e.g. the server sent "Connection: close"), the session bound @@ -602,11 +913,12 @@ private static String trimToNull(final String s) { @Override public void close() { // Only attempt a graceful shell Delete if no operation is currently using the connection: a - // blocking tryLock (never a lock()) keeps close() from waiting on an abandoned, timed-out worker - // still holding operationLock while blocked on a socket read. When we cannot acquire the lock, - // or a request would otherwise race the worker, we skip the Delete and just hard-close the - // transport below — which unblocks that worker's read; the shell is reaped by the server IdleTimeout. - final boolean locked = operationLock.tryLock(); + // non-blocking tryAcquire (never an acquire()) keeps close() from waiting on an abandoned, + // timed-out worker — or an open streaming handle — still holding the permit while blocked on + // a socket read. When we cannot acquire the permit, or a request would otherwise race the + // worker, we skip the Delete and just hard-close the transport below — which unblocks that + // worker's read; the shell is reaped by the server IdleTimeout. + final boolean locked = connectionPermit.tryAcquire(); try { final String shell = shellId; shellId = null; @@ -625,7 +937,7 @@ public void close() { } } finally { if (locked) { - operationLock.unlock(); + connectionPermit.release(); } transport.close(); } diff --git a/src/site/markdown/commands.md b/src/site/markdown/commands.md index f4039c3..d7b1cde 100644 --- a/src/site/markdown/commands.md +++ b/src/site/markdown/commands.md @@ -43,10 +43,11 @@ Everything between `command(...)` and `execute()` is optional: | Option | Default | Meaning | | --- | --- | --- | -| `timeout(Duration)` | the client's timeout | Wall-clock deadline covering file uploads, encoding detection, and the command itself. | +| `timeout(Duration)` | the client's timeout | Wall-clock deadline covering file uploads, encoding detection, and the command itself with `execute()`; inactivity timeout with `start()`. | | `charset(Charset)` | detected from the remote code set | 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). | +| `onStdout(Consumer)` / `onStderr(Consumer)` | none | Callbacks receiving each chunk of output live while `execute()` runs (see below). | ## The result @@ -59,6 +60,56 @@ Everything between `command(...)` and `execute()` is optional: | `exitCode()` | `int` | The process exit code (Windows HRESULT codes reported as unsigned 32-bit values are narrowed to the equivalent signed `int`). | | `elapsed()` | `java.time.Duration` | Wall-clock time of the operation. | +## Streaming the output + +`execute()` collects the complete output in memory and returns only when the command has exited. +For long-running or verbose commands, end the same request with `start()` instead: it returns a +[`RemoteProcess`](apidocs/org/metricshub/winrm/RemoteProcess.html) — shaped like +`java.lang.Process` — whose output can be consumed **while the command is still running**: + +```java +try (RemoteProcess process = client.command("wevtutil qe System /f:text").start()) { + try (BufferedReader out = process.stdout()) { + out.lines().forEach(this::process); + } + int exitCode = process.waitFor(); // or waitFor(Duration) for an overall deadline +} +``` + +Points to know: + +* **Close the process** — use try-with-resources. Closing before completion sends the WinRM + terminate `Signal`, which actually stops the remote command; a command drained to its end cleans + up on its own. Closing the readers does *not* close the process. +* `stdout()` and `stderr()` are fed by the same protocol loop: reading either channel (or calling + `waitFor()`) advances it, and output arriving for the channel not being read is buffered until + read — memory is bounded by the *unread* channel, not by the total output. +* Output is **decoded incrementally** with the request's charset; a multibyte character split + across protocol chunks is decoded correctly. +* The process **holds the client's serial connection** until completion or close: other operations + on the same client wait in the meantime. +* The timeout is an **inactivity** timeout — the longest silence tolerated from the server — not + an overall deadline: a command may run (and stream) far longer than the timeout as long as it + keeps producing output. Use `waitFor(Duration)` when you need a hard deadline. See + [Timeouts and Errors](timeouts-and-errors.html). + +### Tailing the output of a blocking execution + +When you only want to *observe* the output live — logging, progress reporting — but still want the +blocking call and its complete [`CommandResult`](apidocs/org/metricshub/winrm/CommandResult.html), +register `onStdout(...)` / `onStderr(...)` callbacks and keep `execute()` as the terminal: + +```java +CommandResult result = client.command("longRunningThing.exe") + .onStdout(chunk -> log.info(chunk)) + .onStderr(chunk -> log.warn(chunk)) + .execute(); +``` + +Each callback receives the output chunk by chunk as the server delivers it (not necessarily whole +lines), on an internal worker thread, never concurrently. The wall-clock timeout of `execute()` +applies unchanged. + ## Character set By default the output character set does not need to be specified: the client detects the remote diff --git a/src/site/markdown/index.md b/src/site/markdown/index.md index 13d87f0..6f04308 100644 --- a/src/site/markdown/index.md +++ b/src/site/markdown/index.md @@ -15,6 +15,11 @@ The **WinRM Java Client** is a small library that talks to the Windows Remote Ma * **execute remote commands**, capturing standard output, standard error and the exit code — optionally copying local script files to the host first ([Remote Commands](commands.html)). +Both operations can also **stream**: WQL rows are consumed page by page as they arrive +(`stream()`), and command output is consumed while the command is still running (`start()`, +returning a `java.lang.Process`-like handle) — memory stays bounded regardless of the result +size. + It supports **NTLM** over HTTP (with message encryption) and HTTPS, and **Kerberos (SPNEGO)** over HTTPS ([Authentication](authentication.html)). diff --git a/src/site/markdown/timeouts-and-errors.md b/src/site/markdown/timeouts-and-errors.md index 9348bd9..2a59638 100644 --- a/src/site/markdown/timeouts-and-errors.md +++ b/src/site/markdown/timeouts-and-errors.md @@ -33,6 +33,18 @@ no part of it (in particular: the command itself) runs afterward. The timeout also drives the wire-level behavior: the WSMan `OperationTimeout` header and the socket timeouts follow each operation's own deadline. +### Streaming terminals: inactivity timeout + +The streaming terminals — `stream()` on a WQL request and `start()` on a command (see +[WQL Queries](wql.html) and [Remote Commands](commands.html)) — interpret the same `timeout(...)` +value differently, because an overall deadline would make long-running streams impossible: there +it is an **inactivity timeout**, the longest silence tolerated from the server between two +responses. A query result can be consumed, or a command can keep streaming output, for arbitrarily +long — but as soon as the server stays silent for a whole timeout, the operation fails with +[`WinRMTimeoutException`](apidocs/org/metricshub/winrm/exceptions/WinRMTimeoutException.html). +For commands, `RemoteProcess.waitFor(Duration)` provides an overall deadline on top when one is +needed. + ## The exception surface The fluent API is **unchecked**: every failure is a diff --git a/src/site/markdown/wql.md b/src/site/markdown/wql.md index e3031fc..23e497e 100644 --- a/src/site/markdown/wql.md +++ b/src/site/markdown/wql.md @@ -42,7 +42,7 @@ Everything between `wql(...)` and `execute()` is optional: | Option | Default | Meaning | | --- | --- | --- | | `namespace(String)` | the client's namespace (`ROOT\CIMV2` unless set on the builder) | The WMI namespace to query. | -| `timeout(Duration)` | the client's timeout | Wall-clock deadline for the whole query. See [Timeouts and Errors](timeouts-and-errors.html). | +| `timeout(Duration)` | the client's timeout | Wall-clock deadline for the whole query with `execute()`; inactivity timeout with `stream()`. See [Timeouts and Errors](timeouts-and-errors.html). | | `pageSize(int)` | 32000 | WS-Enumeration `MaxElements`: how many rows the server may return per protocol round trip. | | `pullTimeout(Duration)` | server default | WS-Enumeration `MaxTime`: how long the server may hold a single `Pull` open before answering with the rows it has. | @@ -59,6 +59,40 @@ WqlResult events = client.wql("SELECT * FROM Win32_NTLogEvent") a smaller page bounds each response's size, and a pull timeout keeps the server from holding a `Pull` open past your deadline while it gathers rows. +## Streaming the rows + +`execute()` collects the complete result in memory. For very large result sets — Windows event +logs, software inventories — end the same request with `stream()` instead: it returns a lazy +`java.util.stream.Stream` of [`WqlRow`](apidocs/org/metricshub/winrm/WqlRow.html)s that yields +each row as soon as it is parsed and pulls the next WS-Enumeration page from the server only as +the stream advances. Memory stays bounded by one page (`pageSize(int)`), not by the whole result +set. + +```java +try (Stream rows = client.wql("SELECT * FROM Win32_NTLogEvent") + .pageSize(5000) + .stream()) { + + rows.filter(row -> "Error".equals(row.string("Type"))) + .limit(100) + .forEach(this::process); +} +``` + +Points to know: + +* **Close the stream** — use try-with-resources, exactly like `Files.lines(...)`. Closing before + the last row tells the server to free the enumeration immediately (WS-Enumeration `Release`); + an exhausted stream cleans up on its own. +* The stream **holds the client's serial connection** while open: other operations on the same + client wait until it is closed or exhausted (the same contract as a JDBC `ResultSet` on its + connection). +* The timeout is an **inactivity** timeout — the longest silence tolerated from the server between + two responses — not an overall deadline: consuming a huge result can take arbitrarily long as + long as the server keeps answering. See [Timeouts and Errors](timeouts-and-errors.html). +* The stream is sequential and ordered; failures while iterating are reported through the same + unchecked exceptions as `execute()` (see below). + ## Reading the result [`WqlResult`](apidocs/org/metricshub/winrm/WqlResult.html) is immutable and iterable: diff --git a/src/test/java/org/metricshub/winrm/ChunkDecoderTest.java b/src/test/java/org/metricshub/winrm/ChunkDecoderTest.java new file mode 100644 index 0000000..921db42 --- /dev/null +++ b/src/test/java/org/metricshub/winrm/ChunkDecoderTest.java @@ -0,0 +1,93 @@ +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.assertEquals; + +import java.nio.charset.Charset; +import java.nio.charset.StandardCharsets; +import java.util.Arrays; +import org.junit.jupiter.api.Test; + +/** + * Unit tests of {@link ChunkDecoder}: incrementally decoding a byte sequence — split at any + * boundary, including inside multibyte characters — must yield exactly the text a whole-buffer + * {@code new String(bytes, charset)} yields. + */ +class ChunkDecoderTest { + + /** Decode the bytes in two chunks split at the given position, plus the final flush. */ + private static String decodeSplit(final byte[] bytes, final int split, final Charset charset) { + final ChunkDecoder decoder = new ChunkDecoder(charset); + return (decoder.decode(Arrays.copyOfRange(bytes, 0, split)) + + decoder.decode(Arrays.copyOfRange(bytes, split, bytes.length)) + + decoder.finish()); + } + + @Test + void anySplitOfMultibyteUtf8MatchesWholeBufferDecoding() { + // 2-byte (é), 3-byte (€) and 4-byte (🙂) UTF-8 sequences. + final String text = "aé€🙂z"; + final byte[] bytes = text.getBytes(StandardCharsets.UTF_8); + for (int split = 0; split <= bytes.length; split++) { + assertEquals(text, decodeSplit(bytes, split, StandardCharsets.UTF_8), "split at " + split); + } + } + + @Test + void oneByteAtATimeMatchesWholeBufferDecoding() { + final String text = "é€🙂"; + final byte[] bytes = text.getBytes(StandardCharsets.UTF_8); + final ChunkDecoder decoder = new ChunkDecoder(StandardCharsets.UTF_8); + final StringBuilder decoded = new StringBuilder(); + for (final byte b : bytes) { + decoded.append(decoder.decode(new byte[] { b })); + } + decoded.append(decoder.finish()); + assertEquals(text, decoded.toString()); + } + + @Test + void malformedInputIsReplacedLikeStringConstructor() { + // A stray continuation byte and a truncated 2-byte sequence at the very end. + final byte[] bytes = { 'a', (byte) 0xA9, 'b', (byte) 0xC3 }; + final String expected = new String(bytes, StandardCharsets.UTF_8); + for (int split = 0; split <= bytes.length; split++) { + assertEquals(expected, decodeSplit(bytes, split, StandardCharsets.UTF_8), "split at " + split); + } + } + + @Test + void singleByteCharsetsPassThrough() { + final Charset cp1252 = Charset.forName("windows-1252"); + final String text = "café au lait"; + final byte[] bytes = text.getBytes(cp1252); + assertEquals(text, decodeSplit(bytes, bytes.length / 2, cp1252)); + } + + @Test + void emptyChunksProduceNoOutput() { + final ChunkDecoder decoder = new ChunkDecoder(StandardCharsets.UTF_8); + assertEquals("", decoder.decode(new byte[0])); + assertEquals("", decoder.decode(new byte[0])); + assertEquals("", decoder.finish()); + } +} diff --git a/src/test/java/org/metricshub/winrm/StreamingApiTest.java b/src/test/java/org/metricshub/winrm/StreamingApiTest.java new file mode 100644 index 0000000..a4fc95b --- /dev/null +++ b/src/test/java/org/metricshub/winrm/StreamingApiTest.java @@ -0,0 +1,437 @@ +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.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +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.envelope; +import static org.metricshub.winrm.light.FakeWsmanResponses.enumerationDone; +import static org.metricshub.winrm.light.FakeWsmanResponses.fault; +import static org.metricshub.winrm.light.FakeWsmanResponses.instance; +import static org.metricshub.winrm.light.FakeWsmanResponses.receiveResponse; +import static org.metricshub.winrm.light.FakeWsmanResponses.resourceCreated; +import static org.metricshub.winrm.light.FakeWsmanResponses.signalResponse; +import static org.metricshub.winrm.light.FakeWsmanResponses.stream; + +import java.io.BufferedReader; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.ArrayList; +import java.util.Iterator; +import java.util.List; +import java.util.stream.Stream; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.metricshub.winrm.exceptions.WinRMTimeoutException; +import org.metricshub.winrm.exceptions.WqlSyntaxException; +import org.metricshub.winrm.light.FakeWsmanServer; + +/** + * End-to-end tests of the streaming API (issue #111) against {@link FakeWsmanServer}: + * {@code WqlRequest.stream()}, {@code CommandRequest.start()} / {@code RemoteProcess}, and the + * {@code onStdout}/{@code onStderr} callbacks — laziness, resource cleanup (WS-Enumeration + * Release, terminate Signal), incremental decoding, and the inactivity-timeout semantics. + */ +class StreamingApiTest { + + private static final String DOMAIN = "FAKE"; + private static final String USER = "user"; + private static final String PASSWORD = "s3cret-Passw0rd"; + + private static final String WSEN = "http://schemas.xmlsoap.org/ws/2004/09/enumeration"; + private static final String WSMAN = "http://schemas.dmtf.org/wbem/wsman/1/wsman.xsd"; + + /** The WSMan fault code the server answers with when no result is ready before OperationTimeout. */ + private static final String FAULT_OPERATION_TIMEOUT = "2150858793"; + + 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 static String service(final String name, final String state) { + return instance("Win32_Service", "Name", name, "State", state); + } + + /** An EnumerateResponse carrying rows and an open enumeration context (more pages follow). */ + private static String enumeratePage(final String context, final String... instances) { + final StringBuilder xml = new StringBuilder(); + xml + .append("") + .append("") + .append(context) + .append("") + .append(""); + for (final String item : instances) { + xml.append(item); + } + return xml.append("").toString(); + } + + /** A final PullResponse: the last rows and the end-of-sequence marker. */ + private static String pullDone(final String... instances) { + final StringBuilder xml = new StringBuilder(); + xml + .append(""); + for (final String item : instances) { + xml.append(item); + } + return xml.append("").toString(); + } + + private static String releaseResponse() { + return ""; + } + + // --- WQL streaming ------------------------------------------------------- + + @Test + void wqlStreamYieldsRowsBeforeLaterPagesAreFetched() throws Exception { + server + .enqueue(200, envelope(enumeratePage("uuid:CTX-1", service("Spooler", "Running"), service("W32Time", "Stopped")))) + .enqueue(200, envelope(pullDone(service("WinRM", "Running")))); + + try (WinRMClient client = builder().build()) { + try (Stream rows = client.wql("SELECT Name, State FROM Win32_Service").stream()) { + final Iterator iterator = rows.iterator(); + + assertEquals("Spooler", iterator.next().string("Name")); + assertEquals("W32Time", iterator.next().string("Name")); + // Both first-page rows were served from the Enumerate response alone: no Pull yet. + assertEquals(1, server.decryptedRequests().size()); + + assertEquals("WinRM", iterator.next().string("Name")); + assertEquals(2, server.decryptedRequests().size()); + assertTrue(server.decryptedRequests().get(1).contains("enumeration/Pull")); + + assertFalse(iterator.hasNext()); + } + // The enumeration completed with EndOfSequence: nothing to release. + assertEquals(2, server.decryptedRequests().size()); + } + } + + @Test + void closingWqlStreamEarlySendsRelease() throws Exception { + server + .enqueue( + 200, + envelope(enumeratePage("uuid:CTX-42", service("Spooler", "Running"), service("W32Time", "Stopped"))) + ) + .enqueue(200, envelope(releaseResponse())); + + try (WinRMClient client = builder().build()) { + try (Stream rows = client.wql("SELECT Name FROM Win32_Service").stream()) { + assertEquals("Spooler", rows.findFirst().orElseThrow().string("Name")); + } + + final List requests = server.decryptedRequests(); + assertEquals(2, requests.size()); + assertTrue(requests.get(1).contains("enumeration/Release"), "early close must send a Release"); + assertTrue(requests.get(1).contains("uuid:CTX-42"), "the Release must carry the enumeration context"); + + // The connection is free again: a follow-up query runs on the same client. + server.enqueue(200, envelope(enumerationDone(service("WinRM", "Running")))); + assertEquals(1, client.wql("SELECT Name FROM Win32_Service").execute().size()); + } + } + + @Test + void exhaustedWqlStreamReleasesTheConnectionWithoutRelease() throws Exception { + server.enqueue(200, envelope(enumerationDone(service("Spooler", "Running")))); + + try (WinRMClient client = builder().build()) { + final List names = new ArrayList<>(); + try (Stream rows = client.wql("SELECT Name FROM Win32_Service").stream()) { + rows.map(row -> row.string("Name")).forEach(names::add); + } + assertEquals(List.of("Spooler"), names); + assertEquals(1, server.decryptedRequests().size()); + + // The permit was released on exhaustion: the client is immediately reusable. + server.enqueue(200, envelope(enumerationDone(service("WinRM", "Running")))); + assertEquals(1, client.wql("SELECT Name FROM Win32_Service").execute().size()); + } + } + + @Test + void wqlStreamMapsOperationTimeoutFaultToInactivityTimeout() throws Exception { + server + .enqueue(200, envelope(enumeratePage("uuid:CTX-1", service("Spooler", "Running")))) + .enqueue(500, fault(FAULT_OPERATION_TIMEOUT, "The operation timed out.")); + + try (WinRMClient client = builder().timeout(Duration.ofMillis(500)).build()) { + try (Stream rows = client.wql("SELECT Name FROM Win32_Service").stream()) { + final Iterator iterator = rows.iterator(); + assertEquals("Spooler", iterator.next().string("Name")); + + final WinRMTimeoutException e = assertThrows(WinRMTimeoutException.class, iterator::next); + assertTrue(e.getMessage().contains("timed out"), e.getMessage()); + } + // The enumeration state is unknown after the failure: no Release is pushed into it. + assertEquals(2, server.decryptedRequests().size()); + } + } + + @Test + void wqlStreamRejectsInvalidQueryBeforeSendingAnything() throws Exception { + try (WinRMClient client = builder().build()) { + assertThrows(WqlSyntaxException.class, () -> client.wql("Not a WQL query").stream()); + assertEquals(0, server.decryptedRequests().size()); + } + } + + // --- Command streaming --------------------------------------------------- + + /** Script the shell creation and command startup that precede every command exchange. */ + private void enqueueCommandStartup() { + server.enqueue(200, envelope(resourceCreated("SHELL-1"))).enqueue(200, envelope(commandResponse(COMMAND_ID))); + } + + private static String stdoutChunk(final String text) { + return stream("stdout", COMMAND_ID, text.getBytes(StandardCharsets.UTF_8)); + } + + private static String stderrChunk(final String text) { + return stream("stderr", COMMAND_ID, text.getBytes(StandardCharsets.UTF_8)); + } + + @Test + void startStreamsOutputWhileTheCommandIsStillRunning() throws Exception { + enqueueCommandStartup(); + server + .enqueue(200, envelope(receiveResponse(stdoutChunk("line1\n"), null))) + .enqueue(200, envelope(receiveResponse(stdoutChunk("line2\n"), done(COMMAND_ID, 7)))) + .enqueue(200, envelope(signalResponse())); + + try (WinRMClient client = builder().build()) { + try (RemoteProcess process = client.command("dir /s").charset(StandardCharsets.UTF_8).start()) { + final BufferedReader stdout = process.stdout(); + + assertEquals("line1", stdout.readLine()); + // The first line was consumed while only one Receive had been answered: the command + // is still running from the client's point of view. + assertEquals(3, server.decryptedRequests().size()); + + assertEquals("line2", stdout.readLine()); + assertNull(stdout.readLine()); + assertEquals(7, process.waitFor()); + } + + final List requests = server.decryptedRequests(); + // Create, Command, Receive, Receive, Signal — and the close() after completion adds nothing. + assertEquals(5, requests.size()); + assertTrue(requests.get(4).contains("signal/terminate")); + } + } + + @Test + void interleavedChannelsAreSplitAndOrderIsPreservedPerChannel() throws Exception { + enqueueCommandStartup(); + server + .enqueue(200, envelope(receiveResponse(stdoutChunk("out1\n") + stderrChunk("err1\n"), null))) + .enqueue(200, envelope(receiveResponse(stderrChunk("err2\n") + stdoutChunk("out2\n"), done(COMMAND_ID, 0)))) + .enqueue(200, envelope(signalResponse())); + + try ( + WinRMClient client = builder().build(); + RemoteProcess process = client.command("run").charset(StandardCharsets.UTF_8).start()) { + // Draining stdout first buffers whatever arrives on stderr in the meantime. + assertEquals(List.of("out1", "out2"), process.stdout().lines().collect(java.util.stream.Collectors.toList())); + assertEquals(List.of("err1", "err2"), process.stderr().lines().collect(java.util.stream.Collectors.toList())); + assertEquals(0, process.waitFor()); + } + } + + @Test + void multibyteCharacterSplitAcrossReceiveResponsesIsDecodedCorrectly() throws Exception { + final byte[] eAcute = "é".getBytes(StandardCharsets.UTF_8); // 0xC3 0xA9 + enqueueCommandStartup(); + server + .enqueue(200, envelope(receiveResponse(stream("stdout", COMMAND_ID, new byte[] + { 'a', eAcute[0] }), null))) + .enqueue( + 200, + envelope(receiveResponse(stream("stdout", COMMAND_ID, new byte[] + { eAcute[1], 'b' }), done(COMMAND_ID, 0))) + ) + .enqueue(200, envelope(signalResponse())); + + try ( + WinRMClient client = builder().build(); + RemoteProcess process = client.command("type utf8.txt").charset(StandardCharsets.UTF_8).start()) { + assertEquals("aéb", process.stdout().readLine()); + assertEquals(0, process.waitFor()); + } + } + + @Test + void closingTheProcessEarlyTerminatesTheRemoteCommand() throws Exception { + enqueueCommandStartup(); + server + .enqueue(200, envelope(receiveResponse(stdoutChunk("tick\n"), null))) + .enqueue(200, envelope(signalResponse())); + + try (WinRMClient client = builder().build()) { + final RemoteProcess process = client.command("ping -t localhost").charset(StandardCharsets.UTF_8).start(); + assertEquals("tick", process.stdout().readLine()); + assertThrows(IllegalStateException.class, process::exitCode); + + process.close(); + + final List requests = server.decryptedRequests(); + assertEquals(4, requests.size()); + assertTrue(requests.get(3).contains("signal/terminate"), "early close must Signal the command"); + + // The connection is free again after the early termination. + server.enqueue(200, envelope(enumerationDone(service("WinRM", "Running")))); + assertEquals(1, client.wql("SELECT Name FROM Win32_Service").execute().size()); + } + } + + @Test + void waitForDeadlineExpiresWhileTheCommandKeepsRunning() throws Exception { + enqueueCommandStartup(); + server + .enqueueDelayed(200, envelope(receiveResponse(stdoutChunk("slow\n"), null)), 300) + .enqueue(200, envelope(signalResponse())); + + try (WinRMClient client = builder().build()) { + try (RemoteProcess process = client.command("slow.exe").charset(StandardCharsets.UTF_8).start()) { + assertFalse(process.waitFor(Duration.ofMillis(100)), "the command must still be running"); + // The output that arrived while waiting stays readable. + assertEquals("slow", process.stdout().readLine()); + } + assertTrue(server.decryptedRequests().get(3).contains("signal/terminate")); + } + } + + @Test + void commandSilenceBeyondTheTimeoutSurfacesAsInactivityTimeout() throws Exception { + enqueueCommandStartup(); + server + .enqueue(500, fault(FAULT_OPERATION_TIMEOUT, "The operation timed out.")) + .enqueue(200, envelope(signalResponse())); + + try (WinRMClient client = builder().timeout(Duration.ofMillis(500)).build()) { + try (RemoteProcess process = client.command("silent.exe").charset(StandardCharsets.UTF_8).start()) { + final WinRMTimeoutException e = assertThrows(WinRMTimeoutException.class, process::waitFor); + assertTrue(e.getMessage().contains("no output"), e.getMessage()); + } + // Closing after the failure still terminates the remote command. + assertTrue(server.decryptedRequests().get(3).contains("signal/terminate")); + } + } + + // --- onStdout / onStderr callbacks ---------------------------------------- + + @Test + void callbacksReceiveChunksAsTheyArriveAndTheResultIsComplete() throws Exception { + final byte[] eAcute = "é".getBytes(StandardCharsets.UTF_8); + enqueueCommandStartup(); + server + // The first chunk ends with half of a UTF-8 character: the callback must not see it + // until the second chunk completes it. + .enqueue( + 200, + envelope( + receiveResponse(stream("stdout", COMMAND_ID, concat("first".getBytes(StandardCharsets.UTF_8), new byte[] + { eAcute[0] })), null) + ) + ) + .enqueue( + 200, + envelope( + receiveResponse( + stream("stdout", COMMAND_ID, concat(new byte[] + { eAcute[1] }, "second".getBytes(StandardCharsets.UTF_8))) + + stderrChunk("warning"), + done(COMMAND_ID, 3) + ) + ) + ) + .enqueue(200, envelope(signalResponse())); + + final List stdoutChunks = new ArrayList<>(); + final List stderrChunks = new ArrayList<>(); + try (WinRMClient client = builder().build()) { + final CommandResult result = client + .command("chatty.exe") + .charset(StandardCharsets.UTF_8) + .onStdout(stdoutChunks::add) + .onStderr(stderrChunks::add) + .execute(); + + assertEquals(List.of("first", "ésecond"), stdoutChunks); + assertEquals(List.of("warning"), stderrChunks); + assertEquals("firstésecond", result.stdout()); + assertEquals("warning", result.stderr()); + assertEquals(3, result.exitCode()); + } + } + + // --- SPI defaults ---------------------------------------------------------- + + @Test + 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)); + } + + private static byte[] concat(final byte[] a, final byte[] b) { + final byte[] result = new byte[a.length + b.length]; + System.arraycopy(a, 0, result, 0, a.length); + System.arraycopy(b, 0, result, a.length, b.length); + return result; + } +} From e126f18fc24e2b9dcb786b2c9014d72977d9a329 Mon Sep 17 00:00:00 2001 From: Bertrand Martin Date: Tue, 28 Jul 2026 00:04:55 +0200 Subject: [PATCH 02/13] Enforce the inactivity timeout on streaming socket reads; make a closed RemoteProcess inert (Codex P2s) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - HttpTransport gains inactivityTimeout(int): for streaming opens the socket read timeout IS the inactivity bound, with none of the +10s headroom the blocking paths keep for the retryable op-timeout fault — a server that stops answering entirely is now detected at the configured timeout, not ten seconds later. Regression test with a response delayed far past the timeout, asserting the elapsed detection time. - Closing a RemoteProcess early now transitions the local state: the decoders are flushed (buffered output stays readable, then EOF) and no read/wait may ever call cursor.next() again — the cursor released the connection, so the previous behavior could fire a stray Receive without owning it and race a later operation. The RemoteCommand primitive gets the same guard (a finished handle always yields null). waitFor()/exitCode() after an early close throw IllegalStateException — unless the command's final chunk had in fact been received, in which case close() recovers the real exit code from the cursor. Co-Authored-By: Claude Fable 5 --- .../org/metricshub/winrm/CommandCursor.java | 8 +-- .../org/metricshub/winrm/RemoteProcess.java | 48 +++++++++++++---- .../metricshub/winrm/light/HttpTransport.java | 32 +++++++++--- .../metricshub/winrm/light/WsmanClient.java | 25 ++++++++- .../metricshub/winrm/StreamingApiTest.java | 52 +++++++++++++++++++ 5 files changed, 145 insertions(+), 20 deletions(-) diff --git a/src/main/java/org/metricshub/winrm/CommandCursor.java b/src/main/java/org/metricshub/winrm/CommandCursor.java index 1d17955..607135f 100644 --- a/src/main/java/org/metricshub/winrm/CommandCursor.java +++ b/src/main/java/org/metricshub/winrm/CommandCursor.java @@ -63,9 +63,11 @@ public interface CommandCursor extends AutoCloseable { /** * 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. May throw - * an unchecked {@link org.metricshub.winrm.exceptions.WinRMClientException} when the Signal - * itself fails — the remote command may then still be running. + * 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(); diff --git a/src/main/java/org/metricshub/winrm/RemoteProcess.java b/src/main/java/org/metricshub/winrm/RemoteProcess.java index 2f1dfee..b7ce743 100644 --- a/src/main/java/org/metricshub/winrm/RemoteProcess.java +++ b/src/main/java/org/metricshub/winrm/RemoteProcess.java @@ -83,8 +83,10 @@ public final class RemoteProcess implements AutoCloseable { private final BufferedReader stdout; private final BufferedReader stderr; + // 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 int exitCode; + private Integer exitCode; RemoteProcess(final CommandCursor cursor, final Charset charset, final String hostname, final Duration timeout) { this.cursor = cursor; @@ -130,7 +132,7 @@ public synchronized int waitFor() { while (!finished) { fetchOnce(); } - return exitCode; + return exitCodeValue(); } /** @@ -152,6 +154,9 @@ public synchronized boolean waitFor(final Duration deadline) { while (!finished && Utils.getCurrentTimeMillis() - start < deadlineMillis) { fetchOnce(); } + if (finished && exitCode == null) { + throw new IllegalStateException("The process was closed before the command completed."); + } return finished; } @@ -160,25 +165,50 @@ public synchronized boolean waitFor(final Duration deadline) { * * @return the exit code * @throws IllegalStateException when the command has not completed yet — wait for completion - * with {@link #waitFor()}, or read the output streams to their end first + * with {@link #waitFor()}, or read the output streams to their end first — or when the + * process was closed before the command completed */ public synchronized int exitCode() { - if (!finished) { - throw new IllegalStateException("The command has not completed yet."); - } - return exitCode; + return exitCodeValue(); } /** * Terminate the command (when it is still running) and release the client's connection. - * Idempotent; a no-op when the command already completed. Buffered output remains readable - * after closing. + * Idempotent; a no-op when the command already completed. Output buffered before the close + * remains readable, then the readers report end of stream; {@link #waitFor()} and + * {@link #exitCode()} throw {@link IllegalStateException} when the close preceded completion — + * a terminated command has no exit code. */ @Override public synchronized void close() { + if (!finished) { + // No protocol fetch may happen after the close: the cursor signals the command and + // releases the connection, so this handle must never touch it again. The decoders are + // flushed so a trailing partial character surfaces (as a replacement) instead of vanishing. + finished = true; + try { + // The command may in fact have completed (its final chunk was received) without this + // handle having observed the end-of-stream fetch: the exit code is then already known. + exitCode = cursor.exitCode(); + } catch (final IllegalStateException ignored) { + // Genuinely closed before completion: there is no exit code. + } + stdoutPending.append(stdoutDecoder.finish()); + stderrPending.append(stderrDecoder.finish()); + } cursor.close(); } + /** The observed exit code, or the explanation of why there is none. */ + private int exitCodeValue() { + if (exitCode == null) { + throw new IllegalStateException( + finished ? "The process was closed before the command completed." : "The command has not completed yet." + ); + } + return exitCode; + } + /** One Receive round trip: decode what arrived into the per-channel buffers. Holds the monitor. */ private void fetchOnce() { final CommandCursor.Chunk chunk; diff --git a/src/main/java/org/metricshub/winrm/light/HttpTransport.java b/src/main/java/org/metricshub/winrm/light/HttpTransport.java index b26a27d..b14a57d 100644 --- a/src/main/java/org/metricshub/winrm/light/HttpTransport.java +++ b/src/main/java/org/metricshub/winrm/light/HttpTransport.java @@ -87,16 +87,36 @@ final class HttpTransport implements AutoCloseable { } /** - * Align the socket timeouts with the current operation's timeout: the connect timeout for a - * (re)connection made on behalf of this operation, and the read timeout (plus headroom, so - * the WSMan OperationTimeout fault arrives before the socket read gives up). Applies to the - * live connection immediately and to any future reconnection. + * Align the socket timeouts with the current blocking operation's timeout: the connect timeout + * for a (re)connection made on behalf of this operation, and the read timeout (plus headroom, + * so the WSMan OperationTimeout fault the Receive loop retries on reliably arrives before the + * socket read gives up — the blocking paths are bounded by their caller's wall-clock deadline, + * not by the socket). Applies to the live connection immediately and to any future + * reconnection. * * @param operationTimeoutMillis the current operation's timeout in milliseconds */ void operationTimeout(final int operationTimeoutMillis) { - connectTimeoutMillis = operationTimeoutMillis; - readTimeoutMillis = operationTimeoutMillis + 10_000; + applyTimeouts(operationTimeoutMillis, operationTimeoutMillis + 10_000); + } + + /** + * Align the socket timeouts with a STREAMING operation's inactivity timeout. Unlike + * {@link #operationTimeout(int)} the read timeout gets NO headroom: the streaming paths have + * no outer wall-clock timer, and a read timeout there means "the server stayed silent too + * long" — so the socket must give up at the inactivity bound itself, not ten seconds later. + * A server that enforces the WSMan OperationTimeout by answering with the op-timeout fault + * reaches the caller through that fault instead; both surface as the same timeout. + * + * @param inactivityTimeoutMillis the longest tolerated silence in milliseconds + */ + void inactivityTimeout(final int inactivityTimeoutMillis) { + applyTimeouts(inactivityTimeoutMillis, inactivityTimeoutMillis); + } + + private void applyTimeouts(final int connectMillis, final int readMillis) { + connectTimeoutMillis = connectMillis; + readTimeoutMillis = readMillis; if (socket != null && !socket.isClosed()) { try { socket.setSoTimeout(readTimeoutMillis); diff --git a/src/main/java/org/metricshub/winrm/light/WsmanClient.java b/src/main/java/org/metricshub/winrm/light/WsmanClient.java index 5265054..a1e547e 100644 --- a/src/main/java/org/metricshub/winrm/light/WsmanClient.java +++ b/src/main/java/org/metricshub/winrm/light/WsmanClient.java @@ -148,6 +148,22 @@ private static int toSocketTimeoutMillis(final long millis) { return (int) Math.min(millis, Integer.MAX_VALUE - 10_000L); } + /** + * Align the transport's socket timeouts with the operation being opened. Blocking operations + * keep the read-timeout headroom (their caller's wall-clock deadline governs, and the WSMan + * op-timeout fault must arrive before the socket gives up so the Receive loop can retry); + * streaming operations must observe the configured inactivity timeout on the socket itself — + * a server that stops answering entirely would otherwise be detected ten seconds late. + */ + private void configureTimeouts(final long operationTimeoutMs, final boolean failOnQuietTimeout) { + final int millis = toSocketTimeoutMillis(operationTimeoutMs); + if (failOnQuietTimeout) { + transport.inactivityTimeout(millis); + } else { + transport.operationTimeout(millis); + } + } + /** A decrypted WSMan response: HTTP status plus the (decrypted) SOAP body. */ private static final class Decoded { @@ -218,7 +234,7 @@ WqlEnumeration openWql( lockAbortably(); boolean opened = false; try { - transport.operationTimeout(toSocketTimeoutMillis(operationTimeoutMs)); + configureTimeouts(operationTimeoutMs, failOnQuietTimeout); // WMI namespaces are case-insensitive, but preserve the caller's case to match the CXF backend. final String ns = namespace.replace('\\', '/'); final WqlEnumeration enumeration = new WqlEnumeration( @@ -454,7 +470,7 @@ RemoteCommand startCommand( lockAbortably(); boolean opened = false; try { - transport.operationTimeout(toSocketTimeoutMillis(operationTimeoutMs)); + configureTimeouts(operationTimeoutMs, failOnQuietTimeout); if (!shellWorkingDirectoryPinned) { shellWorkingDirectory = workingDirectory; shellWorkingDirectoryPinned = true; @@ -530,6 +546,11 @@ private RemoteCommand(final String commandId, final long operationTimeoutMs, fin * {@link #close()}; the exit code is then available from {@link #exitCode()}. */ Chunk nextChunk() throws Exception { + if (finished) { + // Already signaled — normally after completion, but also after an early close(): the + // connection was released either way, so never touch it again from this handle. + return null; + } if (exitCode != null) { // The command completed with the previous chunk: Signal it and release the connection. finish(); diff --git a/src/test/java/org/metricshub/winrm/StreamingApiTest.java b/src/test/java/org/metricshub/winrm/StreamingApiTest.java index a4fc95b..584580f 100644 --- a/src/test/java/org/metricshub/winrm/StreamingApiTest.java +++ b/src/test/java/org/metricshub/winrm/StreamingApiTest.java @@ -222,6 +222,31 @@ void wqlStreamMapsOperationTimeoutFaultToInactivityTimeout() throws Exception { } } + @Test + void totalServerSilenceIsBoundedByTheInactivityTimeout() throws Exception { + server + .enqueue(200, envelope(enumeratePage("uuid:CTX-1", service("Spooler", "Running")))) + // The Pull response arrives way past the inactivity timeout — a server that stopped + // answering entirely (no op-timeout fault). The socket read itself must give up at the + // inactivity bound, not 10 seconds later (the headroom the blocking paths keep). + .enqueueDelayed(200, envelope(pullDone(service("WinRM", "Running"))), 5_000); + + try (WinRMClient client = builder().timeout(Duration.ofMillis(300)).build()) { + try (Stream rows = client.wql("SELECT Name FROM Win32_Service").stream()) { + final Iterator iterator = rows.iterator(); + assertEquals("Spooler", iterator.next().string("Name")); + + final long start = System.nanoTime(); + assertThrows(WinRMTimeoutException.class, iterator::next); + final long elapsedMillis = (System.nanoTime() - start) / 1_000_000; + assertTrue( + elapsedMillis < 4_000, + "silence must be detected at the inactivity timeout, not " + elapsedMillis + " ms later" + ); + } + } + } + @Test void wqlStreamRejectsInvalidQueryBeforeSendingAnything() throws Exception { try (WinRMClient client = builder().build()) { @@ -328,6 +353,12 @@ void closingTheProcessEarlyTerminatesTheRemoteCommand() throws Exception { process.close(); + // The handle is inert after closing: buffered output only, then end of stream — reads + // and waits must not issue any further protocol request on a connection they no longer own. + assertNull(process.stdout().readLine()); + assertThrows(IllegalStateException.class, process::waitFor); + assertThrows(IllegalStateException.class, process::exitCode); + final List requests = server.decryptedRequests(); assertEquals(4, requests.size()); assertTrue(requests.get(3).contains("signal/terminate"), "early close must Signal the command"); @@ -338,6 +369,27 @@ void closingTheProcessEarlyTerminatesTheRemoteCommand() throws Exception { } } + @Test + void closingAfterTheFinalChunkStillExposesTheExitCode() throws Exception { + enqueueCommandStartup(); + server + .enqueue(200, envelope(receiveResponse(stdoutChunk("all\n"), done(COMMAND_ID, 5)))) + .enqueue(200, envelope(signalResponse())); + + try (WinRMClient client = builder().build()) { + final RemoteProcess process = client.command("quick.exe").charset(StandardCharsets.UTF_8).start(); + // The final chunk (carrying the exit state) was received, but the end-of-stream fetch + // never ran: closing must still expose the exit code the command actually reported. + assertEquals("all", process.stdout().readLine()); + process.close(); + + assertEquals(5, process.exitCode()); + assertEquals(5, process.waitFor()); + assertNull(process.stdout().readLine()); + assertEquals(4, server.decryptedRequests().size()); + } + } + @Test void waitForDeadlineExpiresWhileTheCommandKeepsRunning() throws Exception { enqueueCommandStartup(); From 802817a89d873515a775cc3a66739dc46ef6cc97 Mon Sep 17 00:00:00 2001 From: Bertrand Martin Date: Tue, 28 Jul 2026 00:16:57 +0200 Subject: [PATCH 03/13] Consistent inactivity timeouts on startup round trips; fence stragglers after close (Codex P2s) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - New WsmanClient.exchange(): every round trip of a streaming operation — the initial Enumerate, shell Create, Command, each Pull and Receive — now translates the two "server stayed quiet" signals (socket read timeout and the WSMan op-timeout fault 2150858793) into the documented inactivity TimeoutException, so stream()/start() surface WinRMTimeoutException no matter which request exceeded the limit first. Blocking mode goes through the unchanged expectOk() path. - WsmanClient.close() now sets a closed flag before hard-closing the transport, and request() refuses to run on a closed client: a streaming handle (or abandoned worker) outliving the client can no longer trigger a transparent reconnect + re-authentication that would leak a socket nothing ever closes. The handle cleanup paths (terminate Signal, WS-Enumeration Release) skip their network send in that state and just release the connection permit. Regression tests: op-timeout fault answering the initial Enumerate / shell Create → WinRMTimeoutException; client closed while a stream/process is open → closing the handle stays silent on the wire. Co-Authored-By: Claude Fable 5 --- .../metricshub/winrm/light/WsmanClient.java | 156 ++++++++++++------ .../metricshub/winrm/StreamingApiTest.java | 61 +++++++ 2 files changed, 169 insertions(+), 48 deletions(-) diff --git a/src/main/java/org/metricshub/winrm/light/WsmanClient.java b/src/main/java/org/metricshub/winrm/light/WsmanClient.java index a1e547e..aa48f87 100644 --- a/src/main/java/org/metricshub/winrm/light/WsmanClient.java +++ b/src/main/java/org/metricshub/winrm/light/WsmanClient.java @@ -91,6 +91,12 @@ final class WsmanClient implements AutoCloseable { // released at all — unlock is owner-only). private final Semaphore connectionPermit = new Semaphore(1); + // Set (before anything else) by close(): a straggler — an abandoned worker or a streaming + // handle outliving the client — must never send another request, because request() would + // happily reconnect and re-authenticate the hard-closed transport, reviving a connection + // nothing will ever close again. Volatile: close() may run on another thread. + private volatile boolean closed; + /** * Acquire {@link #connectionPermit}, aborting when this task has been cancelled. A caller's * wall-clock timeout can fire while its operation is still QUEUED behind another one on this @@ -245,7 +251,12 @@ WqlEnumeration openWql( failOnQuietTimeout ); enumeration.ingest( - expectOk(Envelopes.enumerateWql(url, ns, query, operationTimeoutMs, maxElements), "Enumerate") + exchange( + Envelopes.enumerateWql(url, ns, query, operationTimeoutMs, maxElements), + "Enumerate", + operationTimeoutMs, + failOnQuietTimeout + ) ); opened = true; return enumeration; @@ -335,38 +346,18 @@ private Map advance() throws Exception { } // Stop pulling once the caller has been told the operation timed out. checkNotCancelled(); - final Decoded resp; - try { - resp = request(Envelopes.pull(url, namespace, context, operationTimeoutMs, maxElements, maxTimeMs)); - } catch (final SocketTimeoutException e) { - if (failOnQuietTimeout) { - throw inactivityTimeout("No response from the WinRM service", e); - } - throw e; - } - if (resp.status != 200) { - if (failOnQuietTimeout && FAULT_OPERATION_TIMEOUT.equals(wsmanFaultCode(resp.document))) { - // The server had no rows to hand out for a whole OperationTimeout: that IS the - // streaming inactivity timeout. - throw inactivityTimeout("The WQL enumeration produced no rows", null); - } - throw faultException("Pull", resp); - } - ingest(resp.document); + ingest( + exchange( + Envelopes.pull(url, namespace, context, operationTimeoutMs, maxElements, maxTimeMs), + "Pull", + operationTimeoutMs, + failOnQuietTimeout + ) + ); } return page.get(cursor++); } - private TimeoutException inactivityTimeout(final String what, final Throwable cause) { - final TimeoutException timeout = new TimeoutException( - what + " within the " + operationTimeoutMs + " ms timeout." - ); - if (cause != null) { - timeout.initCause(cause); - } - return timeout; - } - /** * Release the enumeration: when the server still holds an enumeration context, a * best-effort WS-Enumeration Release lets it free the context (and the operation slot it @@ -379,7 +370,9 @@ public void close() { } finished = true; try { - if (!broken && !endOfSequence && context != null && !context.isEmpty()) { + // No Release when the whole client was closed while this handle was open: its transport + // is gone, and the request would reconnect and re-authenticate just to be thrown away. + if (!broken && !closed && !endOfSequence && context != null && !context.isEmpty()) { try { request(Envelopes.release(url, namespace, context, operationTimeoutMs)); } catch (final Exception ignored) { @@ -476,14 +469,14 @@ RemoteCommand startCommand( shellWorkingDirectoryPinned = true; } if (shellId == null) { - createShell(shellWorkingDirectory, operationTimeoutMs); + createShell(shellWorkingDirectory, operationTimeoutMs, failOnQuietTimeout); } // The caller's timeout may have fired while the Create response was being awaited (socket // reads do not observe interrupts): never START the command after the reported timeout. checkNotCancelled(); String commandId; try { - commandId = sendCommand(commandLine, operationTimeoutMs); + commandId = sendCommand(commandLine, operationTimeoutMs, failOnQuietTimeout); } catch (final WinRMFaultException e) { if (!FAULT_SHELL_NOT_FOUND.equals(e.getFaultCode())) { throw e; @@ -492,9 +485,9 @@ RemoteCommand startCommand( // long-lived client). The Command was rejected before it could run, so it is safe to // recreate the shell — with its ORIGINAL working directory — and retry once. shellId = null; - createShell(shellWorkingDirectory, operationTimeoutMs); + createShell(shellWorkingDirectory, operationTimeoutMs, failOnQuietTimeout); checkNotCancelled(); - commandId = sendCommand(commandLine, operationTimeoutMs); + commandId = sendCommand(commandLine, operationTimeoutMs, failOnQuietTimeout); } opened = true; return new RemoteCommand(commandId, operationTimeoutMs, failOnQuietTimeout); @@ -576,11 +569,7 @@ private Decoded receiveOutput() throws Exception { resp = request(Envelopes.receive(url, shellId, commandId, operationTimeoutMs)); } catch (final SocketTimeoutException e) { if (failOnQuietTimeout) { - final TimeoutException timeout = new TimeoutException( - "No response from the WinRM service within the " + operationTimeoutMs + " ms timeout." - ); - timeout.initCause(e); - throw timeout; + throw quietTimeout("No response from the WinRM service", operationTimeoutMs, e); } throw e; } @@ -594,9 +583,7 @@ private Decoded receiveOutput() throws Exception { // immediately (its caller's wall-clock deadline governs); for a streaming consumer // that silence IS the inactivity timeout. if (failOnQuietTimeout) { - throw new TimeoutException( - "The command produced no output within the " + operationTimeoutMs + " ms timeout." - ); + throw quietTimeout("The command produced no output", operationTimeoutMs, null); } } } @@ -618,7 +605,12 @@ private void finish() throws Exception { } finished = true; try { - terminate(commandId, operationTimeoutMs); + // No Signal when the whole client was closed while this handle was open: its transport + // is gone, and the request would reconnect and re-authenticate just to be thrown away — + // the server reaps the shell (and its commands) on its own IdleTimeout instead. + if (!closed) { + terminate(commandId, operationTimeoutMs); + } } finally { connectionPermit.release(); } @@ -635,8 +627,14 @@ public void close() throws Exception { } } - private void createShell(final String workingDirectory, final long timeoutMs) throws Exception { - final Document doc = expectOk(Envelopes.createShell(url, workingDirectory, timeoutMs), "Create shell"); + private void createShell(final String workingDirectory, final long timeoutMs, final boolean failOnQuietTimeout) + throws Exception { + final Document doc = exchange( + Envelopes.createShell(url, workingDirectory, timeoutMs), + "Create shell", + timeoutMs, + failOnQuietTimeout + ); final NodeList selectors = doc.getElementsByTagNameNS("*", "Selector"); for (int i = 0; i < selectors.getLength(); i++) { final Element selector = (Element) selectors.item(i); @@ -648,8 +646,14 @@ private void createShell(final String workingDirectory, final long timeoutMs) th throw new IllegalStateException("Shell ID not found in Create response"); } - private String sendCommand(final String commandLine, final long timeoutMs) throws Exception { - final Document doc = expectOk(Envelopes.command(url, shellId, commandLine, timeoutMs), "Command"); + private String sendCommand(final String commandLine, final long timeoutMs, final boolean failOnQuietTimeout) + throws Exception { + final Document doc = exchange( + Envelopes.command(url, shellId, commandLine, timeoutMs), + "Command", + timeoutMs, + failOnQuietTimeout + ); final String commandId = text(doc, "CommandId"); if (commandId == null) { throw new IllegalStateException("No CommandId in Command response"); @@ -681,6 +685,48 @@ private Document expectOk(final String soap, final String operation) throws Exce return resp.document; } + /** + * Send one request of a streaming-capable operation, expecting HTTP 200. In streaming mode + * ({@code failOnQuietTimeout}) the two "server stayed quiet for a whole timeout" signals — a + * socket read timeout and the WSMan operation-timeout fault — are translated into the + * {@link TimeoutException} the streaming contract documents, on EVERY round trip (startup + * included), so the caller sees one consistent inactivity failure regardless of which request + * exceeded the limit first. In blocking mode this is exactly {@link #expectOk}: the raw + * failures surface and the caller's wall-clock deadline governs. + */ + private Document exchange( + final String soap, + final String operation, + final long operationTimeoutMs, + final boolean failOnQuietTimeout + ) throws Exception { + if (!failOnQuietTimeout) { + return expectOk(soap, operation); + } + final Decoded resp; + try { + resp = request(soap); + } catch (final SocketTimeoutException e) { + throw quietTimeout("No response from the WinRM service", operationTimeoutMs, e); + } + if (resp.status != 200) { + if (FAULT_OPERATION_TIMEOUT.equals(wsmanFaultCode(resp.document))) { + throw quietTimeout(operation + " produced no result", operationTimeoutMs, null); + } + throw faultException(operation, resp); + } + return resp.document; + } + + /** The streaming inactivity timeout: the server produced nothing for a whole timeout. */ + private static TimeoutException quietTimeout(final String what, final long timeoutMs, final Throwable cause) { + final TimeoutException timeout = new TimeoutException(what + " within the " + timeoutMs + " ms timeout."); + if (cause != null) { + timeout.initCause(cause); + } + return timeout; + } + /** * Build the exception for a faulting response: the message keeps the historical * {@code failed:

} format (part of the exception-message contract inherited @@ -704,6 +750,17 @@ private static WinRMFaultException faultException(final String operation, final * close, so requests never interleave on the stateful connection. */ private Decoded request(final String soap) throws Exception { + // A straggler outliving close() must fail here rather than transparently reconnect and + // re-authenticate the hard-closed transport — that revived connection would leak, since + // nothing will ever close this client again. Same message as the executor's own guard. + if (closed) { + throw new IllegalStateException("This instance has been closed and a new one must be created."); + } + return send(soap); + } + + /** The body of {@link #request(String)}, also reachable from close() itself. */ + private Decoded send(final String soap) throws Exception { // If the connection was dropped (e.g. the server sent "Connection: close"), the session bound // to it is dead — re-handshake on the fresh connection rather than sending unauthenticated. if (auth.isAuthenticated() && !transport.isConnected()) { @@ -933,6 +990,9 @@ private static String trimToNull(final String s) { @Override public void close() { + // Fence stragglers first: any in-flight or later request() from a worker or streaming handle + // that outlives this close must fail instead of reviving the connection (see request()). + closed = true; // Only attempt a graceful shell Delete if no operation is currently using the connection: a // non-blocking tryAcquire (never an acquire()) keeps close() from waiting on an abandoned, // timed-out worker — or an open streaming handle — still holding the permit while blocked on @@ -946,7 +1006,7 @@ public void close() { if (locked) { if (shell != null) { try { - request(Envelopes.deleteShell(url, shell, timeoutMs)); + send(Envelopes.deleteShell(url, shell, timeoutMs)); } catch (final Exception ignored) { // best-effort shell cleanup } diff --git a/src/test/java/org/metricshub/winrm/StreamingApiTest.java b/src/test/java/org/metricshub/winrm/StreamingApiTest.java index 584580f..a465843 100644 --- a/src/test/java/org/metricshub/winrm/StreamingApiTest.java +++ b/src/test/java/org/metricshub/winrm/StreamingApiTest.java @@ -247,6 +247,35 @@ void totalServerSilenceIsBoundedByTheInactivityTimeout() throws Exception { } } + @Test + void quietTimeoutOnTheInitialEnumerateSurfacesAsInactivityTimeout() throws Exception { + // The op-timeout fault can answer the very first request too: stream() must report the + // documented timeout, not a generic WSMan fault. + server.enqueue(500, fault(FAULT_OPERATION_TIMEOUT, "The operation timed out.")); + + try (WinRMClient client = builder().timeout(Duration.ofMillis(500)).build()) { + assertThrows(WinRMTimeoutException.class, () -> client.wql("SELECT Name FROM Win32_Service").stream()); + } + } + + @Test + void closingTheClientWhileAWqlStreamIsOpenLeavesTheStreamInert() throws Exception { + server.enqueue(200, envelope(enumeratePage("uuid:CTX-1", service("Spooler", "Running")))); + + try (WinRMClient client = builder().build()) { + final Stream rows = client.wql("SELECT Name FROM Win32_Service").stream(); + final Iterator iterator = rows.iterator(); + assertEquals("Spooler", iterator.next().string("Name")); + + // The client goes away while the stream still owns the connection: closing the stream + // afterward must not resurrect the transport (reconnect + re-authenticate) for a Release. + client.close(); + rows.close(); + + assertEquals(1, server.decryptedRequests().size()); + } + } + @Test void wqlStreamRejectsInvalidQueryBeforeSendingAnything() throws Exception { try (WinRMClient client = builder().build()) { @@ -407,6 +436,38 @@ void waitForDeadlineExpiresWhileTheCommandKeepsRunning() throws Exception { } } + @Test + void quietTimeoutDuringCommandStartupSurfacesAsInactivityTimeout() throws Exception { + // The op-timeout fault can answer the shell Create too: start() must report the documented + // timeout, not a generic WSMan fault. + server.enqueue(500, fault(FAULT_OPERATION_TIMEOUT, "The operation timed out.")); + + try (WinRMClient client = builder().timeout(Duration.ofMillis(500)).build()) { + assertThrows( + WinRMTimeoutException.class, + () -> client.command("slow-start.exe").charset(StandardCharsets.UTF_8).start() + ); + } + } + + @Test + void closingTheClientWhileAProcessIsOpenLeavesTheProcessInert() throws Exception { + enqueueCommandStartup(); + server.enqueue(200, envelope(receiveResponse(stdoutChunk("tick\n"), null))); + + try (WinRMClient client = builder().build()) { + final RemoteProcess process = client.command("ping -t localhost").charset(StandardCharsets.UTF_8).start(); + assertEquals("tick", process.stdout().readLine()); + + // The client goes away while the process still owns the connection: closing the process + // afterward must not resurrect the transport (reconnect + re-authenticate) for a Signal. + client.close(); + process.close(); + + assertEquals(3, server.decryptedRequests().size()); + } + } + @Test void commandSilenceBeyondTheTimeoutSurfacesAsInactivityTimeout() throws Exception { enqueueCommandStartup(); From a334905cf4be317b8a702bdd39c32e9032bdc156 Mon Sep 17 00:00:00 2001 From: Bertrand Martin Date: Tue, 28 Jul 2026 01:30:21 +0200 Subject: [PATCH 04/13] Build the CLI on the streaming API: live WQL rows and command output The CLI now dogfoods the fluent WinRMClient streaming terminals: - wql streams each row to stdout (flushed per row) as the WS-Enumeration pages arrive: a huge query starts producing JSON Lines immediately and memory stays bounded by one page. The -t timeout is the stream's inactivity timeout (longest tolerated server silence), so large results are no longer killed by an overall deadline; a mid-stream failure can leave partial output before the nonzero exit (documented). - command/exec forwards stdout and stderr chunks live to the local streams while the remote command runs (onStdout/onStderr + execute(), which keeps the overall wall-clock deadline and the complete exit-code contract). - connect() now builds a WinRMClient: --https-permissive maps to the per-client trustAllCertificates() instead of mutating the global org.metricshub.winrm.tls.insecure system property (the ambient property still works for those who set it themselves); WinRMTimeoutException maps to exit code 124 like the legacy TimeoutException. The RemoteOperations test seam became consumer-based, and the Windows code-page decoding test now runs the full CLI stack (argument parsing, real connect factory, streaming forwarders) against FakeWsmanServer. Co-Authored-By: Claude Fable 5 --- README.md | 9 +- .../org/metricshub/winrm/cli/WinRmCli.java | 158 +++++++++++------- src/site/markdown/commands.md | 5 +- src/site/markdown/wql.md | 6 +- .../metricshub/winrm/cli/WinRmCliTest.java | 73 ++++---- 5 files changed, 158 insertions(+), 93 deletions(-) diff --git a/README.md b/README.md index f4506e3..19479d1 100644 --- a/README.md +++ b/README.md @@ -202,9 +202,12 @@ echoing it. Non-interactive runs must use `--password-file` (or, less securely, WQL writes one compact UTF-8 JSON object per row to stdout ([JSON Lines](https://jsonlines.org/)); property order follows the WinRM response. Diagnostics go -only to stderr. Remote command stdout and stderr are forwarded to the corresponding local streams. -The current backend buffers an operation's result; the CLI output boundary is ready to consume the -streaming API when it becomes available. +only to stderr. The CLI is built on the streaming API: WQL rows are written **as the enumeration +pages arrive** (a huge query starts producing output immediately, memory stays bounded, and for +`wql` the timeout is the longest tolerated silence between two server responses rather than an +overall deadline — a mid-stream failure can leave partial output before the nonzero exit), and +remote command stdout and stderr are forwarded **live** to the corresponding local streams while +the command runs (the timeout remains the overall command deadline). Exit behavior is stable: diff --git a/src/main/java/org/metricshub/winrm/cli/WinRmCli.java b/src/main/java/org/metricshub/winrm/cli/WinRmCli.java index 41bee70..6e50235 100644 --- a/src/main/java/org/metricshub/winrm/cli/WinRmCli.java +++ b/src/main/java/org/metricshub/winrm/cli/WinRmCli.java @@ -27,23 +27,30 @@ 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; import java.util.Map; import java.util.concurrent.TimeoutException; +import java.util.function.Consumer; +import java.util.stream.Stream; import javax.net.ssl.SSLException; -import org.metricshub.winrm.WindowsRemoteCommandResult; -import org.metricshub.winrm.WindowsRemoteProcessUtils; -import org.metricshub.winrm.exceptions.WindowsRemoteException; -import org.metricshub.winrm.light.LightWinRMService; -import org.metricshub.winrm.service.WinRMEndpoint; +import org.metricshub.winrm.AuthScheme; +import org.metricshub.winrm.WinRMClient; +import org.metricshub.winrm.WinRMHttpProtocolEnum; +import org.metricshub.winrm.WqlRow; +import org.metricshub.winrm.exceptions.WinRMTimeoutException; +import org.metricshub.winrm.service.client.auth.AuthenticationEnum; /** - * Command-line interface for WQL queries and remote command execution through WinRM. + * Command-line interface for WQL queries and remote command execution through WinRM, built on the + * streaming terminals of the fluent {@link WinRMClient} API. *

- * WQL results are emitted as UTF-8 JSON Lines on standard output. Remote command output is - * forwarded to the matching local output stream. Diagnostics are written only to standard error. + * WQL results are emitted as UTF-8 JSON Lines on standard output, row by row as the + * 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. *

* NTLM is the default authentication scheme. Kerberos requires HTTPS. HTTPS validates certificates * and hostnames unless the explicitly insecure {@code --https-permissive} option is used. @@ -63,7 +70,6 @@ public final class WinRmCli { static final int EXIT_AUTHENTICATION = 77; static final int EXIT_TIMEOUT = 124; - private static final String INSECURE_TLS_PROPERTY = "org.metricshub.winrm.tls.insecure"; private static final String KERBEROS_KDC_PROPERTY = "java.security.krb5.kdc"; private static final String KERBEROS_REALM_PROPERTY = "java.security.krb5.realm"; @@ -140,29 +146,40 @@ private static int execute( final PrintStream standardError, final RemoteFactory remoteFactory ) { - final String previousInsecureTls = System.getProperty(INSECURE_TLS_PROPERTY); final String previousKerberosKdc = System.getProperty(KERBEROS_KDC_PROPERTY); final String previousKerberosRealm = System.getProperty(KERBEROS_REALM_PROPERTY); try { - setPermissiveHttps(arguments.permissiveHttps()); setKerberosConfiguration(arguments, standardError); try (RemoteOperations remote = remoteFactory.connect(arguments)) { if (arguments.operation() == CliArguments.Operation.WQL) { - final List> rows = remote.executeWql(arguments.input(), arguments.timeout()); - for (final Map row : rows) { - JsonLinesWriter.write(row, standardOutput); - } - standardOutput.flush(); + // Flush after every row so a downstream pipe sees each row as soon as the server + // hands it out, not when the enumeration ends. + remote.streamWql( + arguments.input(), + arguments.timeout(), + row -> { + JsonLinesWriter.write(row, standardOutput); + standardOutput.flush(); + } + ); return 0; } - final WindowsRemoteCommandResult result = remote.executeCommand(arguments.input(), arguments.timeout()); - standardOutput.print(result.getStdout()); - standardError.print(result.getStderr()); - standardOutput.flush(); - standardError.flush(); - return remoteExitCode(result.getStatusCode(), standardError); + // Forward each output chunk as it arrives, so a long-running command can be followed live. + final int exitCode = remote.executeCommand( + arguments.input(), + arguments.timeout(), + chunk -> { + standardOutput.print(chunk); + standardOutput.flush(); + }, + chunk -> { + standardError.print(chunk); + standardError.flush(); + } + ); + return remoteExitCode(exitCode, standardError); } - } catch (final TimeoutException e) { + } catch (final TimeoutException | WinRMTimeoutException e) { diagnostic(standardError, "operation timed out"); return EXIT_TIMEOUT; } catch (final Exception e) { @@ -170,24 +187,35 @@ private static int execute( diagnostic(standardError, safeMessage(e)); return exitCode; } finally { - restoreProperty(INSECURE_TLS_PROPERTY, previousInsecureTls); restoreProperty(KERBEROS_KDC_PROPERTY, previousKerberosKdc); restoreProperty(KERBEROS_REALM_PROPERTY, previousKerberosRealm); } } - private static RemoteOperations connect(final CliArguments arguments) throws WindowsRemoteException { - final WinRMEndpoint endpoint = new WinRMEndpoint( - arguments.protocol(), - arguments.hostname(), - arguments.port(), - arguments.username(), - arguments.password(), - null - ); - return new LightRemoteOperations( - LightWinRMService.createInstance(endpoint, arguments.timeout(), null, arguments.authentications()) - ); + static RemoteOperations connect(final CliArguments arguments) { + final WinRMClient.Builder builder = WinRMClient + .builder(arguments.hostname()) + .port(arguments.port()) + .credentials(arguments.username(), arguments.password()) + .timeout(Duration.ofMillis(arguments.timeout())); + if (arguments.protocol() == WinRMHttpProtocolEnum.HTTPS) { + builder.https(); + } + if (arguments.permissiveHttps()) { + // Per-client setting: unlike the legacy org.metricshub.winrm.tls.insecure system + // property, it does not leak to (or race with) anything else in the JVM. + builder.trustAllCertificates(); + } + final List authentications = arguments.authentications(); + if (authentications != null && !authentications.isEmpty()) { + builder.authentication( + authentications + .stream() + .map(scheme -> scheme == AuthenticationEnum.KERBEROS ? AuthScheme.KERBEROS : AuthScheme.NTLM) + .toArray(AuthScheme[]::new) + ); + } + return new FluentRemoteOperations(builder.build()); } private static int remoteExitCode(final int exitCode, final PrintStream standardError) { @@ -238,12 +266,6 @@ private static void diagnostic(final PrintStream standardError, final String mes standardError.println("winrm-java: " + message); } - private static void setPermissiveHttps(final boolean permissive) { - if (permissive) { - System.setProperty(INSECURE_TLS_PROPERTY, Boolean.TRUE.toString()); - } - } - private static void setKerberosConfiguration( final CliArguments arguments, final PrintStream standardError @@ -304,8 +326,11 @@ private static String help() { "LF, CRLF, or CR removed; every other byte is part of the UTF-8 password.\n" + "Kerberos KDC/realm options set the JDK Kerberos configuration for this invocation.\n" + "Without them, the ambient JDK Kerberos configuration is used.\n" + - "WQL rows are written to stdout as UTF-8 JSON Lines. Command stdout and stderr are forwarded\n" + - "to the corresponding local streams.\n" + + "WQL rows are streamed to stdout as UTF-8 JSON Lines as they arrive from the host; the timeout\n" + + "is the longest tolerated silence between two server responses, so large results may stream\n" + + "for longer, and a mid-stream failure can leave partial output before the nonzero exit.\n" + + "Command stdout and stderr are forwarded live to the corresponding local streams while the\n" + + "command runs; the timeout is the overall command deadline.\n" + "\n" + "Exit codes: 0 success; remote command code 0..255 when available; 64 usage;\n" + "69 connection/TLS; 70 WinRM protocol; 77 authentication; 124 timeout.\n"; @@ -322,36 +347,55 @@ interface PasswordReader { } interface RemoteOperations extends AutoCloseable { - List> executeWql(String query, long timeout) throws Exception; + /** Run the WQL query, handing each row to the consumer as it arrives. */ + void streamWql(String query, long timeout, Consumer> rowConsumer) throws Exception; - WindowsRemoteCommandResult executeCommand(String command, long timeout) throws Exception; + /** + * Run the command, forwarding each decoded output chunk to the matching consumer as it + * arrives, and return the remote exit code. + */ + int executeCommand(String command, long timeout, Consumer stdoutConsumer, Consumer stderrConsumer) + throws Exception; @Override void close(); } - static final class LightRemoteOperations implements RemoteOperations { + /** The real remote operations: the streaming terminals of the fluent {@link WinRMClient}. */ + static final class FluentRemoteOperations implements RemoteOperations { - private final LightWinRMService service; + private final WinRMClient client; - LightRemoteOperations(final LightWinRMService service) { - this.service = service; + FluentRemoteOperations(final WinRMClient client) { + this.client = client; } @Override - public List> executeWql(final String query, final long timeout) throws Exception { - return service.executeWql(query, timeout); + public void streamWql(final String query, final long timeout, final Consumer> rowConsumer) { + try (Stream rows = client.wql(query).timeout(Duration.ofMillis(timeout)).stream()) { + rows.forEach(row -> rowConsumer.accept(row.asMap())); + } } @Override - public WindowsRemoteCommandResult executeCommand(final String command, final long timeout) throws Exception { - final Charset charset = WindowsRemoteProcessUtils.getWindowsEncodingCharset(service, timeout); - return service.executeCommand(command, null, charset, timeout); + public int executeCommand( + final String command, + final long timeout, + final Consumer stdoutConsumer, + final Consumer stderrConsumer + ) { + return client + .command(command) + .timeout(Duration.ofMillis(timeout)) + .onStdout(stdoutConsumer) + .onStderr(stderrConsumer) + .execute() + .exitCode(); } @Override public void close() { - service.close(); + client.close(); } } } diff --git a/src/site/markdown/commands.md b/src/site/markdown/commands.md index d7b1cde..97dd7c3 100644 --- a/src/site/markdown/commands.md +++ b/src/site/markdown/commands.md @@ -170,8 +170,9 @@ See [Timeouts and Errors](timeouts-and-errors.html) for details. ## From the command line The standalone jar runs a command with the `command` subcommand (aliases: `cmd`, `exec`, `run`). -Standard output and standard error are forwarded to the corresponding local streams, and the -process exits with the remote exit code when it fits in 0–255: +Standard output and standard error are forwarded **live** to the corresponding local streams while +the command runs (each chunk is flushed as it arrives, so a long-running command can be followed +in real time), and the process exits with the remote exit code when it fits in 0–255: ```bash java -jar ${project.artifactId}-${project.version}-standalone.jar \ diff --git a/src/site/markdown/wql.md b/src/site/markdown/wql.md index 23e497e..f520575 100644 --- a/src/site/markdown/wql.md +++ b/src/site/markdown/wql.md @@ -173,4 +173,8 @@ java -jar ${project.artifactId}-${project.version}-standalone.jar \ wql 'SELECT Name, State FROM Win32_Service' ``` -Property order follows the WinRM response; diagnostics go only to standard error. +The rows are **streamed**: each one is written (and flushed) as it arrives from the host, so a +pipe consuming the output starts working immediately and memory stays bounded regardless of the +result size. The `-t`/`--timeout` option is the inactivity timeout of the stream; a mid-stream +failure can leave partial output on standard output before the nonzero exit. Property order +follows the WinRM response; diagnostics go only to standard error. diff --git a/src/test/java/org/metricshub/winrm/cli/WinRmCliTest.java b/src/test/java/org/metricshub/winrm/cli/WinRmCliTest.java index 698c31f..77e6ac7 100644 --- a/src/test/java/org/metricshub/winrm/cli/WinRmCliTest.java +++ b/src/test/java/org/metricshub/winrm/cli/WinRmCliTest.java @@ -39,12 +39,9 @@ import java.util.List; import java.util.Map; import java.util.concurrent.TimeoutException; +import java.util.function.Consumer; import org.junit.jupiter.api.Test; -import org.metricshub.winrm.WinRMHttpProtocolEnum; -import org.metricshub.winrm.WindowsRemoteCommandResult; import org.metricshub.winrm.light.FakeWsmanServer; -import org.metricshub.winrm.light.LightWinRMService; -import org.metricshub.winrm.service.WinRMEndpoint; class WinRmCliTest { @@ -99,7 +96,9 @@ void writesWqlAsJsonLines() throws Exception { @Test void forwardsCommandStreamsAndExitCode() throws Exception { final FakeRemote remote = new FakeRemote(); - remote.commandResult = new WindowsRemoteCommandResult("output", "warning", 0.1f, 7); + remote.stdoutChunks = List.of("out", "put"); + remote.stderrChunks = List.of("warning"); + remote.commandExitCode = 7; final Invocation invocation = invoke(concat(REQUIRED, "exec", "echo", "hello world"), args -> remote); @@ -258,35 +257,39 @@ void honorsAnAmbientInsecureTlsProperty() throws Exception { @Test void decodesCommandOutputUsingTheRemoteWindowsCodePage() throws Exception { final Charset windowsCharset = Charset.forName("windows-1251"); - final long timeout = 30_000L; - // End to end against the in-process WSMan server: the remote reports Windows code page - // 1251 and the command output arrives in that encoding — the CLI must query the code page - // and decode the stream bytes with it, or the Cyrillic output turns into mojibake. + // Full stack against the in-process WSMan server, through the CLI's real connect factory + // and its streaming forwarders: the remote reports Windows code page 1251 and the command + // output arrives in that encoding — the CLI must query the code page and decode the stream + // bytes with it, or the Cyrillic output turns into mojibake. try (FakeWsmanServer server = new FakeWsmanServer("FAKE", "user", "secret")) { enqueueEnumeration(server, instance("Win32_OperatingSystem", "CodeSet", "1251")); enqueueShellCreation(server); enqueueCommandExchange(server, "Результат".getBytes(windowsCharset), new byte[0], 0); enqueueShellDeletion(server); - final WinRMEndpoint endpoint = new WinRMEndpoint( - WinRMHttpProtocolEnum.HTTP, - "127.0.0.1", - server.port(), - "FAKE\\user", - "secret".toCharArray(), - null + final Invocation invocation = invoke( + new String[] + { + "-h", + "127.0.0.1", + "-P", + String.valueOf(server.port()), + "-u", + "FAKE\\user", + "-p", + "secret", + "-t", + "30000", + "exec", + "whoami" + }, + WinRmCli::connect ); - final WindowsRemoteCommandResult result; - try ( - WinRmCli.LightRemoteOperations remote = new WinRmCli.LightRemoteOperations( - LightWinRMService.createInstance(endpoint, timeout, null, null) - )) { - result = remote.executeCommand("whoami", timeout); - } - assertEquals("Результат", result.getStdout()); - assertEquals(0, result.getStatusCode()); + assertEquals(0, invocation.exitCode); + assertEquals("Результат", invocation.stdout); + assertEquals("", invocation.stderr); // The decoding charset really came from the remote code-page query assertTrue( @@ -362,22 +365,32 @@ private Invocation(final int exitCode, final String stdout, final String stderr) private static final class FakeRemote implements WinRmCli.RemoteOperations { private List> rows = List.of(); - private WindowsRemoteCommandResult commandResult = new WindowsRemoteCommandResult("", "", 0.0f, 0); + private List stdoutChunks = List.of(); + private List stderrChunks = List.of(); + private int commandExitCode; private Exception failure; private String command; private boolean closed; @Override - public List> executeWql(final String query, final long timeout) throws Exception { + public void streamWql(final String query, final long timeout, final Consumer> rowConsumer) + throws Exception { failIfConfigured(); - return rows; + rows.forEach(rowConsumer); } @Override - public WindowsRemoteCommandResult executeCommand(final String command, final long timeout) throws Exception { + public int executeCommand( + final String command, + final long timeout, + final Consumer stdoutConsumer, + final Consumer stderrConsumer + ) throws Exception { this.command = command; failIfConfigured(); - return commandResult; + stdoutChunks.forEach(stdoutConsumer); + stderrChunks.forEach(stderrConsumer); + return commandExitCode; } @Override From 664cc3f5d0a7660f75ced9f2f1448d329f564266 Mon Sep 17 00:00:00 2001 From: Bertrand Martin Date: Tue, 28 Jul 2026 01:41:11 +0200 Subject: [PATCH 05/13] Bound the active Receive by the remaining waitFor(Duration) deadline (Codex P2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit waitFor(Duration) could overshoot by a whole inactivity timeout: the loop only checked the clock between round trips, and a silent command left the wait blocked in a Receive bounded by the (much larger) per-response timeout. The remaining wait now bounds the round trip itself, using the protocol's own mechanism: CommandCursor gains poll(maxWaitMillis) (default: next()), backed by RemoteCommand.pollChunk, which sends the Receive with OperationTimeout = remaining wait. A compliant server answers within that — with output, or with the "nothing yet" op-timeout fault, which the poll returns as an EMPTY chunk instead of failing: an expired bounded wait is not an inactivity timeout, the connection stays in sync (the socket keeps the fault-wins headroom on purpose), and the process remains fully usable. Regression test: a wait of 200 ms against a silent command sends a Receive whose OperationTimeout is sub-second (not the 10 s inactivity bound), returns false on the fault, and the process then completes normally. Co-Authored-By: Claude Fable 5 --- .../org/metricshub/winrm/CommandCursor.java | 19 +++++++ .../org/metricshub/winrm/RemoteProcess.java | 32 ++++++++--- .../winrm/light/LightWinRMService.java | 10 +++- .../metricshub/winrm/light/WsmanClient.java | 53 ++++++++++++++++++- .../metricshub/winrm/StreamingApiTest.java | 31 +++++++++++ 5 files changed, 135 insertions(+), 10 deletions(-) diff --git a/src/main/java/org/metricshub/winrm/CommandCursor.java b/src/main/java/org/metricshub/winrm/CommandCursor.java index 607135f..6445d88 100644 --- a/src/main/java/org/metricshub/winrm/CommandCursor.java +++ b/src/main/java/org/metricshub/winrm/CommandCursor.java @@ -52,6 +52,25 @@ public interface CommandCursor extends AutoCloseable { */ 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(); + } + /** * Get the command's exit code. * diff --git a/src/main/java/org/metricshub/winrm/RemoteProcess.java b/src/main/java/org/metricshub/winrm/RemoteProcess.java index b7ce743..83d9d24 100644 --- a/src/main/java/org/metricshub/winrm/RemoteProcess.java +++ b/src/main/java/org/metricshub/winrm/RemoteProcess.java @@ -137,22 +137,26 @@ public synchronized int waitFor() { /** * Wait at most the given duration for the command to complete — an overall deadline, on top of - * the per-response inactivity timeout. The deadline is checked between protocol round trips, - * so the wait can overshoot by up to one inactivity timeout. Expiry does not affect the - * command: it keeps running, and the caller decides whether to keep waiting or {@link #close()}. + * the per-response inactivity timeout. The remaining wait also bounds the active protocol + * round trip (the server is asked to answer within it), so the wait returns promptly at the + * deadline instead of hanging on a silent command. Expiry does not affect the command: it + * keeps running, the process stays fully usable, and the caller decides whether to keep + * waiting or {@link #close()}. * * @param deadline how long to wait (at least one millisecond) * @return {@code true} when the command completed within the given duration — the exit code is * then available from {@link #exitCode()} — {@code false} when the wait expired first - * @throws WinRMTimeoutException when the command stays silent for a whole inactivity timeout + * @throws WinRMTimeoutException when the server does not even answer the bounded requests * @throws org.metricshub.winrm.exceptions.WinRMClientException for any other failure */ public synchronized boolean waitFor(final Duration deadline) { WinRMClient.checkPositive(deadline, "deadline"); final long deadlineMillis = WinRMClient.toMillis(deadline); final long start = Utils.getCurrentTimeMillis(); - while (!finished && Utils.getCurrentTimeMillis() - start < deadlineMillis) { - fetchOnce(); + long remaining = deadlineMillis; + while (!finished && remaining > 0) { + absorb(advance(remaining)); + remaining = deadlineMillis - (Utils.getCurrentTimeMillis() - start); } if (finished && exitCode == null) { throw new IllegalStateException("The process was closed before the command completed."); @@ -211,9 +215,17 @@ private int exitCodeValue() { /** One Receive round trip: decode what arrived into the per-channel buffers. Holds the monitor. */ private void fetchOnce() { - final CommandCursor.Chunk chunk; + absorb(advance(-1)); + } + + /** + * One protocol round trip: unbounded ({@code maxWaitMillis < 0}, the inactivity timeout + * governs) or bounded to the given wait (a deadline-driven poll whose expiry is an empty + * chunk, not a failure). Holds the monitor. + */ + private CommandCursor.Chunk advance(final long maxWaitMillis) { try { - chunk = cursor.next(); + return maxWaitMillis < 0 ? cursor.next() : cursor.poll(maxWaitMillis); } catch (final TimeoutException e) { throw new WinRMTimeoutException( String.format("Command produced no output within %s on %s", timeout, hostname), @@ -222,6 +234,10 @@ private void fetchOnce() { } catch (final WindowsRemoteException e) { throw WinRMClient.translate(e); } + } + + /** Absorb one round trip's outcome into the process state. Holds the monitor. */ + private void absorb(final CommandCursor.Chunk chunk) { if (chunk == null) { finished = true; exitCode = cursor.exitCode(); diff --git a/src/main/java/org/metricshub/winrm/light/LightWinRMService.java b/src/main/java/org/metricshub/winrm/light/LightWinRMService.java index a47daa4..25894ac 100644 --- a/src/main/java/org/metricshub/winrm/light/LightWinRMService.java +++ b/src/main/java/org/metricshub/winrm/light/LightWinRMService.java @@ -299,7 +299,15 @@ public CommandCursor startCommand(final String command, final String workingDire return new CommandCursor() { @Override public Chunk next() throws TimeoutException, WindowsRemoteException { - final WsmanClient.RemoteCommand.Chunk chunk = callStreaming(remoteCommand::nextChunk); + return adapt(callStreaming(remoteCommand::nextChunk)); + } + + @Override + public Chunk poll(final long maxWaitMillis) throws TimeoutException, WindowsRemoteException { + return adapt(callStreaming(() -> remoteCommand.pollChunk(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 aa48f87..f941162 100644 --- a/src/main/java/org/metricshub/winrm/light/WsmanClient.java +++ b/src/main/java/org/metricshub/winrm/light/WsmanClient.java @@ -549,7 +549,58 @@ Chunk nextChunk() throws Exception { finish(); return null; } - final Decoded resp = receiveOutput(); + return toChunk(receiveOutput()); + } + + /** + * Bounded variant of {@link #nextChunk()}: one Receive round trip whose WSMan + * OperationTimeout is the given wait, so a compliant server answers within it — with output, + * or with the "nothing yet" op-timeout fault, which is returned as an EMPTY chunk instead of + * failing the handle. This is how a deadline-bounded wait polls without risking the + * connection: the fault is the protocol's clean expiry, the exchange completes, and the + * command (and this handle) remain fully usable. Returns {@code null} exactly like + * {@link #nextChunk()} once the command has completed. + * + * @param maxWaitMs how long the server may hold the Receive, capped by the handle's own + * per-round-trip timeout + */ + Chunk pollChunk(final long maxWaitMs) throws Exception { + if (finished) { + return null; + } + if (exitCode != null) { + finish(); + return null; + } + final long wait = Math.max(1, Math.min(maxWaitMs, operationTimeoutMs)); + // The socket gets the blocking-style headroom on purpose: the expected answer to an + // expired bounded Receive is the op-timeout FAULT, and it must win the race against the + // socket timeout or the poll would desync the connection it is supposed to leave intact. + transport.operationTimeout(toSocketTimeoutMillis(wait)); + try { + checkNotCancelled(); + final Decoded resp; + try { + resp = request(Envelopes.receive(url, shellId, commandId, wait)); + } catch (final SocketTimeoutException e) { + throw quietTimeout("No response from the WinRM service", wait, e); + } + if (resp.status != 200) { + if (FAULT_OPERATION_TIMEOUT.equals(wsmanFaultCode(resp.document))) { + // Nothing yet: the bounded wait elapsed server-side. + return new Chunk(new byte[0], new byte[0]); + } + throw faultException("Receive", resp); + } + return toChunk(resp); + } finally { + // Back to the strict streaming bound for the ordinary (unbounded) fetches. + transport.inactivityTimeout(toSocketTimeoutMillis(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(); final ByteArrayOutputStream stderr = new ByteArrayOutputStream(); collectStreams(resp.document, stdout, stderr); diff --git a/src/test/java/org/metricshub/winrm/StreamingApiTest.java b/src/test/java/org/metricshub/winrm/StreamingApiTest.java index a465843..bff3c62 100644 --- a/src/test/java/org/metricshub/winrm/StreamingApiTest.java +++ b/src/test/java/org/metricshub/winrm/StreamingApiTest.java @@ -468,6 +468,37 @@ void closingTheClientWhileAProcessIsOpenLeavesTheProcessInert() throws Exception } } + @Test + void waitForDeadlineBoundsTheActiveReceive() throws Exception { + enqueueCommandStartup(); + server + // Answers the bounded Receive after the wait would have expired: with a compliant server + // this is the "nothing yet" op-timeout fault at the requested OperationTimeout. It must + // read as an expired poll, NOT as an inactivity failure — the handle stays usable. + .enqueueDelayed(500, fault(FAULT_OPERATION_TIMEOUT, "The operation timed out."), 300) + .enqueue(200, envelope(receiveResponse(stdoutChunk("done\n"), done(COMMAND_ID, 3)))) + .enqueue(200, envelope(signalResponse())); + + try (WinRMClient client = builder().build()) { + try (RemoteProcess process = client.command("slow.exe").charset(StandardCharsets.UTF_8).start()) { + assertFalse(process.waitFor(Duration.ofMillis(200)), "the command must still be running"); + + // The active Receive was bounded by the remaining wait, not by the 10 s inactivity + // timeout: its WSMan OperationTimeout is sub-second. + final String boundedReceive = server.decryptedRequests().get(2); + assertTrue(boundedReceive.contains("/Receive"), boundedReceive); + assertTrue( + boundedReceive.contains("PT0."), + "the bounded Receive must carry the remaining wait as its OperationTimeout" + ); + + // The expired wait was non-destructive: the process completes normally afterward. + assertEquals(3, process.waitFor()); + assertEquals("done", process.stdout().readLine()); + } + } + } + @Test void commandSilenceBeyondTheTimeoutSurfacesAsInactivityTimeout() throws Exception { enqueueCommandStartup(); From 978f5c098500889cea7a3f9ed22f932e66c7c74e Mon Sep 17 00:00:00 2001 From: Bertrand Martin Date: Tue, 28 Jul 2026 01:49:09 +0200 Subject: [PATCH 06/13] Keep bounded waits near the requested deadline even against a dead peer (Codex P2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The bounded poll's socket read timeout no longer reuses the blocking paths' ten-second fault headroom: HttpTransport.pollTimeout(wait) caps the read at wait + 1 s — enough for a live server's "nothing yet" op-timeout fault (generated the instant the requested wait expires) to cross the network and win the race, small enough that a peer that stopped answering entirely cannot hold waitFor(Duration.ofMillis(200)) hostage for ten seconds. When even that expires, the Receive was abandoned mid-flight, so the transport is dropped outright before reporting the timeout: a late response must not be readable as the answer to a later request — the next operation (e.g. the close-path Signal) reconnects and re-authenticates cleanly. Regression test: a peer answering 4 s late fails a 200 ms wait in ~1.2 s (not 10.2 s), and close() still terminates the command over a fresh connection. Co-Authored-By: Claude Fable 5 --- .../org/metricshub/winrm/RemoteProcess.java | 4 ++- .../metricshub/winrm/light/HttpTransport.java | 13 ++++++++++ .../metricshub/winrm/light/WsmanClient.java | 14 +++++++--- .../metricshub/winrm/StreamingApiTest.java | 26 +++++++++++++++++++ 4 files changed, 52 insertions(+), 5 deletions(-) diff --git a/src/main/java/org/metricshub/winrm/RemoteProcess.java b/src/main/java/org/metricshub/winrm/RemoteProcess.java index 83d9d24..bbe3662 100644 --- a/src/main/java/org/metricshub/winrm/RemoteProcess.java +++ b/src/main/java/org/metricshub/winrm/RemoteProcess.java @@ -146,7 +146,9 @@ public synchronized int waitFor() { * @param deadline how long to wait (at least one millisecond) * @return {@code true} when the command completed within the given duration — the exit code is * then available from {@link #exitCode()} — {@code false} when the wait expired first - * @throws WinRMTimeoutException when the server does not even answer the bounded requests + * @throws WinRMTimeoutException when the server does not even answer the bounded requests — a + * peer that stopped answering is detected within the remaining wait plus a small + * network headroom, not the full inactivity timeout * @throws org.metricshub.winrm.exceptions.WinRMClientException for any other failure */ public synchronized boolean waitFor(final Duration deadline) { diff --git a/src/main/java/org/metricshub/winrm/light/HttpTransport.java b/src/main/java/org/metricshub/winrm/light/HttpTransport.java index b14a57d..b5ed624 100644 --- a/src/main/java/org/metricshub/winrm/light/HttpTransport.java +++ b/src/main/java/org/metricshub/winrm/light/HttpTransport.java @@ -100,6 +100,19 @@ void operationTimeout(final int operationTimeoutMillis) { applyTimeouts(operationTimeoutMillis, operationTimeoutMillis + 10_000); } + /** + * Socket timeouts for one deadline-bounded poll round trip: the read timeout is the wait plus + * a small fault headroom — enough for the server's "nothing yet" op-timeout fault (generated + * the moment the requested wait expires) to cross the network, yet small enough that a peer + * that stopped answering entirely cannot hold a deadline-bounded wait hostage the way the + * blocking paths' ten-second headroom would. + * + * @param waitMillis the bounded wait of this round trip in milliseconds + */ + void pollTimeout(final int waitMillis) { + applyTimeouts(waitMillis, waitMillis + 1_000); + } + /** * Align the socket timeouts with a STREAMING operation's inactivity timeout. Unlike * {@link #operationTimeout(int)} the read timeout gets NO headroom: the streaming paths have diff --git a/src/main/java/org/metricshub/winrm/light/WsmanClient.java b/src/main/java/org/metricshub/winrm/light/WsmanClient.java index f941162..c1bc485 100644 --- a/src/main/java/org/metricshub/winrm/light/WsmanClient.java +++ b/src/main/java/org/metricshub/winrm/light/WsmanClient.java @@ -573,16 +573,22 @@ Chunk pollChunk(final long maxWaitMs) throws Exception { return null; } final long wait = Math.max(1, Math.min(maxWaitMs, operationTimeoutMs)); - // The socket gets the blocking-style headroom on purpose: the expected answer to an - // expired bounded Receive is the op-timeout FAULT, and it must win the race against the - // socket timeout or the poll would desync the connection it is supposed to leave intact. - transport.operationTimeout(toSocketTimeoutMillis(wait)); + // The socket gets a small fault headroom on purpose: the expected answer to an expired + // bounded Receive is the op-timeout FAULT, and it must win the race against the socket + // timeout or the poll would desync the connection it is supposed to leave intact. It is + // deliberately much smaller than the blocking paths' headroom, so a peer that stopped + // answering entirely cannot hold a deadline-bounded wait far past its deadline. + transport.pollTimeout(toSocketTimeoutMillis(wait)); try { checkNotCancelled(); final Decoded resp; try { resp = request(Envelopes.receive(url, shellId, commandId, wait)); } catch (final SocketTimeoutException e) { + // The peer did not even answer the bounded request it was asked to answer within + // the wait. The Receive is abandoned mid-flight, so drop the connection outright: + // a late response must not be readable as the answer to a LATER request. + transport.close(); throw quietTimeout("No response from the WinRM service", wait, e); } if (resp.status != 200) { diff --git a/src/test/java/org/metricshub/winrm/StreamingApiTest.java b/src/test/java/org/metricshub/winrm/StreamingApiTest.java index bff3c62..f86d63c 100644 --- a/src/test/java/org/metricshub/winrm/StreamingApiTest.java +++ b/src/test/java/org/metricshub/winrm/StreamingApiTest.java @@ -499,6 +499,32 @@ void waitForDeadlineBoundsTheActiveReceive() throws Exception { } } + @Test + void deadPeerCannotHoldABoundedWaitHostage() throws Exception { + enqueueCommandStartup(); + server + // The peer answers the bounded Receive long after the wait AND its small fault headroom: + // a peer that stopped answering. The wait must fail at wait + headroom (~1.2 s here), + // not at the blocking paths' ten-second socket headroom. + .enqueueDelayed(500, fault(FAULT_OPERATION_TIMEOUT, "The operation timed out."), 4_000) + .enqueue(200, envelope(signalResponse())); + + 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))); + final long elapsedMillis = (System.nanoTime() - start) / 1_000_000; + assertTrue( + elapsedMillis < 3_000, + "a dead peer must be detected near the bounded wait, not " + elapsedMillis + " ms later" + ); + } + // close() terminated the command over a fresh connection (the abandoned one was dropped). + final List requests = server.decryptedRequests(); + assertTrue(requests.get(requests.size() - 1).contains("signal/terminate")); + } + } + @Test void commandSilenceBeyondTheTimeoutSurfacesAsInactivityTimeout() throws Exception { enqueueCommandStartup(); From 4563e722480b8fa1e1d7e166d47d801c7ec46a69 Mon Sep 17 00:00:00 2001 From: Bertrand Martin Date: Tue, 28 Jul 2026 01:59:57 +0200 Subject: [PATCH 07/13] Share one absolute deadline across every leg of a bounded poll (Codex P2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A bounded poll's socket timeout was per-HTTP-round-trip: when the poll had to re-establish the connection, the NTLM probe, the challenge exchange and the final Receive each got a fresh wait+1s, letting a slow peer stretch a deadline-bounded waitFor(Duration) to several multiples of the requested wait. HttpTransport.pollTimeout(wait) now also records an absolute deadline (now + wait + headroom); every socket wait until the next timeout-mode switch — the reconnect's connect timeout, and each leg's read timeout, re-capped at the start of every post() — gets only what is left of that budget (1 ms floor, so an expired deadline fails fast instead of turning into an infinite socket wait). The other timeout modes clear the deadline. Unit test: a server answering every leg after 600 ms (fast enough for any single leg) against a 500 ms poll — the third leg must run out of the shared 1.5 s budget instead of the sequence running to 8 x 600 ms. Co-Authored-By: Claude Fable 5 --- .../metricshub/winrm/light/HttpTransport.java | 43 ++++++- .../light/HttpTransportDeadlineTest.java | 121 ++++++++++++++++++ 2 files changed, 157 insertions(+), 7 deletions(-) create mode 100644 src/test/java/org/metricshub/winrm/light/HttpTransportDeadlineTest.java diff --git a/src/main/java/org/metricshub/winrm/light/HttpTransport.java b/src/main/java/org/metricshub/winrm/light/HttpTransport.java index b5ed624..5eff065 100644 --- a/src/main/java/org/metricshub/winrm/light/HttpTransport.java +++ b/src/main/java/org/metricshub/winrm/light/HttpTransport.java @@ -65,6 +65,10 @@ final class HttpTransport implements AutoCloseable { private int connectTimeoutMillis; private int readTimeoutMillis; + // Absolute bound (epoch ms, 0 = none) on every socket wait while a deadline-bounded poll is + // active — see pollTimeout(int). Cleared by the other timeout modes. + private long deadlineEpochMillis; + HttpTransport(final String host, final int port, final int timeoutMillis) { this(host, port, timeoutMillis, null, false); } @@ -97,7 +101,7 @@ final class HttpTransport implements AutoCloseable { * @param operationTimeoutMillis the current operation's timeout in milliseconds */ void operationTimeout(final int operationTimeoutMillis) { - applyTimeouts(operationTimeoutMillis, operationTimeoutMillis + 10_000); + applyTimeouts(operationTimeoutMillis, operationTimeoutMillis + 10_000, 0); } /** @@ -106,11 +110,16 @@ void operationTimeout(final int operationTimeoutMillis) { * the moment the requested wait expires) to cross the network, yet small enough that a peer * that stopped answering entirely cannot hold a deadline-bounded wait hostage the way the * blocking paths' ten-second headroom would. + * The wait-plus-headroom also becomes an ABSOLUTE deadline shared by every socket operation + * until the next timeout-mode switch: one poll may span several HTTP round trips (a dropped + * connection forces a reconnect and a whole re-authentication exchange), and each leg must + * only get what is left of the poll's budget — not a fresh full timeout each, which would let + * a slow peer stretch a deadline-bounded wait to several multiples of the requested duration. * * @param waitMillis the bounded wait of this round trip in milliseconds */ void pollTimeout(final int waitMillis) { - applyTimeouts(waitMillis, waitMillis + 1_000); + applyTimeouts(waitMillis, waitMillis + 1_000, Utils.getCurrentTimeMillis() + waitMillis + 1_000L); } /** @@ -124,21 +133,35 @@ void pollTimeout(final int waitMillis) { * @param inactivityTimeoutMillis the longest tolerated silence in milliseconds */ void inactivityTimeout(final int inactivityTimeoutMillis) { - applyTimeouts(inactivityTimeoutMillis, inactivityTimeoutMillis); + applyTimeouts(inactivityTimeoutMillis, inactivityTimeoutMillis, 0); } - private void applyTimeouts(final int connectMillis, final int readMillis) { + private void applyTimeouts(final int connectMillis, final int readMillis, final long deadline) { + deadlineEpochMillis = deadline; connectTimeoutMillis = connectMillis; readTimeoutMillis = readMillis; if (socket != null && !socket.isClosed()) { try { - socket.setSoTimeout(readTimeoutMillis); + socket.setSoTimeout(boundedByDeadline(readTimeoutMillis)); } catch (final IOException ignored) { // the next read fails and request() re-establishes the connection } } } + /** + * Cap a configured timeout by what is left of the poll deadline, when one is active. The 1 ms + * floor keeps an already-expired deadline from disabling the timeout (0 would mean "infinite" + * to a socket): the next blocking operation then fails almost immediately instead. + */ + private int boundedByDeadline(final int timeoutMillis) { + if (deadlineEpochMillis == 0) { + return timeoutMillis; + } + final long remaining = deadlineEpochMillis - Utils.getCurrentTimeMillis(); + return (int) Math.max(1, Math.min(timeoutMillis, remaining)); + } + static final class Response { final int status; @@ -258,8 +281,8 @@ private void ensureConnected() throws IOException { params.setEndpointIdentificationAlgorithm("HTTPS"); sslSocket.setSSLParameters(params); } - newSocket.connect(new InetSocketAddress(host, port), connectTimeoutMillis); - newSocket.setSoTimeout(readTimeoutMillis); + newSocket.connect(new InetSocketAddress(host, port), boundedByDeadline(connectTimeoutMillis)); + newSocket.setSoTimeout(boundedByDeadline(readTimeoutMillis)); if (newSocket instanceof SSLSocket) { // Force the TLS handshake now so certificate/hostname failures surface here, not on // the first read after we have already sent the request. @@ -289,6 +312,12 @@ Response post(final String path, final byte[] body, final String contentType, fi throws IOException { ensureConnected(); try { + if (deadlineEpochMillis != 0) { + // Several HTTP legs can run under one poll deadline (reconnect, authentication + // exchange, the request itself): re-cap the read wait to what is left of the budget + // at the start of every leg. + socket.setSoTimeout(boundedByDeadline(readTimeoutMillis)); + } final StringBuilder head = new StringBuilder(); head.append("POST ").append(path).append(" HTTP/1.1\r\n"); head.append("Accept: */*\r\n"); diff --git a/src/test/java/org/metricshub/winrm/light/HttpTransportDeadlineTest.java b/src/test/java/org/metricshub/winrm/light/HttpTransportDeadlineTest.java new file mode 100644 index 0000000..7c7e1c0 --- /dev/null +++ b/src/test/java/org/metricshub/winrm/light/HttpTransportDeadlineTest.java @@ -0,0 +1,121 @@ +package org.metricshub.winrm.light; + +/*- + * ╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲ + * 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.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.BufferedInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.net.ServerSocket; +import java.net.Socket; +import java.net.SocketTimeoutException; +import java.nio.charset.StandardCharsets; +import org.junit.jupiter.api.Test; + +/** + * Unit test of the {@link HttpTransport#pollTimeout(int)} deadline: one deadline-bounded poll may + * span several HTTP round trips (a reconnect plus a re-authentication exchange), and every leg + * must be capped by what is LEFT of the poll's budget — a peer answering each leg just fast enough + * must not be able to stretch the poll to several multiples of the requested wait. + */ +class HttpTransportDeadlineTest { + + @Test + void everyLegOfABoundedPollSharesOneDeadline() throws Exception { + try (ServerSocket server = new ServerSocket(0)) { + final Thread handler = new Thread(() -> serveSlowly(server), "slow-http-server"); + handler.setDaemon(true); + handler.start(); + + final HttpTransport transport = new HttpTransport("127.0.0.1", server.getLocalPort(), 60_000); + try { + // Budget: 500 ms wait + 1 s fault headroom = 1.5 s for EVERY leg together. The server + // answers each leg after 600 ms — fast enough for any single leg, so only the shared + // deadline can stop the sequence (leg 1 at ~0.6 s, leg 2 at ~1.2 s, leg 3 runs out). + transport.pollTimeout(500); + final long start = System.nanoTime(); + assertThrows( + SocketTimeoutException.class, + () -> { + for (int leg = 0; leg < 8; leg++) { + transport.post("/wsman", new byte[0], null, null); + } + } + ); + final long elapsedMillis = (System.nanoTime() - start) / 1_000_000; + assertTrue( + elapsedMillis < 3_000, + "the legs must share the poll deadline, not get a fresh timeout each; took " + elapsedMillis + " ms" + ); + } finally { + transport.close(); + } + } // closing the ServerSocket unblocks the handler thread + } + + /** Serve every request of every connection with a minimal 200 response, 600 ms late. */ + private static void serveSlowly(final ServerSocket server) { + try { + while (true) { + final Socket socket = server.accept(); + final Thread connection = new Thread(() -> serveConnection(socket), "slow-http-conn"); + connection.setDaemon(true); + connection.start(); + } + } catch (final IOException ignored) { + // server socket closed: test over + } + } + + private static void serveConnection(final Socket socket) { + try (socket) { + final InputStream in = new BufferedInputStream(socket.getInputStream()); + final OutputStream out = socket.getOutputStream(); + while (readRequestHead(in)) { + Thread.sleep(600); + out.write("HTTP/1.1 200 OK\r\nContent-Length: 0\r\n\r\n".getBytes(StandardCharsets.ISO_8859_1)); + out.flush(); + } + } catch (final IOException | InterruptedException ignored) { + // connection torn down: client timed out or test over + } + } + + /** Consume one request head (the posts of this test carry no body). */ + private static boolean readRequestHead(final InputStream in) throws IOException { + int matched = 0; + int b; + while ((b = in.read()) != -1) { + // A request head ends with CRLFCRLF. + if ((matched % 2 == 0 && b == '\r') || (matched % 2 == 1 && b == '\n')) { + if (++matched == 4) { + return true; + } + } else { + matched = b == '\r' ? 1 : 0; + } + } + return false; + } +} From 46679c7fda3df5d9addd2c69d1c201860f81857c Mon Sep 17 00:00:00 2001 From: Bertrand Martin Date: Tue, 28 Jul 2026 02:08:15 +0200 Subject: [PATCH 08/13] Re-cap the socket timeout before every blocking read of a bounded poll (Codex P2) SO_TIMEOUT applies to each read independently: a peer trickling a response one byte at a time during a deadline-bounded poll reset its clock with every byte, stretching status/header/body parsing arbitrarily past the poll's absolute deadline. Every blocking read of the response now goes through beforeBlockingRead(), which re-caps SO_TIMEOUT to what is left of the deadline (skipping the syscall outside poll mode, and while buffered data makes the next read non-blocking). Unit test: a peer trickling one byte per 300 ms against a 500 ms poll is cut off by the shared 1.5 s budget instead of taking ~12 s for the ~40-byte response. Co-Authored-By: Claude Fable 5 --- .../metricshub/winrm/light/HttpTransport.java | 22 ++++++++- .../light/HttpTransportDeadlineTest.java | 45 +++++++++++++++++++ 2 files changed, 66 insertions(+), 1 deletion(-) diff --git a/src/main/java/org/metricshub/winrm/light/HttpTransport.java b/src/main/java/org/metricshub/winrm/light/HttpTransport.java index 5eff065..3ee5300 100644 --- a/src/main/java/org/metricshub/winrm/light/HttpTransport.java +++ b/src/main/java/org/metricshub/winrm/light/HttpTransport.java @@ -405,7 +405,7 @@ private String readLine() throws IOException { final ByteArrayOutputStream buffer = new ByteArrayOutputStream(); int b; int prev = -1; - while ((b = in.read()) != -1) { + while ((b = readByte()) != -1) { if (prev == '\r' && b == '\n') { final byte[] raw = buffer.toByteArray(); return new String(raw, 0, raw.length - 1, StandardCharsets.ISO_8859_1); @@ -420,6 +420,7 @@ private byte[] readFixed(final int length) throws IOException { final byte[] buffer = new byte[length]; int read = 0; while (read < length) { + beforeBlockingRead(); final int n = in.read(buffer, read, length - read); if (n < 0) { throw new IOException("Unexpected EOF: got " + read + " of " + length + " body bytes"); @@ -429,6 +430,25 @@ private byte[] readFixed(final int length) throws IOException { return buffer; } + /** One byte of the response, its blocking wait re-capped by the poll deadline. */ + private int readByte() throws IOException { + beforeBlockingRead(); + return in.read(); + } + + /** + * Re-cap the socket timeout by what is left of the poll deadline before a blocking read. + * {@code SO_TIMEOUT} applies to EACH read independently: without this, a peer trickling a + * response one byte at a time would reset its clock with every byte and stretch a + * deadline-bounded poll arbitrarily past the deadline. Costs nothing outside poll mode, and + * skips the syscall while buffered data makes the next read non-blocking. + */ + private void beforeBlockingRead() throws IOException { + if (deadlineEpochMillis != 0 && in.available() == 0) { + socket.setSoTimeout(boundedByDeadline(readTimeoutMillis)); + } + } + private byte[] readChunked() throws IOException { final ByteArrayOutputStream body = new ByteArrayOutputStream(); while (true) { diff --git a/src/test/java/org/metricshub/winrm/light/HttpTransportDeadlineTest.java b/src/test/java/org/metricshub/winrm/light/HttpTransportDeadlineTest.java index 7c7e1c0..be2857b 100644 --- a/src/test/java/org/metricshub/winrm/light/HttpTransportDeadlineTest.java +++ b/src/test/java/org/metricshub/winrm/light/HttpTransportDeadlineTest.java @@ -74,6 +74,51 @@ void everyLegOfABoundedPollSharesOneDeadline() throws Exception { } // closing the ServerSocket unblocks the handler thread } + @Test + void aTricklingPeerCannotStretchABoundedPollPastItsDeadline() throws Exception { + try (ServerSocket server = new ServerSocket(0)) { + final Thread handler = new Thread( + () -> { + // One connection: read the request head, then trickle the response one byte + // every 300 ms. SO_TIMEOUT applies per read, so without the deadline every byte + // would reset the clock and the ~40-byte response would take ~12 s. + try (Socket socket = server.accept()) { + final InputStream in = new BufferedInputStream(socket.getInputStream()); + final OutputStream out = socket.getOutputStream(); + if (readRequestHead(in)) { + for (final byte b : "HTTP/1.1 200 OK\r\nContent-Length: 0\r\n\r\n".getBytes( + StandardCharsets.ISO_8859_1 + )) { + Thread.sleep(300); + out.write(b); + out.flush(); + } + } + } catch (final IOException | InterruptedException ignored) { + // client timed out or test over + } + }, + "trickle-http-server" + ); + handler.setDaemon(true); + handler.start(); + + final HttpTransport transport = new HttpTransport("127.0.0.1", server.getLocalPort(), 60_000); + try { + transport.pollTimeout(500); + final long start = System.nanoTime(); + assertThrows(SocketTimeoutException.class, () -> transport.post("/wsman", new byte[0], null, null)); + final long elapsedMillis = (System.nanoTime() - start) / 1_000_000; + assertTrue( + elapsedMillis < 3_000, + "each response byte must be capped by the shared deadline; took " + elapsedMillis + " ms" + ); + } finally { + transport.close(); + } + } + } + /** Serve every request of every connection with a minimal 200 response, 600 ms late. */ private static void serveSlowly(final ServerSocket server) { try { From daf662114b579cdc7538e4c5ff3f8d4a9012be5d Mon Sep 17 00:00:00 2001 From: Bertrand Martin Date: Tue, 28 Jul 2026 02:20:06 +0200 Subject: [PATCH 09/13] Make a bounded poll's wait a hard bound: no headroom outside the deadline (Codex P2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The fixed 1 s fault headroom sat ON TOP of the requested wait, so waitFor(Duration.ofMillis(1)) could block for about a second. The transit slack for the server's "nothing yet" op-timeout fault is now carved out of the INSIDE of the budget instead: the Receive asks the server to answer at budget - min(1s, budget/2), and the socket cuts at the budget itself — pollTimeout(budget) adds nothing on top. A wait too short for any network round trip (< 100 ms) is waited out locally, without touching the wire. Tests: a 2 s poll sends OperationTimeout PT1S and treats the fault arriving within the budget as "nothing yet" (cursor stays fully usable); a dead peer fails a 200 ms wait at ~200 ms with the server asked to answer in PT0.1S; a sub-round-trip wait leaves the wire untouched and the process readable. Co-Authored-By: Claude Fable 5 --- .../org/metricshub/winrm/RemoteProcess.java | 16 +++--- .../metricshub/winrm/light/HttpTransport.java | 26 +++++----- .../metricshub/winrm/light/WsmanClient.java | 52 +++++++++++-------- .../metricshub/winrm/StreamingApiTest.java | 49 ++++++++++------- .../light/HttpTransportDeadlineTest.java | 10 ++-- 5 files changed, 86 insertions(+), 67 deletions(-) diff --git a/src/main/java/org/metricshub/winrm/RemoteProcess.java b/src/main/java/org/metricshub/winrm/RemoteProcess.java index bbe3662..dc60f42 100644 --- a/src/main/java/org/metricshub/winrm/RemoteProcess.java +++ b/src/main/java/org/metricshub/winrm/RemoteProcess.java @@ -137,18 +137,18 @@ public synchronized int waitFor() { /** * 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 also bounds the active protocol - * round trip (the server is asked to answer within it), so the wait returns promptly at the - * deadline instead of hanging on a silent command. Expiry does not affect the command: it - * keeps running, the process stays fully usable, and the caller decides whether to keep - * waiting or {@link #close()}. + * the per-response inactivity timeout. The remaining wait is a hard bound on the active + * protocol round trip: the server is asked to answer early enough for its reply to arrive + * within it, and a wait too short for any network round trip is waited out locally without + * touching the wire. Expiry does not affect the command: it keeps running, the process stays + * fully usable, and the caller decides whether to keep waiting or {@link #close()}. * * @param deadline how long to wait (at least one millisecond) * @return {@code true} when the command completed within the given duration — the exit code is * then available from {@link #exitCode()} — {@code false} when the wait expired first - * @throws WinRMTimeoutException when the server does not even answer the bounded requests — a - * peer that stopped answering is detected within the remaining wait plus a small - * network headroom, not the full inactivity timeout + * @throws WinRMTimeoutException when the server does not answer the bounded requests within + * the remaining wait — a peer that stopped answering cannot hold the wait past its + * deadline * @throws org.metricshub.winrm.exceptions.WinRMClientException for any other failure */ public synchronized boolean waitFor(final Duration deadline) { diff --git a/src/main/java/org/metricshub/winrm/light/HttpTransport.java b/src/main/java/org/metricshub/winrm/light/HttpTransport.java index 3ee5300..afb52a7 100644 --- a/src/main/java/org/metricshub/winrm/light/HttpTransport.java +++ b/src/main/java/org/metricshub/winrm/light/HttpTransport.java @@ -105,21 +105,21 @@ void operationTimeout(final int operationTimeoutMillis) { } /** - * Socket timeouts for one deadline-bounded poll round trip: the read timeout is the wait plus - * a small fault headroom — enough for the server's "nothing yet" op-timeout fault (generated - * the moment the requested wait expires) to cross the network, yet small enough that a peer - * that stopped answering entirely cannot hold a deadline-bounded wait hostage the way the - * blocking paths' ten-second headroom would. - * The wait-plus-headroom also becomes an ABSOLUTE deadline shared by every socket operation - * until the next timeout-mode switch: one poll may span several HTTP round trips (a dropped - * connection forces a reconnect and a whole re-authentication exchange), and each leg must - * only get what is left of the poll's budget — not a fresh full timeout each, which would let - * a slow peer stretch a deadline-bounded wait to several multiples of the requested duration. + * Socket timeouts for one deadline-bounded poll round trip: the budget is the deadline itself + * — no headroom on top, or a peer that stopped answering could hold a deadline-bounded wait + * past its advertised bound (the caller carves the fault-transit slack out of the INSIDE of + * the budget instead, by asking the server to answer earlier than the budget). + *

+ * The budget also becomes an ABSOLUTE deadline shared by every socket operation until the + * next timeout-mode switch: one poll may span several HTTP round trips (a dropped connection + * forces a reconnect and a whole re-authentication exchange), and each leg must only get what + * is left of the budget — not a fresh full timeout each, which would let a slow peer stretch + * a deadline-bounded wait to several multiples of the requested duration. * - * @param waitMillis the bounded wait of this round trip in milliseconds + * @param budgetMillis the poll's whole budget in milliseconds */ - void pollTimeout(final int waitMillis) { - applyTimeouts(waitMillis, waitMillis + 1_000, Utils.getCurrentTimeMillis() + waitMillis + 1_000L); + void pollTimeout(final int budgetMillis) { + applyTimeouts(budgetMillis, budgetMillis, Utils.getCurrentTimeMillis() + budgetMillis); } /** diff --git a/src/main/java/org/metricshub/winrm/light/WsmanClient.java b/src/main/java/org/metricshub/winrm/light/WsmanClient.java index c1bc485..74b1149 100644 --- a/src/main/java/org/metricshub/winrm/light/WsmanClient.java +++ b/src/main/java/org/metricshub/winrm/light/WsmanClient.java @@ -55,6 +55,10 @@ 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; + // 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" // or "EnumerationContext" inside cannot be mistaken for the enumeration control element. @@ -553,16 +557,16 @@ Chunk nextChunk() throws Exception { } /** - * Bounded variant of {@link #nextChunk()}: one Receive round trip whose WSMan - * OperationTimeout is the given wait, so a compliant server answers within it — with output, - * or with the "nothing yet" op-timeout fault, which is returned as an EMPTY chunk instead of - * failing the handle. This is how a deadline-bounded wait polls without risking the - * connection: the fault is the protocol's clean expiry, the exchange completes, and the - * command (and this handle) remain fully usable. Returns {@code null} exactly like - * {@link #nextChunk()} once the command has completed. + * Bounded variant of {@link #nextChunk()}: block at most the given wait — a hard bound. A + * wire poll asks the server to answer EARLIER than the wait (the difference is transit + * slack for its "nothing yet" op-timeout fault to reach us before the socket cuts at the + * full wait); that fault is returned as an EMPTY chunk instead of failing the handle — + * the protocol's clean expiry, leaving the command and this handle fully usable. A wait + * too short for any network round trip is waited out locally instead. Returns {@code null} + * exactly like {@link #nextChunk()} once the command has completed. * - * @param maxWaitMs how long the server may hold the Receive, capped by the handle's own - * per-round-trip timeout + * @param maxWaitMs how long to block at most, capped by the handle's own per-round-trip + * timeout */ Chunk pollChunk(final long maxWaitMs) throws Exception { if (finished) { @@ -572,24 +576,30 @@ Chunk pollChunk(final long maxWaitMs) throws Exception { finish(); return null; } - final long wait = Math.max(1, Math.min(maxWaitMs, operationTimeoutMs)); - // The socket gets a small fault headroom on purpose: the expected answer to an expired - // bounded Receive is the op-timeout FAULT, and it must win the race against the socket - // timeout or the poll would desync the connection it is supposed to leave intact. It is - // deliberately much smaller than the blocking paths' headroom, so a peer that stopped - // answering entirely cannot hold a deadline-bounded wait far past its deadline. - transport.pollTimeout(toSocketTimeoutMillis(wait)); + final long budget = Math.max(1, Math.min(maxWaitMs, operationTimeoutMs)); + 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. + 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. + final long transit = Math.min(1_000, budget / 2); + transport.pollTimeout(toSocketTimeoutMillis(budget)); try { checkNotCancelled(); final Decoded resp; try { - resp = request(Envelopes.receive(url, shellId, commandId, wait)); + resp = request(Envelopes.receive(url, shellId, commandId, budget - transit)); } catch (final SocketTimeoutException e) { - // The peer did not even answer the bounded request it was asked to answer within - // the wait. The Receive is abandoned mid-flight, so drop the connection outright: - // a late response must not be readable as the answer to a LATER request. + // 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 + // late response must not be readable as the answer to a LATER request. transport.close(); - throw quietTimeout("No response from the WinRM service", wait, e); + throw quietTimeout("No response from the WinRM service", budget, e); } if (resp.status != 200) { if (FAULT_OPERATION_TIMEOUT.equals(wsmanFaultCode(resp.document))) { diff --git a/src/test/java/org/metricshub/winrm/StreamingApiTest.java b/src/test/java/org/metricshub/winrm/StreamingApiTest.java index f86d63c..6cd4bbe 100644 --- a/src/test/java/org/metricshub/winrm/StreamingApiTest.java +++ b/src/test/java/org/metricshub/winrm/StreamingApiTest.java @@ -428,8 +428,12 @@ void waitForDeadlineExpiresWhileTheCommandKeepsRunning() throws Exception { try (WinRMClient client = builder().build()) { try (RemoteProcess process = client.command("slow.exe").charset(StandardCharsets.UTF_8).start()) { - assertFalse(process.waitFor(Duration.ofMillis(100)), "the command must still be running"); - // The output that arrived while waiting stays readable. + // A wait too short for any network round trip is waited out locally: no request goes + // to the wire, and the process is untouched. + assertFalse(process.waitFor(Duration.ofMillis(50)), "the command must still be running"); + assertEquals(2, server.decryptedRequests().size(), "a sub-round-trip wait must not touch the wire"); + + // The process remains fully usable: reading advances the protocol as usual. assertEquals("slow", process.stdout().readLine()); } assertTrue(server.decryptedRequests().get(3).contains("signal/terminate")); @@ -469,32 +473,35 @@ void closingTheClientWhileAProcessIsOpenLeavesTheProcessInert() throws Exception } @Test - void waitForDeadlineBoundsTheActiveReceive() throws Exception { + void boundedPollTreatsAFaultWithinBudgetAsNothingYet() throws Exception { enqueueCommandStartup(); server - // Answers the bounded Receive after the wait would have expired: with a compliant server - // this is the "nothing yet" op-timeout fault at the requested OperationTimeout. It must - // read as an expired poll, NOT as an inactivity failure — the handle stays usable. - .enqueueDelayed(500, fault(FAULT_OPERATION_TIMEOUT, "The operation timed out."), 300) + // The "nothing yet" op-timeout fault answering the bounded poll, arriving well within + // the poll's budget — a compliant server answering at the shortened OperationTimeout. + .enqueueDelayed(500, fault(FAULT_OPERATION_TIMEOUT, "The operation timed out."), 150) .enqueue(200, envelope(receiveResponse(stdoutChunk("done\n"), done(COMMAND_ID, 3)))) .enqueue(200, envelope(signalResponse())); try (WinRMClient client = builder().build()) { - try (RemoteProcess process = client.command("slow.exe").charset(StandardCharsets.UTF_8).start()) { - assertFalse(process.waitFor(Duration.ofMillis(200)), "the command must still be running"); + try (CommandCursor cursor = client.executor().startCommand("run.exe", null, 10_000)) { + // The fault reads as an expired poll (empty chunk), not as a failure. + final CommandCursor.Chunk nothingYet = cursor.poll(2_000); + assertEquals(0, nothingYet.stdout().length + nothingYet.stderr().length); - // The active Receive was bounded by the remaining wait, not by the 10 s inactivity - // timeout: its WSMan OperationTimeout is sub-second. + // The bounded Receive asked the server to answer EARLY: its OperationTimeout is the + // budget minus the fault-transit slack, so the answer arrives within the budget. final String boundedReceive = server.decryptedRequests().get(2); assertTrue(boundedReceive.contains("/Receive"), boundedReceive); assertTrue( - boundedReceive.contains("PT0."), - "the bounded Receive must carry the remaining wait as its OperationTimeout" + boundedReceive.contains("PT1S<"), + "a 2 s poll must ask the server to answer within 1 s" ); - // The expired wait was non-destructive: the process completes normally afterward. - assertEquals(3, process.waitFor()); - assertEquals("done", process.stdout().readLine()); + // The expired poll was non-destructive: the cursor completes normally afterward. + final CommandCursor.Chunk chunk = cursor.next(); + assertEquals("done\n", new String(chunk.stdout(), StandardCharsets.UTF_8)); + assertNull(cursor.next()); + assertEquals(3, cursor.exitCode()); } } } @@ -503,9 +510,9 @@ void waitForDeadlineBoundsTheActiveReceive() throws Exception { void deadPeerCannotHoldABoundedWaitHostage() throws Exception { enqueueCommandStartup(); server - // The peer answers the bounded Receive long after the wait AND its small fault headroom: - // a peer that stopped answering. The wait must fail at wait + headroom (~1.2 s here), - // not at the blocking paths' ten-second socket headroom. + // The peer answers the bounded Receive long after the wait: a peer that stopped + // answering. The wait must fail AT its deadline — the socket cuts at the poll budget + // itself, with no headroom a dead peer could hide behind. .enqueueDelayed(500, fault(FAULT_OPERATION_TIMEOUT, "The operation timed out."), 4_000) .enqueue(200, envelope(signalResponse())); @@ -516,8 +523,10 @@ void deadPeerCannotHoldABoundedWaitHostage() throws Exception { final long elapsedMillis = (System.nanoTime() - start) / 1_000_000; assertTrue( elapsedMillis < 3_000, - "a dead peer must be detected near the bounded wait, not " + elapsedMillis + " ms later" + "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<")); } // close() terminated the command over a fresh connection (the abandoned one was dropped). final List requests = server.decryptedRequests(); diff --git a/src/test/java/org/metricshub/winrm/light/HttpTransportDeadlineTest.java b/src/test/java/org/metricshub/winrm/light/HttpTransportDeadlineTest.java index be2857b..0c8cfaf 100644 --- a/src/test/java/org/metricshub/winrm/light/HttpTransportDeadlineTest.java +++ b/src/test/java/org/metricshub/winrm/light/HttpTransportDeadlineTest.java @@ -50,10 +50,10 @@ void everyLegOfABoundedPollSharesOneDeadline() throws Exception { final HttpTransport transport = new HttpTransport("127.0.0.1", server.getLocalPort(), 60_000); try { - // Budget: 500 ms wait + 1 s fault headroom = 1.5 s for EVERY leg together. The server - // answers each leg after 600 ms — fast enough for any single leg, so only the shared - // deadline can stop the sequence (leg 1 at ~0.6 s, leg 2 at ~1.2 s, leg 3 runs out). - transport.pollTimeout(500); + // One 2 s budget for EVERY leg together. The server answers each leg after 600 ms — + // fast enough for any single leg, so only the shared deadline can stop the sequence + // (legs 1-3 complete by ~1.8 s, leg 4 runs out of budget at 2 s). + transport.pollTimeout(2_000); final long start = System.nanoTime(); assertThrows( SocketTimeoutException.class, @@ -105,7 +105,7 @@ void aTricklingPeerCannotStretchABoundedPollPastItsDeadline() throws Exception { final HttpTransport transport = new HttpTransport("127.0.0.1", server.getLocalPort(), 60_000); try { - transport.pollTimeout(500); + transport.pollTimeout(2_000); final long start = System.nanoTime(); assertThrows(SocketTimeoutException.class, () -> transport.post("/wsman", new byte[0], null, null)); final long elapsedMillis = (System.nanoTime() - start) / 1_000_000; From 58623c4d1e1653021944b93692625d31f902156d Mon Sep 17 00:00:00 2001 From: Bertrand Martin Date: Tue, 28 Jul 2026 02:27:44 +0200 Subject: [PATCH 10/13] Bound the completion Signal by the remaining poll budget (Codex P1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A bounded poll that discovered completion sent the terminate Signal under the full inactivity timeout: a server reporting Done promptly but stalling on the Signal answer could hold waitFor(Duration) far past its advertised hard deadline. The completion cleanup of a bounded poll now runs through finishBounded(): the Signal round trip gets the poll's remaining budget (pollTimeout + the shortened envelope OperationTimeout); a Signal answer that does not arrive in time is abandoned — the connection is dropped so the late response cannot desync a later request — and NEVER reported, because the command has completed and its exit code is known. A budget too small for any round trip skips the Signal outright, leaving the healthy connection untouched (the server reaps the completed command's state with the shell). The plain nextChunk()/close() paths keep their full-timeout Signal. Tests: a Signal stalling 3 s past a 1 s poll still reports completion at ~1 s with the exit code; a 20 ms-budget completion reports without touching the wire. Co-Authored-By: Claude Fable 5 --- .../metricshub/winrm/light/WsmanClient.java | 39 +++++++++++++++- .../metricshub/winrm/StreamingApiTest.java | 45 +++++++++++++++++++ 2 files changed, 82 insertions(+), 2 deletions(-) diff --git a/src/main/java/org/metricshub/winrm/light/WsmanClient.java b/src/main/java/org/metricshub/winrm/light/WsmanClient.java index 74b1149..80ef78b 100644 --- a/src/main/java/org/metricshub/winrm/light/WsmanClient.java +++ b/src/main/java/org/metricshub/winrm/light/WsmanClient.java @@ -508,7 +508,9 @@ RemoteCommand startCommand( * Holds {@link #connectionPermit} from creation until completion or {@link #close()}; both * paths send the terminate Signal, exactly like the pre-streaming receive loop did — Signal * after completion is part of the shell protocol, and Signal on early close is what actually - * stops the remote command. + * stops the remote command. The one exception is completion discovered inside a bounded poll + * (see {@link #finishBounded}), whose Signal is bounded by — or skipped for — the caller's + * remaining wait. */ final class RemoteCommand implements AutoCloseable { @@ -573,7 +575,9 @@ Chunk pollChunk(final long maxWaitMs) throws Exception { return null; } if (exitCode != null) { - finish(); + // The command completed with the previous chunk: Signal it — but under the poll's + // budget, never the full inactivity timeout of the plain fetches. + finishBounded(maxWaitMs); return null; } final long budget = Math.max(1, Math.min(maxWaitMs, operationTimeoutMs)); @@ -683,6 +687,37 @@ private void finish() throws Exception { } } + /** + * Completion cleanup under a poll budget: the Signal acknowledging the ALREADY-COMPLETED + * command must not outlive the caller's remaining wait. A Signal answer that does not + * arrive in time is abandoned (the connection is dropped so its late response cannot + * desync a later request) — never reported: the command completed and its exit code is + * known, and that must not be hidden behind a cleanup hiccup. A budget too small for any + * round trip skips the Signal outright, leaving the healthy connection untouched; the + * server reaps the completed command's state with the shell. Runs at most once. + */ + private void finishBounded(final long budgetMs) throws Exception { + if (finished) { + return; + } + finished = true; + try { + final long budget = Math.max(1, Math.min(budgetMs, operationTimeoutMs)); + if (!closed && budget >= MIN_WIRE_POLL_MS) { + transport.pollTimeout(toSocketTimeoutMillis(budget)); + try { + terminate(commandId, budget); + } catch (final SocketTimeoutException e) { + transport.close(); + } finally { + transport.inactivityTimeout(toSocketTimeoutMillis(operationTimeoutMs)); + } + } + } finally { + connectionPermit.release(); + } + } + /** * Send the terminate Signal (stopping the remote command when it is still running) and * release the connection. Idempotent; a no-op when the command already completed and was diff --git a/src/test/java/org/metricshub/winrm/StreamingApiTest.java b/src/test/java/org/metricshub/winrm/StreamingApiTest.java index 6cd4bbe..78b45af 100644 --- a/src/test/java/org/metricshub/winrm/StreamingApiTest.java +++ b/src/test/java/org/metricshub/winrm/StreamingApiTest.java @@ -534,6 +534,51 @@ void deadPeerCannotHoldABoundedWaitHostage() throws Exception { } } + @Test + void completionSignalIsBoundedByThePollBudget() throws Exception { + enqueueCommandStartup(); + server + .enqueue(200, envelope(receiveResponse(stdoutChunk("done\n"), done(COMMAND_ID, 5)))) + // The Signal acknowledging the ALREADY-COMPLETED command stalls far past the poll + // budget: completion (and the known exit code) must win over the cleanup hiccup. + .enqueueDelayed(200, envelope(signalResponse()), 3_000); + + try (WinRMClient client = builder().build()) { + try (CommandCursor cursor = client.executor().startCommand("run.exe", null, 10_000)) { + final CommandCursor.Chunk chunk = cursor.poll(5_000); + assertEquals("done\n", new String(chunk.stdout(), StandardCharsets.UTF_8)); + + final long start = System.nanoTime(); + assertNull(cursor.poll(1_000), "completion must be reported"); + final long elapsedMillis = (System.nanoTime() - start) / 1_000_000; + assertTrue( + elapsedMillis < 2_500, + "the completion Signal must not outlive the poll budget; took " + elapsedMillis + " ms" + ); + assertEquals(5, cursor.exitCode()); + } + } + } + + @Test + void completionInsideATinyPollSkipsTheSignal() throws Exception { + enqueueCommandStartup(); + server.enqueue(200, envelope(receiveResponse(stdoutChunk("done\n"), done(COMMAND_ID, 5)))); + + try (WinRMClient client = builder().build()) { + try (CommandCursor cursor = client.executor().startCommand("run.exe", null, 10_000)) { + final CommandCursor.Chunk chunk = cursor.poll(5_000); + assertEquals("done\n", new String(chunk.stdout(), StandardCharsets.UTF_8)); + + // No round trip fits in the remaining budget: completion is reported without a wire + // Signal, and the healthy connection is left untouched. + assertNull(cursor.poll(20)); + assertEquals(5, cursor.exitCode()); + assertEquals(3, server.decryptedRequests().size(), "a tiny-budget completion must not touch the wire"); + } + } + } + @Test void commandSilenceBeyondTheTimeoutSurfacesAsInactivityTimeout() throws Exception { enqueueCommandStartup(); From 3d4a48165a13a4f3ff7bcfb6b10f8063707331a5 Mon Sep 17 00:00:00 2001 From: Bertrand Martin Date: Tue, 28 Jul 2026 02:36:48 +0200 Subject: [PATCH 11/13] Report completion at the deadline edge; make ALL post-completion cleanup best-effort (Codex P2s) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - waitFor(Duration) could return false when the final Done-carrying chunk consumed the last of the deadline: the wrapper only learns completion from the NEXT cursor call, which the expired loop never made. After the loop, a local-state probe (cursor.exitCode()) now detects the absorbed completion and finalizes it — the follow-up advance runs on a 1 ms budget, so it skips the wire Signal and cannot wait. - finishBounded() swallowed only SocketTimeoutException: a WSMan fault or a connection reset answering the completion Signal escaped and hid the known exit code. Every failure of that cleanup is now best-effort — a fault (a complete, in-sync exchange) is ignored and the connection kept; anything else drops the connection so a late response cannot desync a later request. The completion always wins. Tests: a Done response landing ~80 ms before a 600 ms deadline reports true with the exit code (and no Signal, since none fits); a fault answering the completion Signal is ignored and the connection stays usable. Co-Authored-By: Claude Fable 5 --- .../org/metricshub/winrm/RemoteProcess.java | 12 ++++++ .../metricshub/winrm/light/WsmanClient.java | 21 ++++++---- .../metricshub/winrm/StreamingApiTest.java | 42 +++++++++++++++++++ 3 files changed, 67 insertions(+), 8 deletions(-) diff --git a/src/main/java/org/metricshub/winrm/RemoteProcess.java b/src/main/java/org/metricshub/winrm/RemoteProcess.java index dc60f42..a50ff41 100644 --- a/src/main/java/org/metricshub/winrm/RemoteProcess.java +++ b/src/main/java/org/metricshub/winrm/RemoteProcess.java @@ -160,6 +160,18 @@ public synchronized boolean waitFor(final Duration deadline) { absorb(advance(remaining)); remaining = deadlineMillis - (Utils.getCurrentTimeMillis() - start); } + if (!finished) { + try { + // The last absorbed chunk may have carried completion right as the deadline ran out: + // the command DID complete within the wait, so report that rather than a spurious + // expiry. exitCode() answers from local state; the follow-up advance then completes + // without waiting (its bounded cleanup cannot block on a 1 ms budget). + cursor.exitCode(); + absorb(advance(1)); + } catch (final IllegalStateException ignored) { + // Genuinely still running: the wait expired. + } + } if (finished && exitCode == null) { throw new IllegalStateException("The process was closed before the command completed."); } diff --git a/src/main/java/org/metricshub/winrm/light/WsmanClient.java b/src/main/java/org/metricshub/winrm/light/WsmanClient.java index 80ef78b..9dc7ddd 100644 --- a/src/main/java/org/metricshub/winrm/light/WsmanClient.java +++ b/src/main/java/org/metricshub/winrm/light/WsmanClient.java @@ -689,14 +689,16 @@ private void finish() throws Exception { /** * Completion cleanup under a poll budget: the Signal acknowledging the ALREADY-COMPLETED - * command must not outlive the caller's remaining wait. A Signal answer that does not - * arrive in time is abandoned (the connection is dropped so its late response cannot - * desync a later request) — never reported: the command completed and its exit code is - * known, and that must not be hidden behind a cleanup hiccup. A budget too small for any - * round trip skips the Signal outright, leaving the healthy connection untouched; the - * server reaps the completed command's state with the shell. Runs at most once. + * command must not outlive the caller's remaining wait — and no failure of it may be + * reported either: the command completed and its exit code is known, and that must never + * be hidden behind a cleanup hiccup. A fault answering the Signal is a complete, in-sync + * exchange and is simply ignored; any other failure (a timeout, a reset, a half-read + * response) leaves the connection in an unknown state, so it is dropped — a late response + * must not desync a later request. A budget too small for any round trip skips the Signal + * outright, leaving the healthy connection untouched; the server reaps the completed + * command's state with the shell. Runs at most once. */ - private void finishBounded(final long budgetMs) throws Exception { + private void finishBounded(final long budgetMs) { if (finished) { return; } @@ -707,7 +709,10 @@ private void finishBounded(final long budgetMs) throws Exception { transport.pollTimeout(toSocketTimeoutMillis(budget)); try { terminate(commandId, budget); - } catch (final SocketTimeoutException e) { + } catch (final WinRMFaultException ignored) { + // The Signal was answered with a fault: the exchange completed, the connection + // is in sync — and the command's completion is what matters. + } catch (final Exception e) { transport.close(); } finally { transport.inactivityTimeout(toSocketTimeoutMillis(operationTimeoutMs)); diff --git a/src/test/java/org/metricshub/winrm/StreamingApiTest.java b/src/test/java/org/metricshub/winrm/StreamingApiTest.java index 78b45af..dfcfaae 100644 --- a/src/test/java/org/metricshub/winrm/StreamingApiTest.java +++ b/src/test/java/org/metricshub/winrm/StreamingApiTest.java @@ -560,6 +560,48 @@ void completionSignalIsBoundedByThePollBudget() throws Exception { } } + @Test + void completionArrivingNearTheDeadlineIsStillReported() throws Exception { + enqueueCommandStartup(); + // 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); + + 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"); + assertEquals(9, process.exitCode()); + assertEquals("late", process.stdout().readLine()); + // The leftover budget could not fit a Signal round trip: none was sent. + assertEquals(3, server.decryptedRequests().size()); + } + } + } + + @Test + void faultAnsweringTheCompletionSignalDoesNotHideCompletion() throws Exception { + enqueueCommandStartup(); + server + .enqueue(200, envelope(receiveResponse(stdoutChunk("done\n"), done(COMMAND_ID, 5)))) + // The Signal acknowledging the ALREADY-COMPLETED command is answered with a fault: pure + // cleanup noise — the completion and its exit code must win. + .enqueue(500, fault("999", "Signal rejected")); + + try (WinRMClient client = builder().build()) { + try (CommandCursor cursor = client.executor().startCommand("run.exe", null, 10_000)) { + final CommandCursor.Chunk chunk = cursor.poll(5_000); + assertEquals("done\n", new String(chunk.stdout(), StandardCharsets.UTF_8)); + assertNull(cursor.poll(5_000), "completion must be reported despite the Signal fault"); + assertEquals(5, cursor.exitCode()); + } + + // The fault was a complete, in-sync exchange: the connection remains usable. + server.enqueue(200, envelope(enumerationDone(service("WinRM", "Running")))); + assertEquals(1, client.wql("SELECT Name FROM Win32_Service").execute().size()); + } + } + @Test void completionInsideATinyPollSkipsTheSignal() throws Exception { enqueueCommandStartup(); From 7efc72dc83ba2931b6ee3bbf77f2063b5e7c4480 Mon Sep 17 00:00:00 2001 From: Bertrand Martin Date: Tue, 28 Jul 2026 12:01:14 +0200 Subject: [PATCH 12/13] Give the CLI a proper manual page; point --help at it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New doc-site page cli.md — a manual page for the standalone jar: synopsis, subcommands, the full option table, password handling, Kerberos KDC/realm configuration and inference, streaming output behavior, timeout semantics, exit codes, and examples. Published at https://metricshub.org/winrm-java/cli.html and added to the site menu. Everything that duplicated it now points there instead: --help shrinks to usage + options + the manual link; the per-topic CLI sections of wql.md, commands.md and installation.md become one-line pointers; the exit-code table moves out of timeouts-and-errors.md; the README CLI section keeps the quick-start commands and links to the manual for the rest. Also document in ChunkDecoder's Javadoc why the class exists at all: it is a thin push-style convenience over the JDK's own CharsetDecoder — the JDK's ready-made incremental decoders (InputStreamReader/StreamDecoder) only fit pull-based streams, and WSMan output chunks are pushed. Co-Authored-By: Claude Fable 5 --- README.md | 62 ++----- .../org/metricshub/winrm/ChunkDecoder.java | 7 + .../org/metricshub/winrm/cli/WinRmCli.java | 13 +- src/site/markdown/cli.md | 152 ++++++++++++++++++ src/site/markdown/commands.md | 12 +- src/site/markdown/index.md | 1 + src/site/markdown/installation.md | 22 +-- src/site/markdown/timeouts-and-errors.md | 20 +-- src/site/markdown/wql.md | 16 +- src/site/site.xml | 1 + .../metricshub/winrm/cli/WinRmCliTest.java | 2 + 11 files changed, 185 insertions(+), 123 deletions(-) create mode 100644 src/site/markdown/cli.md diff --git a/README.md b/README.md index 19479d1..fb86fb5 100644 --- a/README.md +++ b/README.md @@ -167,59 +167,15 @@ java -jar target/winrm-java--standalone.jar \ exec ipconfig /all ``` -Use `--help` for the complete option list and `--version` for the build version. HTTP is the -default transport and uses port 5985; `--https` uses port 5986. `-P`/`--port` overrides either -default, and `-t`/`--timeout` sets the operation timeout in milliseconds (60,000 by default). - -NTLM is used when neither authentication flag is supplied. `--ntlm` and `--kerberos` are mutually -exclusive. Kerberos requires HTTPS. By default it uses the ambient JDK Kerberos configuration. The -CLI can instead configure the JDK for the current invocation with `--kerberos-kdc `. If no -`--kerberos-realm ` is supplied, the realm is inferred by removing the KDC hostname's first -DNS label and uppercasing the remaining suffix. For example: - -```bash -java -jar target/winrm-java--standalone.jar \ - -h server.internal.sentrysoftware.net -u 'DOMAIN\user' -pf password.txt \ - --https --kerberos --kerberos-kdc camus.internal.sentrysoftware.net \ - command whoami -``` - -This infers `INTERNAL.SENTRYSOFTWARE.NET`. The inference follows a common Active Directory DNS -naming convention; it is not guaranteed by Kerberos. Specify `--kerberos-realm` when the realm does -not match the KDC's DNS suffix or when the KDC is not a fully qualified DNS name. Both options are -valid only with `--kerberos`, and `--kerberos-realm` requires `--kerberos-kdc`. - -HTTPS validates the certificate and hostname by default. `--https-permissive` trusts any -certificate and hostname; it is intentionally insecure and should only be used for testing or -isolated hosts. - -`-p`/`--password` is convenient for interactive use, but command-line arguments may be visible to -other local processes. Prefer `-pf`/`--password-file` for automation. Password files are decoded as -UTF-8. Exactly one final LF, CRLF, or CR is removed; all other bytes, including whitespace and -earlier line endings, are part of the password. The two password options are mutually exclusive. -If neither is supplied, the CLI securely requests the password from the interactive console without -echoing it. Non-interactive runs must use `--password-file` (or, less securely, `--password`). - -WQL writes one compact UTF-8 JSON object per row to stdout -([JSON Lines](https://jsonlines.org/)); property order follows the WinRM response. Diagnostics go -only to stderr. The CLI is built on the streaming API: WQL rows are written **as the enumeration -pages arrive** (a huge query starts producing output immediately, memory stays bounded, and for -`wql` the timeout is the longest tolerated silence between two server responses rather than an -overall deadline — a mid-stream failure can leave partial output before the nonzero exit), and -remote command stdout and stderr are forwarded **live** to the corresponding local streams while -the command runs (the timeout remains the overall command deadline). - -Exit behavior is stable: - -| Exit code | Meaning | -| ---: | --- | -| `0` | Successful WQL query or remote command | -| `0`–`255` | Remote command exit code, when representable | -| `64` | Invalid CLI usage | -| `69` | Connection, DNS, socket, or TLS failure | -| `70` | WinRM protocol or other remote failure | -| `77` | Authentication failure | -| `124` | Operation timeout | +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. + +The full manual — options, password handling, Kerberos configuration, streaming and timeout +semantics, exit codes — is the +[Command-Line Client](https://metricshub.org/winrm-java/cli.html) page. ## Build instructions diff --git a/src/main/java/org/metricshub/winrm/ChunkDecoder.java b/src/main/java/org/metricshub/winrm/ChunkDecoder.java index 596135c..c5b7a34 100644 --- a/src/main/java/org/metricshub/winrm/ChunkDecoder.java +++ b/src/main/java/org/metricshub/winrm/ChunkDecoder.java @@ -35,6 +35,13 @@ * characters. Malformed and unmappable input is replaced, matching * {@link String#String(byte[], Charset)}, so incrementally decoding a byte sequence yields the * same text as decoding it in one piece. + *

+ * This is a thin push-style convenience over the JDK's own {@link CharsetDecoder}, which does all + * the actual decoding. The JDK's ready-made incremental decoders ({@code InputStreamReader} and + * its underlying {@code StreamDecoder}) only fit pull-based streams — they block the caller + * until input arrives — whereas output chunks here are pushed by the protocol loop as each + * Receive response is processed; there is no public JDK type for that direction, only the + * {@link CharsetDecoder}/{@link ByteBuffer} primitives this class packages. */ final class ChunkDecoder { diff --git a/src/main/java/org/metricshub/winrm/cli/WinRmCli.java b/src/main/java/org/metricshub/winrm/cli/WinRmCli.java index 6e50235..de89e65 100644 --- a/src/main/java/org/metricshub/winrm/cli/WinRmCli.java +++ b/src/main/java/org/metricshub/winrm/cli/WinRmCli.java @@ -322,18 +322,9 @@ private static String help() { " --version Show the project version\n" + "\n" + "If neither password option is given, the password is requested from the interactive console.\n" + - "The password options are mutually exclusive. Password files have one final\n" + - "LF, CRLF, or CR removed; every other byte is part of the UTF-8 password.\n" + - "Kerberos KDC/realm options set the JDK Kerberos configuration for this invocation.\n" + - "Without them, the ambient JDK Kerberos configuration is used.\n" + - "WQL rows are streamed to stdout as UTF-8 JSON Lines as they arrive from the host; the timeout\n" + - "is the longest tolerated silence between two server responses, so large results may stream\n" + - "for longer, and a mid-stream failure can leave partial output before the nonzero exit.\n" + - "Command stdout and stderr are forwarded live to the corresponding local streams while the\n" + - "command runs; the timeout is the overall command deadline.\n" + "\n" + - "Exit codes: 0 success; remote command code 0..255 when available; 64 usage;\n" + - "69 connection/TLS; 70 WinRM protocol; 77 authentication; 124 timeout.\n"; + "Full manual - streaming behavior, password files, Kerberos, exit codes:\n" + + " https://metricshub.org/winrm-java/cli.html\n"; } @FunctionalInterface diff --git a/src/site/markdown/cli.md b/src/site/markdown/cli.md new file mode 100644 index 0000000..dc2e04c --- /dev/null +++ b/src/site/markdown/cli.md @@ -0,0 +1,152 @@ +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. + +# Command-Line Client + + + +Every release ships a self-contained executable jar that bundles the client and a small CLI: +download `${project.artifactId}-${project.version}-standalone.jar` from the +[latest release](https://github.com/metricshub/winrm-java/releases/latest) and run it with Java. +This page is its manual. + +## Synopsis + +```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 --help | --version +``` + +## Subcommands + +| Subcommand | Description | +| --- | --- | +| `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. | + +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`. + +## Options + +| Option | Description | +| --- | --- | +| `-h, --hostname ` | Target hostname or IP address (required). | +| `-u, --username ` | User name, optionally `DOMAIN\user` (required). | +| `-p, --password ` | Password. Command-line arguments may be visible to other local processes: avoid in automation. | +| `-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). | +| `--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). | +| `--kerberos` | Authenticate with Kerberos. Requires `--https`. | +| `--kerberos-kdc ` | Set the Kerberos KDC for this invocation; the realm is inferred from its DNS suffix (see below). | +| `--kerberos-realm ` | Override the realm inferred from `--kerberos-kdc`. | +| `--help` | Print the usage summary. | +| `--version` | Print the build version. | + +`--ntlm` and `--kerberos` are mutually exclusive, as are the two password options. + +## Passwords + +If neither `-p` nor `-pf` is supplied, the CLI securely requests the password from the interactive +console without echoing it. Non-interactive runs must use `--password-file` (or, less securely, +`--password`). + +Password files are decoded as UTF-8. Exactly one final LF, CRLF, or CR is removed; every other +byte — including whitespace and earlier line endings — is part of the password. + +## Kerberos + +By default, Kerberos uses the ambient JDK configuration (`krb5.conf` / +`-Djava.security.krb5.*`). The CLI can instead configure the JDK for the current invocation with +`--kerberos-kdc `. If no `--kerberos-realm` is supplied, the realm is inferred by removing +the KDC hostname's first DNS label and uppercasing the remaining suffix — for example, a KDC of +`camus.internal.example.net` infers the realm `INTERNAL.EXAMPLE.NET`. The inference follows a +common Active Directory DNS naming convention; it is not guaranteed by Kerberos, so specify +`--kerberos-realm` when the realm does not match the KDC's DNS suffix or when the KDC is not a +fully qualified DNS name. Both options are valid only with `--kerberos`, and `--kerberos-realm` +requires `--kerberos-kdc`. + +See [Authentication](authentication.html) for how NTLM and Kerberos work on the wire. + +## Output + +Diagnostics go **only to standard error**, so standard output can always be piped or parsed. + +### `wql` + +Each result row is printed as one compact UTF-8 JSON object per line +([JSON Lines](https://jsonlines.org/)); property order follows the WinRM response. The rows are +**streamed**: each one is written and flushed as it arrives from the host, so a downstream pipe +starts working immediately and memory stays bounded regardless of the result size. A mid-stream +failure can therefore leave partial output on standard output, signalled by the nonzero exit code. + +### `command` + +Remote stdout and stderr are forwarded **live** to the corresponding local streams while the +command runs — each chunk is flushed as it arrives, so a long-running command can be followed in +real time. The output is decoded with the remote host's active code page, detected automatically +before the command starts. + +## Timeout semantics + +`-t`/`--timeout` follows the operation: + +* For `wql`, it is the **inactivity timeout** of the stream — the longest tolerated silence + between two server responses. A large result can stream for longer than the timeout, as long as + the server keeps answering. +* For `command`, it is the **overall deadline** covering the encoding detection and the command + itself. + +See [Timeouts and Errors](timeouts-and-errors.html) for the underlying semantics. + +## Exit codes + +| Exit code | Meaning | +| ---: | --- | +| `0` | Successful WQL query or remote command. | +| `0`–`255` | Remote command exit code, when it fits in that range. | +| `64` | Invalid CLI usage. | +| `69` | Connection, DNS, socket, or TLS failure. | +| `70` | WinRM protocol or other remote failure (including a remote exit code not representable in 0–255). | +| `77` | Authentication failure. | +| `124` | Operation timeout. | + +## Examples + +Run a WQL query over NTLM and HTTP, reading the password from a file: + +```bash +java -jar ${project.artifactId}-${project.version}-standalone.jar \ + --hostname server.example.net --username 'DOMAIN\user' \ + --password-file password.txt --ntlm \ + wql 'SELECT Name,State FROM Win32_Service' +``` + +Run a remote command over HTTPS: + +```bash +java -jar ${project.artifactId}-${project.version}-standalone.jar \ + -h server.example.net -u Administrator -pf password.txt --https \ + exec ipconfig /all +``` + +Kerberos with an explicit KDC (realm inferred as `INTERNAL.EXAMPLE.NET`): + +```bash +java -jar ${project.artifactId}-${project.version}-standalone.jar \ + -h server.internal.example.net -u 'DOMAIN\user' -pf password.txt \ + --https --kerberos --kerberos-kdc camus.internal.example.net \ + command whoami +``` + +Follow a long-running command live and capture the streamed WQL rows with `jq`: + +```bash +java -jar ${project.artifactId}-${project.version}-standalone.jar \ + -h server.example.net -u 'DOMAIN\user' -pf password.txt \ + wql 'SELECT * FROM Win32_NTLogEvent' | jq -r .Message +``` diff --git a/src/site/markdown/commands.md b/src/site/markdown/commands.md index 97dd7c3..56ac550 100644 --- a/src/site/markdown/commands.md +++ b/src/site/markdown/commands.md @@ -169,13 +169,5 @@ See [Timeouts and Errors](timeouts-and-errors.html) for details. ## From the command line -The standalone jar runs a command with the `command` subcommand (aliases: `cmd`, `exec`, `run`). -Standard output and standard error are forwarded **live** to the corresponding local streams while -the command runs (each chunk is flushed as it arrives, so a long-running command can be followed -in real time), and the process exits with the remote exit code when it fits in 0–255: - -```bash -java -jar ${project.artifactId}-${project.version}-standalone.jar \ - -h server.example.com -u 'DOMAIN\user' -pf password.txt --https \ - exec ipconfig /all -``` +The standalone jar runs a command with its `command` subcommand, forwarding the output live and +propagating the exit code — see the [Command-Line Client](cli.html) manual. diff --git a/src/site/markdown/index.md b/src/site/markdown/index.md index 6f04308..7425ff3 100644 --- a/src/site/markdown/index.md +++ b/src/site/markdown/index.md @@ -109,6 +109,7 @@ remain available and unchanged, with their checked exceptions. * [WQL Queries](wql.html) — query WMI and read the result * [Remote Commands](commands.html) — run commands and copy files to the host * [File Transfers](file-transfers.html) — how files are copied through the WinRM channel +* [Command-Line Client](cli.html) — the standalone jar's manual page * [Authentication](authentication.html) — NTLM and Kerberos * [TLS / HTTPS](tls.html) — certificate validation and trust stores * [Timeouts and Errors](timeouts-and-errors.html) — timeout semantics and the exception surface diff --git a/src/site/markdown/installation.md b/src/site/markdown/installation.md index 4a7461e..2412427 100644 --- a/src/site/markdown/installation.md +++ b/src/site/markdown/installation.md @@ -61,28 +61,12 @@ Download `${project.artifactId}-${project.version}-standalone.jar` from the java -jar ${project.artifactId}-${project.version}-standalone.jar --help ``` -Run a WQL query: - -```bash -java -jar ${project.artifactId}-${project.version}-standalone.jar \ - --hostname server.example.com --username 'DOMAIN\user' \ - --password-file password.txt --ntlm \ - wql 'SELECT Name, State FROM Win32_Service' -``` - -Run a remote command (`cmd`, `exec`, and `run` are aliases for `command`): - -```bash -java -jar ${project.artifactId}-${project.version}-standalone.jar \ - -h server.example.com -u Administrator -pf password.txt --https \ - exec ipconfig /all -``` - -The CLI is covered in more detail throughout the [Usage](wql.html) pages; `--version` prints the -build version. +Subcommands, options, password handling, streaming behavior, and exit codes are documented in the +[Command-Line Client](cli.html) manual. ## Where to go next * [WQL Queries](wql.html) * [Remote Commands](commands.html) +* [Command-Line Client](cli.html) * [Authentication](authentication.html) diff --git a/src/site/markdown/timeouts-and-errors.md b/src/site/markdown/timeouts-and-errors.md index 2a59638..bb4e739 100644 --- a/src/site/markdown/timeouts-and-errors.md +++ b/src/site/markdown/timeouts-and-errors.md @@ -1,5 +1,5 @@ -keywords: timeout, exception, error, winrmclientexception, wsmanfault, exit code -description: Timeout semantics and the exception surface of the WinRM Java Client, plus the command-line exit codes. +keywords: timeout, exception, error, winrmclientexception, wsmanfault +description: Timeout semantics and the exception surface of the WinRM Java Client. # Timeouts and Errors @@ -92,17 +92,5 @@ unaffected by the unchecked hierarchy above. ## Command-line exit codes -The standalone jar maps outcomes to stable process exit codes: - -| Exit code | Meaning | -| ---: | --- | -| `0` | Successful WQL query or remote command. | -| `0`–`255` | Remote command exit code, when it fits in that range. | -| `64` | Invalid CLI usage. | -| `69` | Connection, DNS, socket, or TLS failure. | -| `70` | WinRM protocol or other remote failure. | -| `77` | Authentication failure. | -| `124` | Operation timeout. | - -Diagnostics are written only to standard error, so a WQL query's JSON Lines output on standard -output is never mixed with error messages. +The standalone jar maps these outcomes to stable process exit codes — see the +[Command-Line Client](cli.html) manual. diff --git a/src/site/markdown/wql.md b/src/site/markdown/wql.md index f520575..6c8830e 100644 --- a/src/site/markdown/wql.md +++ b/src/site/markdown/wql.md @@ -164,17 +164,5 @@ See [Timeouts and Errors](timeouts-and-errors.html) for the full exception surfa ## From the command line -The standalone jar exposes the same capability through the `wql` subcommand, printing one compact -UTF-8 JSON object per row ([JSON Lines](https://jsonlines.org/)): - -```bash -java -jar ${project.artifactId}-${project.version}-standalone.jar \ - -h server.example.com -u 'DOMAIN\user' -pf password.txt --ntlm \ - wql 'SELECT Name, State FROM Win32_Service' -``` - -The rows are **streamed**: each one is written (and flushed) as it arrives from the host, so a -pipe consuming the output starts working immediately and memory stays bounded regardless of the -result size. The `-t`/`--timeout` option is the inactivity timeout of the stream; a mid-stream -failure can leave partial output on standard output before the nonzero exit. Property order -follows the WinRM response; diagnostics go only to standard error. +The standalone jar exposes the same capability through its `wql` subcommand, streaming the rows +to stdout as JSON Lines — see the [Command-Line Client](cli.html) manual. diff --git a/src/site/site.xml b/src/site/site.xml index a53060b..a2864f1 100644 --- a/src/site/site.xml +++ b/src/site/site.xml @@ -56,6 +56,7 @@ + diff --git a/src/test/java/org/metricshub/winrm/cli/WinRmCliTest.java b/src/test/java/org/metricshub/winrm/cli/WinRmCliTest.java index 77e6ac7..24e35dd 100644 --- a/src/test/java/org/metricshub/winrm/cli/WinRmCliTest.java +++ b/src/test/java/org/metricshub/winrm/cli/WinRmCliTest.java @@ -58,6 +58,8 @@ void helpAndVersionDoNotConnect() throws Exception { assertTrue(help.stdout.contains("-P, --port")); assertTrue(help.stdout.contains("--kerberos-kdc")); assertTrue(help.stdout.contains("--kerberos-realm")); + // The details (streaming behavior, password files, exit codes) live in the online manual. + assertTrue(help.stdout.contains("https://metricshub.org/winrm-java/cli.html")); assertEquals("", help.stderr); final Invocation version = invoke(new String[] { "--version" }, arguments -> failingRemote()); From 7ed61e0ad6e71fe6978e02f8675a305560c61303 Mon Sep 17 00:00:00 2001 From: Bertrand Martin Date: Tue, 28 Jul 2026 12:11:53 +0200 Subject: [PATCH 13/13] Bound whole streaming responses; make every post-completion Signal best-effort (Codex P2s) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - The streaming (inactivity) socket mode relied on SO_TIMEOUT alone, which restarts on every byte: a peer trickling an endless incomplete response could hold a WqlCursor.next()/CommandCursor.next() forever despite the documented per-round-trip bound. inactivityTimeout mode now arms an absolute per-leg deadline at the start of every post() (reconnect included), enforced by the existing per-read re-caps — one whole response must arrive within the inactivity timeout. - The ordinary nextChunk() EOF path still propagated faults/resets from the terminate Signal of an ALREADY-COMPLETED command, failing a command whose exit code was known. finish() now routes completed commands through the shared best-effort terminateCompleted() (fault → ignored, connection kept; anything else → connection dropped), while an early close of a still-running command keeps its error reporting — there the Signal is what actually stops the command. Tests: a trickling peer is cut off at the inactivity timeout in streaming mode; a fault answering the completion Signal on the plain next() path reports EOF + exit code and leaves the connection usable. Co-Authored-By: Claude Fable 5 --- .../metricshub/winrm/light/HttpTransport.java | 24 +++++- .../metricshub/winrm/light/WsmanClient.java | 68 ++++++++++------ .../metricshub/winrm/StreamingApiTest.java | 23 ++++++ .../light/HttpTransportDeadlineTest.java | 79 +++++++++++++------ 4 files changed, 141 insertions(+), 53 deletions(-) diff --git a/src/main/java/org/metricshub/winrm/light/HttpTransport.java b/src/main/java/org/metricshub/winrm/light/HttpTransport.java index afb52a7..91339c0 100644 --- a/src/main/java/org/metricshub/winrm/light/HttpTransport.java +++ b/src/main/java/org/metricshub/winrm/light/HttpTransport.java @@ -69,6 +69,12 @@ final class HttpTransport implements AutoCloseable { // active — see pollTimeout(int). Cleared by the other timeout modes. private long deadlineEpochMillis; + // Streaming (inactivity) mode: every request leg (re)arms the absolute deadline for itself, so + // one WHOLE response — not each of its reads — is bounded by the inactivity timeout. SO_TIMEOUT + // alone restarts on every byte, and a peer trickling an endless incomplete response would + // otherwise hold a streaming fetch forever. + private boolean deadlinePerLeg; + HttpTransport(final String host, final int port, final int timeoutMillis) { this(host, port, timeoutMillis, null, false); } @@ -101,7 +107,7 @@ final class HttpTransport implements AutoCloseable { * @param operationTimeoutMillis the current operation's timeout in milliseconds */ void operationTimeout(final int operationTimeoutMillis) { - applyTimeouts(operationTimeoutMillis, operationTimeoutMillis + 10_000, 0); + applyTimeouts(operationTimeoutMillis, operationTimeoutMillis + 10_000, 0, false); } /** @@ -119,7 +125,7 @@ void operationTimeout(final int operationTimeoutMillis) { * @param budgetMillis the poll's whole budget in milliseconds */ void pollTimeout(final int budgetMillis) { - applyTimeouts(budgetMillis, budgetMillis, Utils.getCurrentTimeMillis() + budgetMillis); + applyTimeouts(budgetMillis, budgetMillis, Utils.getCurrentTimeMillis() + budgetMillis, false); } /** @@ -129,15 +135,20 @@ void pollTimeout(final int budgetMillis) { * long" — so the socket must give up at the inactivity bound itself, not ten seconds later. * A server that enforces the WSMan OperationTimeout by answering with the op-timeout fault * reaches the caller through that fault instead; both surface as the same timeout. + *

+ * The bound is absolute per request leg (armed at the start of each {@link #post}): one whole + * response must arrive within the inactivity timeout — a peer trickling bytes must not restart + * the clock with every byte and hold a streaming fetch forever. * * @param inactivityTimeoutMillis the longest tolerated silence in milliseconds */ void inactivityTimeout(final int inactivityTimeoutMillis) { - applyTimeouts(inactivityTimeoutMillis, inactivityTimeoutMillis, 0); + applyTimeouts(inactivityTimeoutMillis, inactivityTimeoutMillis, 0, true); } - private void applyTimeouts(final int connectMillis, final int readMillis, final long deadline) { + private void applyTimeouts(final int connectMillis, final int readMillis, final long deadline, final boolean perLeg) { deadlineEpochMillis = deadline; + deadlinePerLeg = perLeg; connectTimeoutMillis = connectMillis; readTimeoutMillis = readMillis; if (socket != null && !socket.isClosed()) { @@ -310,6 +321,11 @@ private void ensureConnected() throws IOException { Response post(final String path, final byte[] body, final String contentType, final String authorization) throws IOException { + if (deadlinePerLeg) { + // Streaming mode: this whole leg — a reconnect included — must complete within the + // inactivity timeout, however many reads it takes (see inactivityTimeout(int)). + deadlineEpochMillis = Utils.getCurrentTimeMillis() + readTimeoutMillis; + } ensureConnected(); try { if (deadlineEpochMillis != 0) { diff --git a/src/main/java/org/metricshub/winrm/light/WsmanClient.java b/src/main/java/org/metricshub/winrm/light/WsmanClient.java index 9dc7ddd..2276e56 100644 --- a/src/main/java/org/metricshub/winrm/light/WsmanClient.java +++ b/src/main/java/org/metricshub/winrm/light/WsmanClient.java @@ -669,7 +669,13 @@ int exitCode() { return exitCode; } - /** Signal the command (terminate) and release the connection; runs at most once. */ + /** + * Signal the command (terminate) and release the connection; runs at most once. For a + * still-running command (an early close) the Signal is what actually stops it, so its + * failures are reported; once the command has COMPLETED the Signal is best-effort cleanup + * (see {@link #terminateCompleted}) — a completed command with a known exit code must + * never turn into a failure because its acknowledgement hiccuped. + */ private void finish() throws Exception { if (finished) { return; @@ -680,7 +686,11 @@ private void finish() throws Exception { // is gone, and the request would reconnect and re-authenticate just to be thrown away — // the server reaps the shell (and its commands) on its own IdleTimeout instead. if (!closed) { - terminate(commandId, operationTimeoutMs); + if (exitCode != null) { + terminateCompleted(operationTimeoutMs); + } else { + terminate(commandId, operationTimeoutMs); + } } } finally { connectionPermit.release(); @@ -688,15 +698,8 @@ private void finish() throws Exception { } /** - * Completion cleanup under a poll budget: the Signal acknowledging the ALREADY-COMPLETED - * command must not outlive the caller's remaining wait — and no failure of it may be - * reported either: the command completed and its exit code is known, and that must never - * be hidden behind a cleanup hiccup. A fault answering the Signal is a complete, in-sync - * exchange and is simply ignored; any other failure (a timeout, a reset, a half-read - * response) leaves the connection in an unknown state, so it is dropped — a late response - * must not desync a later request. A budget too small for any round trip skips the Signal - * outright, leaving the healthy connection untouched; the server reaps the completed - * command's state with the shell. Runs at most once. + * Completion cleanup under a poll budget: like {@link #finish()} after completion, but the + * Signal must not outlive the caller's remaining wait either. Runs at most once. */ private void finishBounded(final long budgetMs) { if (finished) { @@ -704,25 +707,42 @@ private void finishBounded(final long budgetMs) { } finished = true; try { - final long budget = Math.max(1, Math.min(budgetMs, operationTimeoutMs)); - if (!closed && budget >= MIN_WIRE_POLL_MS) { - transport.pollTimeout(toSocketTimeoutMillis(budget)); - try { - terminate(commandId, budget); - } catch (final WinRMFaultException ignored) { - // The Signal was answered with a fault: the exchange completed, the connection - // is in sync — and the command's completion is what matters. - } catch (final Exception e) { - transport.close(); - } finally { - transport.inactivityTimeout(toSocketTimeoutMillis(operationTimeoutMs)); - } + if (!closed) { + terminateCompleted(budgetMs); } } finally { connectionPermit.release(); } } + /** + * Best-effort Signal for an ALREADY-COMPLETED command, bounded by the given budget. No + * failure of it may be reported: the command completed and its exit code is known, and + * that must never be hidden behind a cleanup hiccup. A fault answering the Signal is a + * complete, in-sync exchange and is simply ignored; any other failure (a timeout, a reset, + * a half-read response) leaves the connection in an unknown state, so it is dropped — a + * late response must not desync a later request. A budget too small for any round trip + * skips the Signal outright, leaving the healthy connection untouched; the server reaps + * the completed command's state with the shell. + */ + private void terminateCompleted(final long budgetMs) { + final long budget = Math.max(1, Math.min(budgetMs, operationTimeoutMs)); + if (budget < MIN_WIRE_POLL_MS) { + return; + } + transport.pollTimeout(toSocketTimeoutMillis(budget)); + try { + terminate(commandId, budget); + } catch (final WinRMFaultException ignored) { + // The Signal was answered with a fault: the exchange completed, the connection is in + // sync — and the command's completion is what matters. + } catch (final Exception e) { + transport.close(); + } finally { + transport.inactivityTimeout(toSocketTimeoutMillis(operationTimeoutMs)); + } + } + /** * Send the terminate Signal (stopping the remote command when it is still running) and * release the connection. Idempotent; a no-op when the command already completed and was diff --git a/src/test/java/org/metricshub/winrm/StreamingApiTest.java b/src/test/java/org/metricshub/winrm/StreamingApiTest.java index dfcfaae..dd07b45 100644 --- a/src/test/java/org/metricshub/winrm/StreamingApiTest.java +++ b/src/test/java/org/metricshub/winrm/StreamingApiTest.java @@ -602,6 +602,29 @@ void faultAnsweringTheCompletionSignalDoesNotHideCompletion() throws Exception { } } + @Test + void faultAnsweringTheCompletionSignalDoesNotFailThePlainFetchEither() throws Exception { + enqueueCommandStartup(); + server + .enqueue(200, envelope(receiveResponse(stdoutChunk("done\n"), done(COMMAND_ID, 5)))) + // Same as the bounded-poll case, but on the ordinary next() path: reading EOF from a + // COMPLETED command must not fail because the cleanup Signal was answered with a fault. + .enqueue(500, fault("999", "Signal rejected")); + + try (WinRMClient client = builder().build()) { + try (CommandCursor cursor = client.executor().startCommand("run.exe", null, 10_000)) { + final CommandCursor.Chunk chunk = cursor.next(); + assertEquals("done\n", new String(chunk.stdout(), StandardCharsets.UTF_8)); + assertNull(cursor.next(), "completion must be reported despite the Signal fault"); + assertEquals(5, cursor.exitCode()); + } + + // The fault was a complete, in-sync exchange: the connection remains usable. + server.enqueue(200, envelope(enumerationDone(service("WinRM", "Running")))); + assertEquals(1, client.wql("SELECT Name FROM Win32_Service").execute().size()); + } + } + @Test void completionInsideATinyPollSkipsTheSignal() throws Exception { enqueueCommandStartup(); diff --git a/src/test/java/org/metricshub/winrm/light/HttpTransportDeadlineTest.java b/src/test/java/org/metricshub/winrm/light/HttpTransportDeadlineTest.java index 0c8cfaf..96590ea 100644 --- a/src/test/java/org/metricshub/winrm/light/HttpTransportDeadlineTest.java +++ b/src/test/java/org/metricshub/winrm/light/HttpTransportDeadlineTest.java @@ -77,31 +77,7 @@ void everyLegOfABoundedPollSharesOneDeadline() throws Exception { @Test void aTricklingPeerCannotStretchABoundedPollPastItsDeadline() throws Exception { try (ServerSocket server = new ServerSocket(0)) { - final Thread handler = new Thread( - () -> { - // One connection: read the request head, then trickle the response one byte - // every 300 ms. SO_TIMEOUT applies per read, so without the deadline every byte - // would reset the clock and the ~40-byte response would take ~12 s. - try (Socket socket = server.accept()) { - final InputStream in = new BufferedInputStream(socket.getInputStream()); - final OutputStream out = socket.getOutputStream(); - if (readRequestHead(in)) { - for (final byte b : "HTTP/1.1 200 OK\r\nContent-Length: 0\r\n\r\n".getBytes( - StandardCharsets.ISO_8859_1 - )) { - Thread.sleep(300); - out.write(b); - out.flush(); - } - } - } catch (final IOException | InterruptedException ignored) { - // client timed out or test over - } - }, - "trickle-http-server" - ); - handler.setDaemon(true); - handler.start(); + startTricklingServer(server); final HttpTransport transport = new HttpTransport("127.0.0.1", server.getLocalPort(), 60_000); try { @@ -119,6 +95,59 @@ void aTricklingPeerCannotStretchABoundedPollPastItsDeadline() throws Exception { } } + @Test + void aTricklingPeerCannotStretchAStreamingRoundTrip() throws Exception { + try (ServerSocket server = new ServerSocket(0)) { + startTricklingServer(server); + + final HttpTransport transport = new HttpTransport("127.0.0.1", server.getLocalPort(), 60_000); + try { + // Streaming (inactivity) mode: one WHOLE response must arrive within the timeout — + // a peer trickling one byte per 300 ms must not restart the clock with every byte. + transport.inactivityTimeout(2_000); + final long start = System.nanoTime(); + assertThrows(SocketTimeoutException.class, () -> transport.post("/wsman", new byte[0], null, null)); + final long elapsedMillis = (System.nanoTime() - start) / 1_000_000; + assertTrue( + elapsedMillis < 3_000, + "the whole response must be bounded by the inactivity timeout; took " + elapsedMillis + " ms" + ); + } finally { + transport.close(); + } + } + } + + /** + * One connection: read the request head, then trickle the response one byte every 300 ms. + * {@code SO_TIMEOUT} applies per read, so without an absolute bound every byte would reset the + * clock and the ~40-byte response would take ~12 s. + */ + private static void startTricklingServer(final ServerSocket server) { + final Thread handler = new Thread( + () -> { + try (Socket socket = server.accept()) { + final InputStream in = new BufferedInputStream(socket.getInputStream()); + final OutputStream out = socket.getOutputStream(); + if (readRequestHead(in)) { + for (final byte b : "HTTP/1.1 200 OK\r\nContent-Length: 0\r\n\r\n".getBytes( + StandardCharsets.ISO_8859_1 + )) { + Thread.sleep(300); + out.write(b); + out.flush(); + } + } + } catch (final IOException | InterruptedException ignored) { + // client timed out or test over + } + }, + "trickle-http-server" + ); + handler.setDaemon(true); + handler.start(); + } + /** Serve every request of every connection with a minimal 200 response, 600 ms late. */ private static void serveSlowly(final ServerSocket server) { try {