From c373b9f8db13d8209ac85cf2fe37ebdd4198ef28 Mon Sep 17 00:00:00 2001 From: hengyunabc Date: Thu, 24 Sep 2026 13:25:01 +0800 Subject: [PATCH 1/2] fix: improve as.sh version selection and startup reliability --- bin/as.sh | 147 ++++---- .../com/taobao/arthas/boot/AsScriptTest.java | 342 ++++++++++++++++++ 2 files changed, 428 insertions(+), 61 deletions(-) create mode 100644 boot/src/test/java/com/taobao/arthas/boot/AsScriptTest.java diff --git a/bin/as.sh b/bin/as.sh index 36d4884aab8..7668458b440 100755 --- a/bin/as.sh +++ b/bin/as.sh @@ -162,14 +162,13 @@ COMMAND_LOCATIONS= # if arguments contains -c/--command or -f/--batch-file, BATCH_MODE will be true BATCH_MODE=false -# define arthas's temp dir -TMP_DIR=/tmp - # arthas remote url # https://arthas.aliyun.com/download/3.1.7?mirror=aliyun REMOTE_DOWNLOAD_URL="https://arthas.aliyun.com/download/PLACEHOLDER_VERSION?mirror=PLACEHOLDER_REPO" # update timeout(sec) SO_TIMEOUT=5 +# 下载完整包时允许更长的传输时间,但不能无限等待。 +DOWNLOAD_TIMEOUT=120 # define JVM's OPS JVM_OPTS="" @@ -185,7 +184,7 @@ case "$(uname -s)" in *) OS_TYPE="UNKNOWN" esac -# check curl/grep/awk/telnet/unzip command +# 检查公共依赖,telnet 仅在交互模式下需要。 if ! [ -x "$(command -v curl)" ]; then echo 'Error: curl is not installed. Try to use java -jar arthas-boot.jar' >&2 exit 1 @@ -198,10 +197,6 @@ if ! [ -x "$(command -v awk)" ]; then echo 'Error: awk is not installed. Try to use java -jar arthas-boot.jar' >&2 exit 1 fi -if ! [ -x "$(command -v telnet)" ]; then - echo 'Error: telnet is not installed. Try to use java -jar arthas-boot.jar' >&2 - exit 1 -fi if ! [ -x "$(command -v unzip)" ]; then echo 'Error: unzip is not installed. Try to use java -jar arthas-boot.jar' >&2 exit 1 @@ -321,13 +316,39 @@ reset_for_env() # get latest version from local get_local_version() { - ls "${ARTHAS_LIB_DIR}" | sort | tail -1 + local arthas_home + local version_dir + for arthas_home in "${ARTHAS_LIB_DIR}"/*/arthas; do + if is_arthas_home "${arthas_home}"; then + version_dir=${arthas_home%/arthas} + printf '%s\n' "${version_dir##*/}" + fi + done | LC_ALL=C sort -t. -k1,1n -k2,2n -k3,3n | tail -1 +} + +# 只选择包含启动器所需 JAR 的安装目录,忽略中断下载留下的残缺缓存。 +is_arthas_home() +{ + local jar + for jar in arthas-core.jar arthas-agent.jar arthas-spy.jar arthas-client.jar; do + [[ -f "${1}/${jar}" && -s "${1}/${jar}" ]] || return 1 + done } # get latest version from remote get_remote_version() { - curl -sLk "https://arthas.aliyun.com/api/latest_version" + local remote_version + remote_version=$(curl -sSLk --fail \ + --connect-timeout "${SO_TIMEOUT}" --max-time "${SO_TIMEOUT}" \ + "https://arthas.aliyun.com/api/latest_version") || return 1 + + # 接口应返回版本号,不能把错误页面当成版本参与缓存选择。 + if [[ ! "${remote_version}" =~ ^[0-9]+\.[0-9]+\.[0-9]+([.-][[:alnum:]]+)*$ ]]; then + echo "Invalid Arthas version response: ${remote_version}" >&2 + return 1 + fi + printf '%s\n' "${remote_version}" } # check version greater @@ -335,53 +356,52 @@ version_gt() { local remote_version=$1 local arthas_local_version=$2 - [[ "$remote_version" > "$arthas_local_version" ]] && return 0 || return 1 + [[ -n "${remote_version}" && "${remote_version}" != "${arthas_local_version}" ]] || return 1 + # 使用各数字字段排序,兼容没有 sort -V 的 macOS。 + [[ "$(printf '%s\n' "${remote_version}" "${arthas_local_version}" \ + | LC_ALL=C sort -t. -k1,1n -k2,2n -k3,3n | tail -1)" == "${remote_version}" ]] } # update arthas if necessary update_if_necessary() -{ +( local update_version=$1 + local target_lib_dir="${ARTHAS_LIB_DIR}/${update_version}/arthas" - if [ ! -d "${ARTHAS_LIB_DIR}/${update_version}" ]; then - echo "updating version ${update_version} ..." - - local temp_target_lib_dir="$TMP_DIR/temp_${update_version}_$$" - local temp_target_lib_zip="${temp_target_lib_dir}/arthas-${update_version}-bin.zip" - local target_lib_dir="${ARTHAS_LIB_DIR}/${update_version}/arthas" - - # clean - rm -rf "${temp_target_lib_dir}" - rm -rf "${target_lib_dir}" - - mkdir -p "${temp_target_lib_dir}" \ - || exit_on_err 1 "create ${temp_target_lib_dir} fail." - - # download current arthas version - local downloadUrl="${REMOTE_DOWNLOAD_URL//PLACEHOLDER_REPO/${REPO_MIRROR}}" - downloadUrl="${downloadUrl//PLACEHOLDER_VERSION/${update_version}}" - echo "Download arthas from: ${downloadUrl}" - curl \ - -#Lk \ - --connect-timeout ${SO_TIMEOUT} \ - -o "${temp_target_lib_zip}" \ - "${downloadUrl}" \ - || return 1 - - # unzip arthas lib - if ! (unzip "${temp_target_lib_zip}" -d "${temp_target_lib_dir}") ; then - rm -rf "${temp_target_lib_dir}" "${ARTHAS_LIB_DIR}/${update_version}" - return 1 - fi - - mkdir -p "${ARTHAS_LIB_DIR}/${update_version}" - # rename - mv "${temp_target_lib_dir}" "${target_lib_dir}" || return 1 + if is_arthas_home "${target_lib_dir}"; then + return 0 + fi + echo "updating version ${update_version} ..." + + # 在目标文件系统中解压,验证完成后再替换目录;子 shell 负责清理所有失败路径。 + mkdir -p "${ARTHAS_LIB_DIR}/${update_version}" || return 1 + local temp_target_lib_dir + temp_target_lib_dir=$(mktemp -d "${ARTHAS_LIB_DIR}/${update_version}/.arthas.XXXXXX") || return 1 + trap 'rm -rf "${temp_target_lib_dir}"' EXIT + local temp_target_lib_zip="${temp_target_lib_dir}/arthas-${update_version}-bin.zip" + + local downloadUrl="${REMOTE_DOWNLOAD_URL//PLACEHOLDER_REPO/${REPO_MIRROR}}" + downloadUrl="${downloadUrl//PLACEHOLDER_VERSION/${update_version}}" + echo "Download arthas from: ${downloadUrl}" + curl -#Lk --fail \ + --connect-timeout "${SO_TIMEOUT}" --max-time "${DOWNLOAD_TIMEOUT}" \ + -o "${temp_target_lib_zip}" "${downloadUrl}" || return 1 + + unzip "${temp_target_lib_zip}" -d "${temp_target_lib_dir}" || return 1 + if ! is_arthas_home "${temp_target_lib_dir}"; then + echo "Downloaded Arthas ${update_version} is missing required JAR files." >&2 + return 1 + fi + rm -f "${temp_target_lib_zip}" || return 1 - # print success - echo "update completed." + # 另一启动进程可能已完成相同版本的安装。 + if is_arthas_home "${target_lib_dir}"; then + return 0 fi -} + rm -rf "${target_lib_dir}" || return 1 + mv "${temp_target_lib_dir}" "${target_lib_dir}" || return 1 + echo "update completed." +) # jps command may crash, so need to check it check_jps() { @@ -1052,37 +1072,42 @@ main() parse_arguments "${@}" \ || exit_on_err 1 "$(usage)" + if [[ "${ATTACH_ONLY}" = false && "${BATCH_MODE}" = false ]] \ + && ! [ -x "$(command -v telnet)" ]; then + exit_on_err 1 "Error: telnet is not installed. Try to use java -jar arthas-boot.jar" + fi + # try to find arthas home from --use-version if [[ (-z "${ARTHAS_HOME}") && (! -z "${USE_VERSION}") ]]; then - if [[ ! -d "${ARTHAS_LIB_DIR}/${USE_VERSION}/arthas" ]] ; then - update_if_necessary "${USE_VERSION}" || echo "update fail, ignore this update." 1>&2 - fi + update_if_necessary "${USE_VERSION}" \ + || exit_on_err 1 "Failed to install Arthas version ${USE_VERSION}." ARTHAS_HOME="${ARTHAS_LIB_DIR}/${USE_VERSION}/arthas" fi # try to set arthas home from as.sh directory if [ -z "${ARTHAS_HOME}" ] ; then - [[ -a "${DIR}/arthas-core.jar" ]] \ - && [[ -a "${DIR}/arthas-agent.jar" ]] \ - && [[ -a "${DIR}/arthas-spy.jar" ]] \ - && ARTHAS_HOME="${DIR}" + is_arthas_home "${DIR}" && ARTHAS_HOME="${DIR}" fi # try to find arthas under ~/.arthas/lib if [ -z "${ARTHAS_HOME}" ] ; then - local remote_version=$(get_remote_version) + local remote_version + remote_version=$(get_remote_version) \ + || echo "Failed to retrieve the latest Arthas version; trying the local cache." >&2 local arthas_local_version=$(get_local_version) - if $(version_gt $remote_version $arthas_local_version) ; then + if version_gt "${remote_version}" "${arthas_local_version}"; then update_if_necessary "${remote_version}" || echo "update fail, ignore this update." 1>&2 fi - local arthas_local_version=$(get_local_version) + arthas_local_version=$(get_local_version) + [[ -n "${arthas_local_version}" ]] \ + || exit_on_err 1 "No usable Arthas installation in ${ARTHAS_LIB_DIR}; please retry or specify --arthas-home." ARTHAS_HOME="${ARTHAS_LIB_DIR}/${arthas_local_version}/arthas" fi echo "Arthas home: ${ARTHAS_HOME}" - if [ ! -d "${ARTHAS_HOME}" ] ; then - exit_on_err 1 "Arthas home is not a directory, please delete it and retry." + if ! is_arthas_home "${ARTHAS_HOME}"; then + exit_on_err 1 "Arthas home ${ARTHAS_HOME} is missing required JAR files." fi sanity_check diff --git a/boot/src/test/java/com/taobao/arthas/boot/AsScriptTest.java b/boot/src/test/java/com/taobao/arthas/boot/AsScriptTest.java new file mode 100644 index 00000000000..2bef43a5e73 --- /dev/null +++ b/boot/src/test/java/com/taobao/arthas/boot/AsScriptTest.java @@ -0,0 +1,342 @@ +package com.taobao.arthas.boot; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.TimeUnit; +import java.util.stream.Stream; +import java.util.zip.ZipEntry; +import java.util.zip.ZipOutputStream; + +import org.junit.Assume; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +public class AsScriptTest { + private static final String[] JARS = { "arthas-core.jar", "arthas-agent.jar", "arthas-spy.jar", "arthas-client.jar" }; + + @Rule + public TemporaryFolder temporaryFolder = new TemporaryFolder(); + + private Path commands; + private Path library; + private Path launcher; + private Path javaCalls; + private Path curlCalls; + private Path download; + private Map environment; + + @Before + public void setUp() throws Exception { + Assume.assumeTrue("Bash launcher tests require a Unix shell", File.separatorChar == '/'); + commands = temporaryFolder.newFolder("commands").toPath(); + library = temporaryFolder.newFolder("library").toPath(); + javaCalls = temporaryFolder.newFile("java-calls").toPath(); + curlCalls = temporaryFolder.newFile("curl-calls").toPath(); + download = temporaryFolder.newFile("download.zip").toPath(); + createArchive(true); + + // 只暴露启动器所需命令,避免宿主机的 telnet、lsof 和 Java 进程影响结果。 + for (String command : Arrays.asList("dirname", "basename", "uname", "grep", "awk", "unzip", "ls", "sed", + "tr", "sort", "tail", "mkdir", "rm", "mv", "cp", "date", "id", "cut", "mktemp")) { + Process lookup = new ProcessBuilder("/bin/bash", "-c", "command -v \"$1\"", "lookup", command).start(); + byte[] output = readOutput(lookup); + assertEquals("Missing test dependency: " + command, 0, lookup.exitValue()); + Files.createSymbolicLink(commands.resolve(command), Paths.get(new String(output, StandardCharsets.UTF_8).trim())); + } + writeExecutable(commands.resolve("telnet"), "exit 0\n"); + writeExecutable(commands.resolve("ps"), "case \"$*\" in *user=*) id -u -n;; *) echo 1234;; esac\n"); + writeExecutable(commands.resolve("curl"), + "printf '%s\\n' \"$*\" >> \"$AS_TEST_CURL_CALLS\"\n" + + "output=\n" + + "while [[ $# -gt 0 ]]; do\n" + + " if [[ $1 == -o ]]; then shift; output=$1; fi\n" + + " shift\n" + + "done\n" + + "if [[ -z $output ]]; then\n" + + " [[ $AS_TEST_VERSION_FAILURE == true ]] && exit 22\n" + + " printf '%s' \"$AS_TEST_VERSION\"\n" + + "else\n" + + " [[ $AS_TEST_DOWNLOAD_FAILURE == true ]] && exit 28\n" + + " cp \"$AS_TEST_DOWNLOAD\" \"$output\"\n" + + "fi\n"); + + Path javaHome = temporaryFolder.newFolder("jdk").toPath(); + Files.createDirectory(javaHome.resolve("bin")); + writeExecutable(javaHome.resolve("bin/java"), + "if [[ $1 == -version ]]; then echo 'openjdk version \"17.0.2\"' >&2; exit 0; fi\n" + + "printf '%s\\n' \"$*\" >> \"$AS_TEST_JAVA_CALLS\"\n"); + writeExecutable(javaHome.resolve("bin/jps"), "echo '1234 example.Main'\n"); + + Path project = Paths.get(System.getProperty("user.dir")).toAbsolutePath(); + while (project != null && !Files.isRegularFile(project.resolve("bin/as.sh"))) { + project = project.getParent(); + } + assertTrue("Cannot locate bin/as.sh", project != null); + String script = new String(Files.readAllBytes(project.resolve("bin/as.sh")), StandardCharsets.UTF_8); + int mainCall = script.lastIndexOf("\nmain \"${@}\""); + assertTrue("Cannot locate launcher entry point", mainCall >= 0); + // 保留真实启动流程,仅把旧版本写死的 /tmp 改到测试临时目录。 + script = script.substring(0, mainCall) + "\nTMP_DIR=\"$AS_TEST_TMP_DIR\"" + script.substring(mainCall); + launcher = temporaryFolder.newFile("as.sh").toPath(); + Files.write(launcher, script.getBytes(StandardCharsets.UTF_8)); + + environment = new HashMap(); + environment.put("PATH", commands.toString()); + environment.put("JAVA_HOME", javaHome.toString()); + environment.put("ARTHAS_LIB_DIR", library.toString()); + environment.put("LC_ALL", "C"); + environment.put("AS_TEST_VERSION", "4.3.5"); + environment.put("AS_TEST_DOWNLOAD", download.toString()); + environment.put("AS_TEST_JAVA_CALLS", javaCalls.toString()); + environment.put("AS_TEST_CURL_CALLS", curlCalls.toString()); + environment.put("AS_TEST_TMP_DIR", temporaryFolder.newFolder("downloads").getAbsolutePath()); + } + + @Test + public void choosesNewestNumericCachedVersion() throws Exception { + cache("4.3.9"); + Path newest = cache("4.3.10"); + assertHome(run(0, "--attach-only", "1234"), newest); + } + + @Test + public void downloadsNewerNumericVersion() throws Exception { + cache("4.3.9"); + environment.put("AS_TEST_VERSION", "4.3.10"); + Path newest = library.resolve("4.3.10/arthas"); + assertHome(run(0, "--attach-only", "1234"), newest); + assertComplete(newest); + } + + @Test + public void comparesMajorAndMinorVersionsNumerically() throws Exception { + cache("4.9.9"); + Path minor = cache("4.10.0"); + assertHome(run(0, "--attach-only", "1234"), minor); + cache("9.10.0"); + Path major = cache("10.0.0"); + assertHome(run(0, "--attach-only", "1234"), major); + } + + @Test + public void repairsEmptyVersionDirectory() throws Exception { + Files.createDirectory(library.resolve("4.3.5")); + Path home = library.resolve("4.3.5/arthas"); + assertHome(run(0, "--attach-only", "1234"), home); + assertComplete(home); + } + + @Test + public void repairsExplicitIncompleteVersion() throws Exception { + Path home = Files.createDirectories(library.resolve("4.3.5/arthas")); + Files.write(home.resolve("arthas-core.jar"), new byte[] { 1 }); + run(0, "--use-version", "4.3.5", "--attach-only", "1234"); + assertComplete(home); + } + + @Test + public void repairsEmptyJar() throws Exception { + Path home = cache("4.3.5"); + Files.write(home.resolve("arthas-agent.jar"), new byte[0]); + run(0, "--attach-only", "1234"); + assertComplete(home); + } + + @Test + public void fallsBackWhenArchiveIsIncomplete() throws Exception { + Path previous = cache("4.3.4"); + createArchive(false); + assertHome(run(0, "--attach-only", "1234"), previous); + assertFalse(Files.exists(library.resolve("4.3.5/arthas"))); + assertComplete(previous); + } + + @Test + public void fallsBackWhenArchiveCannotBeUnzipped() throws Exception { + Path previous = cache("4.3.4"); + Files.write(download, "not a zip archive".getBytes(StandardCharsets.UTF_8)); + assertHome(run(0, "--attach-only", "1234"), previous); + assertComplete(previous); + assertNoTemporaryDownloads(); + } + + @Test + public void fallsBackWhenDownloadFailsAndIgnoresBrokenCache() throws Exception { + Path previous = cache("4.3.4"); + Files.createDirectories(library.resolve("4.3.5/arthas")); + environment.put("AS_TEST_DOWNLOAD_FAILURE", "true"); + assertHome(run(0, "--attach-only", "1234"), previous); + assertComplete(previous); + assertNoTemporaryDownloads(); + } + + @Test + public void explicitVersionFailureStopsBeforeAttach() throws Exception { + cache("4.3.4"); + environment.put("AS_TEST_DOWNLOAD_FAILURE", "true"); + String output = run(1, "--use-version", "4.3.5", "--attach-only", "1234"); + assertTrue(output, output.contains("Failed to install Arthas version 4.3.5")); + assertEquals("", read(javaCalls)); + assertNoTemporaryDownloads(); + } + + @Test + public void failedVersionLookupUsesValidCache() throws Exception { + Path previous = cache("4.3.4"); + environment.put("AS_TEST_VERSION_FAILURE", "true"); + assertHome(run(0, "--attach-only", "1234"), previous); + } + + @Test + public void invalidVersionResponseDoesNotTriggerDownload() throws Exception { + Path previous = cache("4.3.4"); + environment.put("AS_TEST_VERSION", "service unavailable"); + assertHome(run(0, "--attach-only", "1234"), previous); + assertEquals(read(curlCalls), 1, Files.readAllLines(curlCalls, StandardCharsets.UTF_8).size()); + } + + @Test + public void reportsUnavailableRuntimeWhenLookupFailsWithoutCache() throws Exception { + environment.put("AS_TEST_VERSION_FAILURE", "true"); + String output = run(1, "--attach-only", "1234"); + assertTrue(output, output.contains("No usable Arthas installation")); + assertEquals("", read(javaCalls)); + } + + @Test + public void attachOnlyDoesNotRequireTelnet() throws Exception { + Files.delete(commands.resolve("telnet")); + Path home = cache("4.3.5"); + assertHome(run(0, "--attach-only", "1234"), home); + assertTrue(read(javaCalls).contains("arthas-core.jar")); + } + + @Test + public void batchCommandDoesNotRequireTelnet() throws Exception { + Files.delete(commands.resolve("telnet")); + cache("4.3.5"); + run(0, "-c", "version", "1234"); + assertTrue(read(javaCalls), read(javaCalls).contains("-c version")); + } + + @Test + public void batchFileDoesNotRequireTelnet() throws Exception { + Files.delete(commands.resolve("telnet")); + cache("4.3.5"); + Path batch = temporaryFolder.newFile("commands.as").toPath(); + Files.write(batch, "version\n".getBytes(StandardCharsets.UTF_8)); + run(0, "-f", batch.toString(), "1234"); + assertTrue(read(javaCalls), read(javaCalls).contains("-f " + batch)); + } + + @Test + public void interactiveModeRequiresTelnetBeforeAttach() throws Exception { + Files.delete(commands.resolve("telnet")); + cache("4.3.5"); + String output = run(1, "1234"); + assertTrue(output, output.contains("telnet is not installed")); + assertEquals("", read(javaCalls)); + } + + @Test + public void requestsHaveHttpFailureHandlingAndTimeouts() throws Exception { + run(0, "--attach-only", "1234"); + List requests = Files.readAllLines(curlCalls, StandardCharsets.UTF_8); + assertEquals(requests.toString(), 2, requests.size()); + for (String request : requests) { + assertTrue(request, request.contains("--fail")); + assertTrue(request, request.contains("--connect-timeout")); + assertTrue(request, request.contains("--max-time")); + } + assertNoTemporaryDownloads(); + } + + private Path cache(String version) throws IOException { + Path home = Files.createDirectories(library.resolve(version).resolve("arthas")); + for (String jar : JARS) { + Files.write(home.resolve(jar), new byte[] { 1 }); + } + return home; + } + + private void createArchive(boolean complete) throws IOException { + try (ZipOutputStream zip = new ZipOutputStream(Files.newOutputStream(download))) { + for (String jar : complete ? JARS : new String[] { JARS[0] }) { + zip.putNextEntry(new ZipEntry(jar)); + zip.write(1); + zip.closeEntry(); + } + } + } + + private String run(int expectedExit, String... arguments) throws Exception { + List command = new ArrayList(Arrays.asList("/bin/bash", launcher.toString())); + command.addAll(Arrays.asList(arguments)); + Path output = temporaryFolder.newFile().toPath(); + ProcessBuilder builder = new ProcessBuilder(command).directory(temporaryFolder.getRoot()); + builder.environment().putAll(environment); + builder.redirectErrorStream(true).redirectOutput(output.toFile()); + Process process = builder.start(); + if (!process.waitFor(10, TimeUnit.SECONDS)) { + process.destroyForcibly(); + throw new AssertionError("Launcher timed out: " + read(output)); + } + String text = read(output); + assertEquals(text, expectedExit, process.exitValue()); + return text; + } + + private void assertHome(String output, Path home) { + assertTrue(output, output.contains("Arthas home: " + home + "\n")); + } + + private void assertComplete(Path home) throws IOException { + for (String jar : JARS) { + assertTrue(home.resolve(jar).toString(), Files.isRegularFile(home.resolve(jar))); + assertTrue(Files.size(home.resolve(jar)) > 0); + } + } + + private void assertNoTemporaryDownloads() throws IOException { + try (Stream paths = Files.walk(temporaryFolder.getRoot().toPath())) { + assertFalse(paths.anyMatch(path -> path.getFileName().toString().startsWith("temp_") + || path.getFileName().toString().startsWith(".arthas."))); + } + } + + private void writeExecutable(Path file, String body) throws IOException { + Files.write(file, ("#!/bin/bash\n" + body).getBytes(StandardCharsets.UTF_8)); + assertTrue(file.toFile().setExecutable(true)); + } + + private String read(Path file) throws IOException { + return new String(Files.readAllBytes(file), StandardCharsets.UTF_8); + } + + private byte[] readOutput(Process process) throws Exception { + ByteArrayOutputStream output = new ByteArrayOutputStream(); + byte[] buffer = new byte[1024]; + int count; + while ((count = process.getInputStream().read(buffer)) != -1) { + output.write(buffer, 0, count); + } + assertTrue(process.waitFor(5, TimeUnit.SECONDS)); + return output.toByteArray(); + } +} From 86142f360a97192767183d04f4b5353226f84394 Mon Sep 17 00:00:00 2001 From: hengyunabc Date: Thu, 24 Sep 2026 15:41:58 +0800 Subject: [PATCH 2/2] fix: preserve launcher install permissions and clean failed updates --- bin/as.sh | 16 +++++--- .../com/taobao/arthas/boot/AsScriptTest.java | 41 +++++++++++++++++-- 2 files changed, 48 insertions(+), 9 deletions(-) diff --git a/bin/as.sh b/bin/as.sh index 7668458b440..054fbff92d5 100755 --- a/bin/as.sh +++ b/bin/as.sh @@ -373,12 +373,15 @@ update_if_necessary() fi echo "updating version ${update_version} ..." - # 在目标文件系统中解压,验证完成后再替换目录;子 shell 负责清理所有失败路径。 - mkdir -p "${ARTHAS_LIB_DIR}/${update_version}" || return 1 - local temp_target_lib_dir - temp_target_lib_dir=$(mktemp -d "${ARTHAS_LIB_DIR}/${update_version}/.arthas.XXXXXX") || return 1 - trap 'rm -rf "${temp_target_lib_dir}"' EXIT - local temp_target_lib_zip="${temp_target_lib_dir}/arthas-${update_version}-bin.zip" + # 暂存目录保持隐藏,下载失败时不会留下被 Java 启动器误选的空版本目录。 + mkdir -p "${ARTHAS_LIB_DIR}" || return 1 + local temp_dir + temp_dir=$(mktemp -d "${ARTHAS_LIB_DIR}/.arthas.XXXXXX") || return 1 + trap 'rm -rf "${temp_dir}"' EXIT + local temp_target_lib_dir="${temp_dir}/arthas" + local temp_target_lib_zip="${temp_dir}/arthas-${update_version}-bin.zip" + # 正式安装使用 mkdir 按 umask 创建的目录,避免继承 mktemp 的 0700 权限。 + mkdir "${temp_target_lib_dir}" || return 1 local downloadUrl="${REMOTE_DOWNLOAD_URL//PLACEHOLDER_REPO/${REPO_MIRROR}}" downloadUrl="${downloadUrl//PLACEHOLDER_VERSION/${update_version}}" @@ -398,6 +401,7 @@ update_if_necessary() if is_arthas_home "${target_lib_dir}"; then return 0 fi + mkdir -p "${ARTHAS_LIB_DIR}/${update_version}" || return 1 rm -rf "${target_lib_dir}" || return 1 mv "${temp_target_lib_dir}" "${target_lib_dir}" || return 1 echo "update completed." diff --git a/boot/src/test/java/com/taobao/arthas/boot/AsScriptTest.java b/boot/src/test/java/com/taobao/arthas/boot/AsScriptTest.java index 2bef43a5e73..2dd1a5df081 100644 --- a/boot/src/test/java/com/taobao/arthas/boot/AsScriptTest.java +++ b/boot/src/test/java/com/taobao/arthas/boot/AsScriptTest.java @@ -11,6 +11,7 @@ import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; +import java.nio.file.attribute.PosixFilePermissions; import java.util.ArrayList; import java.util.Arrays; import java.util.HashMap; @@ -91,8 +92,9 @@ public void setUp() throws Exception { String script = new String(Files.readAllBytes(project.resolve("bin/as.sh")), StandardCharsets.UTF_8); int mainCall = script.lastIndexOf("\nmain \"${@}\""); assertTrue("Cannot locate launcher entry point", mainCall >= 0); - // 保留真实启动流程,仅把旧版本写死的 /tmp 改到测试临时目录。 - script = script.substring(0, mainCall) + "\nTMP_DIR=\"$AS_TEST_TMP_DIR\"" + script.substring(mainCall); + // 显式设置 umask,避免宿主设置影响权限断言;旧版本的 /tmp 仍隔离到测试目录。 + script = script.substring(0, mainCall) + "\numask \"$AS_TEST_UMASK\"\nTMP_DIR=\"$AS_TEST_TMP_DIR\"" + + script.substring(mainCall); launcher = temporaryFolder.newFile("as.sh").toPath(); Files.write(launcher, script.getBytes(StandardCharsets.UTF_8)); @@ -101,6 +103,7 @@ public void setUp() throws Exception { environment.put("JAVA_HOME", javaHome.toString()); environment.put("ARTHAS_LIB_DIR", library.toString()); environment.put("LC_ALL", "C"); + environment.put("AS_TEST_UMASK", "022"); environment.put("AS_TEST_VERSION", "4.3.5"); environment.put("AS_TEST_DOWNLOAD", download.toString()); environment.put("AS_TEST_JAVA_CALLS", javaCalls.toString()); @@ -124,6 +127,23 @@ public void downloadsNewerNumericVersion() throws Exception { assertComplete(newest); } + @Test + public void downloadsRespectUmask() throws Exception { + String[][] permissions = { { "022", "rwxr-xr-x" }, { "027", "rwxr-x---" } }; + for (int i = 0; i < permissions.length; i++) { + String version = "4.3." + (5 + i); + environment.put("AS_TEST_UMASK", permissions[i][0]); + environment.put("AS_TEST_VERSION", version); + Path home = library.resolve(version).resolve("arthas"); + assertHome(run(0, "--attach-only", "1234"), home); + assertComplete(home); + assertEquals(PosixFilePermissions.fromString(permissions[i][1]), Files.getPosixFilePermissions(home)); + assertEquals(PosixFilePermissions.fromString(permissions[i][1]), + Files.getPosixFilePermissions(home.getParent())); + } + assertNoTemporaryDownloads(); + } + @Test public void comparesMajorAndMinorVersionsNumerically() throws Exception { cache("4.9.9"); @@ -163,8 +183,9 @@ public void fallsBackWhenArchiveIsIncomplete() throws Exception { Path previous = cache("4.3.4"); createArchive(false); assertHome(run(0, "--attach-only", "1234"), previous); - assertFalse(Files.exists(library.resolve("4.3.5/arthas"))); + assertFalse(Files.exists(library.resolve("4.3.5"))); assertComplete(previous); + assertNoTemporaryDownloads(); } @Test @@ -172,6 +193,18 @@ public void fallsBackWhenArchiveCannotBeUnzipped() throws Exception { Path previous = cache("4.3.4"); Files.write(download, "not a zip archive".getBytes(StandardCharsets.UTF_8)); assertHome(run(0, "--attach-only", "1234"), previous); + assertFalse(Files.exists(library.resolve("4.3.5"))); + assertComplete(previous); + assertNoTemporaryDownloads(); + } + + @Test + public void failedDownloadDoesNotCreateVersionDirectory() throws Exception { + Path previous = cache("4.3.4"); + environment.put("AS_TEST_DOWNLOAD_FAILURE", "true"); + assertHome(run(0, "--attach-only", "1234"), previous); + // Java 启动器会枚举版本目录,失败下载不能留下一个虚假的最新版本。 + assertFalse(Files.exists(library.resolve("4.3.5"))); assertComplete(previous); assertNoTemporaryDownloads(); } @@ -182,6 +215,7 @@ public void fallsBackWhenDownloadFailsAndIgnoresBrokenCache() throws Exception { Files.createDirectories(library.resolve("4.3.5/arthas")); environment.put("AS_TEST_DOWNLOAD_FAILURE", "true"); assertHome(run(0, "--attach-only", "1234"), previous); + assertTrue(Files.isDirectory(library.resolve("4.3.5/arthas"))); assertComplete(previous); assertNoTemporaryDownloads(); } @@ -193,6 +227,7 @@ public void explicitVersionFailureStopsBeforeAttach() throws Exception { String output = run(1, "--use-version", "4.3.5", "--attach-only", "1234"); assertTrue(output, output.contains("Failed to install Arthas version 4.3.5")); assertEquals("", read(javaCalls)); + assertFalse(Files.exists(library.resolve("4.3.5"))); assertNoTemporaryDownloads(); }