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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
112 changes: 111 additions & 1 deletion client/src/main/java/com/taobao/arthas/client/TelnetConsole.java
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,11 @@ public class TelnetConsole {

private static final byte CTRL_C = 0x03;

/**
* 让命令结果以纯文本输出的管道
*/
private static final String PLAINTEXT_PIPE = "| plaintext";

// ------- Status codes ------- //
/**
* Process success
Expand Down Expand Up @@ -417,7 +422,7 @@ public void run() {
}
}
// send command to server
outputStream.write((command + " | plaintext\n").getBytes(StandardCharsets.UTF_8));
outputStream.write((toBatchCommandLine(command) + "\n").getBytes(StandardCharsets.UTF_8));
outputStream.flush();
}

Expand All @@ -430,6 +435,111 @@ public void run() {
return STATUS_OK;
}

/**
* 生成批处理模式下发给服务端的命令行:按服务端的 token 规则找到最后一个文本 token,
* 需要时在它前面或命令末尾插入 plaintext 管道,让结果以纯文本返回。
*/
private static String toBatchCommandLine(String command) {
String lastTokenValue = null;
int lastTokenStart = -1;
int lastTokenEnd = -1;
boolean hasRedirect = false;
// 最后一个文本 token 的后台标记是否来自 `|&` 拆分,这种写法需要按整个 token 改写
boolean pipeAndBackground = false;

int index = 0;
int length = command.length();
while (index < length) {
char c = command.charAt(index);
if (isBlank(c)) {
index++;
continue;
}
int start = index;
StringBuilder value = new StringBuilder();
int quote = 0; // 0 表示没有引号,否则是当前的引号字符
boolean pendingEscape = false;
while (index < length) {
c = command.charAt(index);
boolean isQuote = false;
boolean isEscapePrefix = false;
boolean isEscaped = false;
if (pendingEscape) {
pendingEscape = false;
isEscaped = true;
} else if (quote == 0) {
if (c == '\'') {
quote = '\'';
isQuote = true;
} else if (c == '"') {
quote = '"';
isQuote = true;
} else if (c == '\\') {
pendingEscape = true;
isEscapePrefix = true;
}
} else if (quote == '\'') {
if (c == '\'') {
quote = 0;
isQuote = true;
}
} else if (c == '"') {
quote = 0;
isQuote = true;
} else if (c == '\\') {
pendingEscape = true;
isEscapePrefix = true;
}
// 未被引号或转义保护的空白结束当前 token
if (quote == 0 && !isEscaped && isBlank(c)) {
break;
}
if (!isQuote && !isEscapePrefix) {
if (isEscaped && quote == '"' && c != '"') {
value.append('\\');
}
value.append(c);
}
index++;
}

// 服务端 CliTokenImpl#correctPipeChar 只做一次纠正:token 结尾或开头的 `|` 会被拆成单独的 token,
// 拆分后与管道配对的那部分是重定向符或后台标记(`|>` 是管道加输出重定向,`|&` 是管道加后台标记)
String tokenValue = value.toString();
boolean pipeSuffix = tokenValue.length() > 1 && tokenValue.endsWith("|");
boolean pipePrefix = !pipeSuffix && tokenValue.length() > 1 && tokenValue.startsWith("|");
String otherPart = pipeSuffix ? tokenValue.substring(0, tokenValue.length() - 1)
: pipePrefix ? tokenValue.substring(1) : tokenValue;
lastTokenStart = start;
lastTokenEnd = index;
if (">".equals(otherPart) || ">>".equals(otherPart)) {
hasRedirect = true;
}
// `x|` 拆分后最后一个文本 token 是管道本身,`|x` 才是 x
lastTokenValue = pipeSuffix ? "|" : otherPart;
pipeAndBackground = pipePrefix && "&".equals(otherPart);
}

if (!"&".equals(lastTokenValue)) {
// 前台命令:结果按纯文本输出
return command + " " + PLAINTEXT_PIPE;
}
if (hasRedirect) {
// 后台任务带重定向:重定向的输出已经是纯文本,原样发送
return command;
}
if (pipeAndBackground) {
// `|&` 会被服务端拆成管道和后台标记,整个 token 改写成 `| plaintext &`
return command.substring(0, lastTokenStart) + PLAINTEXT_PIPE + " &" + command.substring(lastTokenEnd);
}
// 后台任务:把 plaintext 管道插到后台标记之前,保留标记的原始字符和其后的空白
return command.substring(0, lastTokenStart) + PLAINTEXT_PIPE + " " + command.substring(lastTokenStart);
}

private static boolean isBlank(char c) {
return c == ' ' || c == '\t';
}

private static int findPromptAtLineStart(StringBuilder output) {
int fromIndex = 0;
int index;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@
import org.apache.commons.net.telnet.TelnetCommand;
import org.apache.commons.net.telnet.TelnetOption;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.EnumSource;

import java.io.ByteArrayOutputStream;
import java.io.IOException;
Expand All @@ -11,13 +14,17 @@
import java.net.ServerSocket;
import java.net.Socket;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Arrays;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.TimeUnit;

import static org.assertj.core.api.Assertions.assertThat;

/** 服务端按最后一个 token 的解码值识别后台标记;重定向输出本身就是纯文本。 */
class TelnetConsoleBatchModeTest {

@Test
Expand Down Expand Up @@ -99,6 +106,56 @@ void batchModeShouldIgnorePromptRepaintWhileEnteringChineseCommand() throws Exce
}
}

@ParameterizedTest(name = "[{index}] {0}")
@EnumSource(BatchCommandCase.class)
void batchModeShouldPlacePlaintextBeforeBackgroundTokenOnTheWire(BatchCommandCase batchCommandCase)
throws Exception {
ExecutorService executorService = Executors.newSingleThreadExecutor();
try (ServerSocket serverSocket = new ServerSocket(0)) {
Future<BinaryServerResult> serverResult = executorService.submit(() -> runBinaryServer(serverSocket));

int status = TelnetConsole.process(new String[] {
"--quiet",
"-c",
batchCommandCase.command,
"-t",
"1000",
"127.0.0.1",
String.valueOf(serverSocket.getLocalPort())
});

BinaryServerResult result = serverResult.get(2, TimeUnit.SECONDS);
assertThat(status).isEqualTo(TelnetConsole.STATUS_OK);
assertThat(result.clientWillBinary).isTrue();
assertThat(result.clientDoBinary).isTrue();
assertThat(result.command).isEqualTo(batchCommandCase.expectedWireCommand);
assertThat(result.quit).isEqualTo("quit");
} finally {
executorService.shutdownNow();
}
}

@Test
void batchFileShouldPreserveBackgroundRedirectionAndTrailingWhitespace(@TempDir Path tempDir) throws Exception {
String command = "watch Foo m > & \t";
Path batchFile = tempDir.resolve("commands.as");
Files.write(batchFile, Arrays.asList("", command, " \t"), StandardCharsets.UTF_8);
ExecutorService executorService = Executors.newSingleThreadExecutor();
try (ServerSocket serverSocket = new ServerSocket(0)) {
Future<BinaryServerResult> serverResult = executorService.submit(() -> runBinaryServer(serverSocket));
int status = TelnetConsole.process(new String[] {
"--quiet", "-f", batchFile.toString(), "-t", "2000",
"127.0.0.1", String.valueOf(serverSocket.getLocalPort())
});
BinaryServerResult result = serverResult.get(3, TimeUnit.SECONDS);
assertThat(status).isEqualTo(TelnetConsole.STATUS_OK);
assertThat(result.command).isEqualTo(command);
assertThat(result.quit).isEqualTo("quit");
} finally {
executorService.shutdownNow();
}
}

private static ServerResult runPromptFirstServer(ServerSocket serverSocket) throws IOException {
try (Socket socket = serverSocket.accept()) {
socket.setSoTimeout(2000);
Expand Down Expand Up @@ -225,6 +282,34 @@ private PromptRepaintServerResult(
}
}

private enum BatchCommandCase {
BARE_BACKGROUND_OPERATOR("trace Foo bar &", "trace Foo bar | plaintext &"),
REDIRECT_TO_CACHE_AND_BACKGROUND("watch c.t.X test returnObj > &", "watch c.t.X test returnObj > &"),
REDIRECT_FILE_AND_BACKGROUND("trace Foo bar > out.txt &", "trace Foo bar > out.txt &"),
APPEND_REDIRECT_FILE_AND_BACKGROUND("trace Test t >> test.out &", "trace Test t >> test.out &"),
FOREGROUND_COMMAND("version", "version | plaintext"),
FOREGROUND_PIPELINE("thread | grep x", "thread | grep x | plaintext"),
AMPERSAND_IN_QUOTED_ARGUMENT("ognl '\"a & b\"'", "ognl '\"a & b\"' | plaintext"),
QUOTED_REGEX_PIPE_WITH_BACKGROUND("trace -E 'A|B' method &", "trace -E 'A|B' method | plaintext &"),
QUOTED_BACKGROUND_OPERATOR("watch Foo m \"&\"", "watch Foo m | plaintext \"&\""),
ESCAPED_BACKGROUND_OPERATOR("watch Foo m \\&", "watch Foo m | plaintext \\&"),
ESCAPED_AMPERSAND_INSIDE_WORD("trace Foo foo\\&", "trace Foo foo\\& | plaintext"),
QUOTED_WORD_CONCATENATED_WITH_AMPERSAND("trace Foo 'foo'&", "trace Foo 'foo'& | plaintext"),
QUOTED_WORD_WITH_BLANK_AND_AMPERSAND("trace Foo 'foo &'", "trace Foo 'foo &' | plaintext"),
WEAKLY_QUOTED_ESCAPED_AMPERSAND("watch Foo m \"\\&\"", "watch Foo m \"\\&\" | plaintext"),
EMPTY_ARGUMENT_AFTER_AMPERSAND("trace Foo m & ''", "trace Foo m & '' | plaintext"),
PIPE_PREFIXED_BACKGROUND_OPERATOR("version |&", "version | plaintext &"),
QUOTED_PIPE_PREFIXED_BACKGROUND_OPERATOR("version \"|&\"", "version | plaintext &");

private final String command;
private final String expectedWireCommand;

private BatchCommandCase(String command, String expectedWireCommand) {
this.command = command;
this.expectedWireCommand = expectedWireCommand;
}
}

private static class TelnetApplicationReader {
private final InputStream inputStream;
private boolean clientWillBinary;
Expand Down
13 changes: 12 additions & 1 deletion site/docs/doc/batch-support.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,6 @@
- 目前需要每个命令占一行
- dashboard 务必指定执行次数 (`-n`),否则会导致批处理脚本无法终止
- watch/tt/trace/monitor/stack 等命令务必指定执行次数 (`-n`),否则会导致批处理脚本无法终止
- 可以使用异步后台任务,如 `watch c.t.X test returnObj > &`,让命令一直在后台运行,通过日志获取结果,[获取更多异步任务的信息](async.md)
:::

```
Expand All @@ -27,6 +26,18 @@ thread
sc -d org.apache.commons.lang.StringUtils
```

批处理脚本也支持输出重定向和后台任务,`&` 写在命令末尾表示后台任务:

```bash
trace -n 1 Test t >> test.out
trace Test t >> test.out &
watch c.t.X test returnObj > &
```

第一条命令执行一次后结束,结果追加到 `test.out`。第二条命令在后台运行,结果同样追加到 `test.out`,批处理脚本继续执行下一条命令。第三条命令未指定重定向文件名,结果写入 `~/logs/arthas-cache/` 目录下的日志文件。

指定重定向的后台任务将结果写入文件;未指定重定向时,结果仍会打印在批处理输出中。`&` 需要是命令行的最后一个参数,批处理脚本不支持行尾注释。更多信息参考 [后台异步任务](async.md)。

### 第二步:运行你的批处理脚本

通过`-f`执行脚本文件,批处理脚本默认会输出到标准输出中,可以将结果重定向到文件中。
Expand Down
13 changes: 12 additions & 1 deletion site/docs/en/doc/batch-support.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,18 @@ Note:
- Each command takes one line.
- Batch mode execution times (via `-n`) must be explicitly specified for `dashboard`, otherwise batch script cannot terminate.
- Commands such as `watch`/`tt`/`trace`/`monitor`/`stack` should include `-n` option to ensure the script can be able to quit.
- Also consider to use `async` (for example: `watch c.t.X test returnObj > &`) to put commands run at background and get the output from the log file, see more from [asynchronous job](async.md)

A batch script also supports output redirection and background jobs; an `&` at the end of a command runs it in the background:

```bash
trace -n 1 Test t >> test.out
trace Test t >> test.out &
watch c.t.X test returnObj > &
```

The first command finishes after one execution and appends its result to `test.out`. The second command runs in the background and appends its results to the same file, while the script continues with the next command. The third command omits the redirection filename and writes its results to a log file under `~/logs/arthas-cache/`.

A background job with redirection writes its output to a file; without redirection, its output still appears in the batch result. `&` must be the last argument of the command, and batch scripts do not support end-of-line comments. See [asynchronous job](async.md) for more details.

### Step 2: Run the script

Expand Down