44import lombok .extern .slf4j .Slf4j ;
55import org .apache .commons .exec .CommandLine ;
66import org .apache .commons .exec .DefaultExecutor ;
7+ import org .apache .commons .exec .ExecuteException ;
78import org .apache .commons .exec .ExecuteWatchdog ;
9+ import org .apache .commons .exec .PumpStreamHandler ;
810
11+ import java .io .ByteArrayInputStream ;
912import java .io .ByteArrayOutputStream ;
1013import java .io .File ;
1114import java .io .IOException ;
15+ import java .nio .charset .StandardCharsets ;
1216import java .util .Objects ;
1317
1418/**
1519 * <p>Hermes CLI 子进程执行器。</p>
1620 *
1721 * <p>负责命令构造、工作目录、超时等待以及标准输出和错误输出收集;调用方负责限制并发子进程数。</p>
1822 *
23+ * <p>加固约定:</p>
24+ * <ul>
25+ * <li>参数经 {@code addArgument(arg, false)} 原样进入 argv——子进程经 exec 启动而非 shell,
26+ * 默认引号策略会把含空格参数的字面双引号烤进 argv,损坏多词 prompt 与路径;</li>
27+ * <li>子进程始终收到(可能为空的)立即关闭的 stdin——读取型命令读到 EOF 即结束,
28+ * 已关闭管道也不会与输入泵产生写后关闭竞态;</li>
29+ * <li>输出按 UTF-8 显式解码——{@code toString()} 走平台默认字符集,C locale 或
30+ * Windows GBK 环境会损坏中文输出;</li>
31+ * <li>非零退出经 {@link ExecuteException} 单独捕获,保留真实退出码与两路输出;
32+ * 超时判定用截止时间法,规避 {@code watchdog.killedProcess()} 的观察竞态。</li>
33+ * </ul>
34+ *
1935 * @author <a href="https://github.com/loong10k">Loong Wan</a>
2036 * @since 1.0.0
2137 */
2238@ Slf4j
2339public class HermesCliExecutor {
2440
41+ private static final String TIMEOUT_PREFIX = "hermes CLI timed out after " ;
42+
2543 /**
2644 * 当前客户端使用的配置快照。
2745 */
@@ -47,16 +65,80 @@ public HermesCliExecutor(HermesCliConfig config) {
4765 * @since 1.0.0
4866 */
4967 public HermesCliResult execute (String ... args ) {
68+ return runProcess (null , args );
69+ }
70+
71+ /**
72+ * <p>执行 Hermes CLI 命令并向子进程 stdin 管道写入 {@code stdin} 内容。</p>
73+ *
74+ * <p>供从标准输入读取载荷的命令形态使用;{@code stdin} 为 {@code null} 或空时
75+ * 行为与 {@link #execute(String...)} 一致(子进程收到立即关闭的空管道)。失败语义
76+ * 与变参重载相同。</p>
77+ *
78+ * @param stdin 写入子进程标准输入的可选文本
79+ * @param args 传递给 Hermes CLI 的参数
80+ * @return 包含退出码、标准输出和标准错误的命令结果
81+ * @since 1.0.0
82+ */
83+ public HermesCliResult executeWithStdin (String stdin , String ... args ) {
84+ return runProcess (stdin , args );
85+ }
86+
87+ /**
88+ * <p>探测 Hermes CLI 是否可执行。</p>
89+ *
90+ * @return CLI 版本命令成功退出时返回 {@code true}
91+ * @since 1.0.0
92+ */
93+ public boolean probe () {
94+ try {
95+ HermesCliConfig probeConfig = copyForProbe (config );
96+ HermesCliResult result = new HermesCliExecutor (probeConfig ).execute ("--version" );
97+ return result .isSuccess ();
98+ } catch (Exception e ) {
99+ return false ;
100+ }
101+ }
102+
103+ /**
104+ * <p>以 UTF-8 显式解码子进程输出。</p>
105+ *
106+ * <p>{@code toString(Charset)} 是 Java 10+ API,JDK 8 线经由
107+ * {@code toString("UTF-8")};UTF-8 在所有 JVM 上保证存在,
108+ * catch 分支不可达,仅为满足受检异常。</p>
109+ *
110+ * @param buffer 待解码的输出缓冲
111+ * @return UTF-8 解码后的文本
112+ * @since 1.0.0
113+ */
114+ private static String decodeUtf8 (ByteArrayOutputStream buffer ) {
115+ try {
116+ return buffer .toString ("UTF-8" );
117+ } catch (java .io .UnsupportedEncodingException e ) {
118+ return new String (buffer .toByteArray (), StandardCharsets .UTF_8 );
119+ }
120+ }
121+
122+ private HermesCliResult runProcess (String stdin , String ... args ) {
50123 // 按参数边界构造命令,避免手工拼接引入空格转义错误和命令注入风险。
51124 CommandLine cmd = new CommandLine (config .getExecutable ());
52125 for (String arg : args ) {
53- cmd .addArgument (arg );
126+ if (arg == null ) {
127+ continue ;
128+ }
129+ // handleQuoting=false:子进程经 exec(argv) 启动而非 shell,默认引号策略
130+ // 会把含空格参数的字面双引号烤进 argv,损坏多词 prompt 与路径。
131+ cmd .addArgument (arg , false );
54132 }
55133
56134 DefaultExecutor executor = new DefaultExecutor ();
57135 ByteArrayOutputStream stdout = new ByteArrayOutputStream ();
58136 ByteArrayOutputStream stderr = new ByteArrayOutputStream ();
59- executor .setStreamHandler (new org .apache .commons .exec .PumpStreamHandler (stdout , stderr ));
137+ // 始终向子进程提供(可能为空的)立即关闭的 stdin:消费型命令读到 EOF 即结束,
138+ // 已关闭管道不会与输入泵产生写后关闭竞态。
139+ byte [] stdinBytes = stdin == null ? new byte [0 ] : stdin .getBytes (StandardCharsets .UTF_8 );
140+ executor .setStreamHandler (new PumpStreamHandler (stdout , stderr ,
141+ new ByteArrayInputStream (stdinBytes )));
60142
61143 File workingDirectory = resolveWorkingDirectory ();
62144 if (workingDirectory != null ) {
@@ -68,38 +150,50 @@ public HermesCliResult execute(String... args) {
68150 ExecuteWatchdog watchdog = new ExecuteWatchdog (timeoutMs );
69151 executor .setWatchdog (watchdog );
70152
153+ long startNanos = System .nanoTime ();
71154 try {
72155 int exitCode = executor .execute (cmd );
73- String out = stdout . toString ( ).trim ();
74- String err = stderr . toString ( ).trim ();
156+ String out = decodeUtf8 ( stdout ).trim ();
157+ String err = decodeUtf8 ( stderr ).trim ();
75158 if (config .getDebug ().allows (HttpLogLevel .BASIC )) {
76159 log .debug ("Hermes CLI executed: exitCode={}, stdoutLength={}, stderrLength={}" ,
77160 exitCode , out .length (), err .length ());
78161 }
79162 if (config .getDebug ().allows (HttpLogLevel .BODY )) {
80163 log .debug ("Hermes CLI output: stdout={}, stderr={}" , truncate (out ), truncate (err ));
81164 }
165+ if (watchdog .killedProcess ()) {
166+ return timeoutResult (stdout , stderr , timeoutMs );
167+ }
82168 return new HermesCliResult (exitCode , out , err );
169+ } catch (ExecuteException e ) {
170+ // commons-exec 对每次非零退出(以及 watchdog 击杀)都会抛出 ExecuteException;
171+ // 抛出前输出泵已 join,两路缓冲是完整的——原样保留真实退出码与输出,
172+ // 超时判定用截止时间法规避 killedProcess() 的观察竞态。
173+ String out = decodeUtf8 (stdout ).trim ();
174+ String err = decodeUtf8 (stderr ).trim ();
175+ boolean timedOut = watchdog .killedProcess ()
176+ || System .nanoTime () - startNanos >= timeoutMs * 1_000_000L ;
177+ if (timedOut ) {
178+ log .warn ("Hermes CLI timed out after {} ms" , timeoutMs );
179+ return timeoutResult (stdout , stderr , timeoutMs );
180+ }
181+ if (config .getDebug ().allows (HttpLogLevel .BASIC )) {
182+ log .debug ("Hermes CLI failed: exitCode={}, stdoutLength={}, stderrLength={}" ,
183+ e .getExitValue (), out .length (), err .length ());
184+ }
185+ return new HermesCliResult (e .getExitValue (), out , err );
83186 } catch (IOException e ) {
84187 log .warn ("CLI execution failed" , e );
85188 return new HermesCliResult (-1 , "" , e .getMessage ());
86189 }
87190 }
88191
89- /**
90- * <p>探测 Hermes CLI 是否可执行。</p>
91- *
92- * @return CLI 版本命令成功退出时返回 {@code true}
93- * @since 1.0.0
94- */
95- public boolean probe () {
96- try {
97- HermesCliConfig probeConfig = copyForProbe (config );
98- HermesCliResult result = new HermesCliExecutor (probeConfig ).execute ("--version" );
99- return result .isSuccess ();
100- } catch (Exception e ) {
101- return false ;
102- }
192+ private HermesCliResult timeoutResult (ByteArrayOutputStream stdout , ByteArrayOutputStream stderr ,
193+ long timeoutMs ) {
194+ String out = decodeUtf8 (stdout ).trim ();
195+ String err = TIMEOUT_PREFIX + timeoutMs + " ms\n " + decodeUtf8 (stderr ).trim ();
196+ return new HermesCliResult (-1 , out , err );
103197 }
104198
105199 private static HermesCliConfig copyForProbe (HermesCliConfig source ) {
0 commit comments