From f80681c93894f7ea59146751987bc5a2505999e6 Mon Sep 17 00:00:00 2001 From: Richard Li Date: Wed, 17 Sep 2025 15:10:26 -0700 Subject: [PATCH] fix: fix resolve node failing when glibc patch is required --- .../services/amazonq/lsp/AmazonQLspService.kt | 128 +--------------- .../services/amazonq/lsp/NodeExePatcher.kt | 138 ++++++++++++++++++ .../amazonq/lsp/NodeExePatcherTest.kt | 66 +++++++++ 3 files changed, 205 insertions(+), 127 deletions(-) diff --git a/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/AmazonQLspService.kt b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/AmazonQLspService.kt index 5bc04abbb85..9d95df96998 100644 --- a/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/AmazonQLspService.kt +++ b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/AmazonQLspService.kt @@ -5,20 +5,17 @@ package software.aws.toolkits.jetbrains.services.amazonq.lsp import com.google.gson.ToNumberPolicy import com.intellij.execution.configurations.GeneralCommandLine -import com.intellij.execution.configurations.PathEnvironmentVariableUtil import com.intellij.execution.impl.ExecutionManagerImpl import com.intellij.execution.process.KillableColoredProcessHandler import com.intellij.execution.process.KillableProcessHandler import com.intellij.execution.process.ProcessEvent import com.intellij.execution.process.ProcessListener import com.intellij.execution.process.ProcessOutputType -import com.intellij.notification.NotificationAction import com.intellij.openapi.Disposable import com.intellij.openapi.application.ApplicationManager import com.intellij.openapi.components.Service import com.intellij.openapi.components.service import com.intellij.openapi.components.serviceIfCreated -import com.intellij.openapi.options.ShowSettingsUtil import com.intellij.openapi.project.Project import com.intellij.openapi.util.Disposer import com.intellij.openapi.util.Key @@ -82,12 +79,8 @@ import software.aws.toolkits.jetbrains.services.amazonq.lsp.util.WorkspaceFolder import software.aws.toolkits.jetbrains.services.amazonq.lsp.workspace.WorkspaceServiceHandler import software.aws.toolkits.jetbrains.services.amazonq.profile.QDefaultServiceConfig import software.aws.toolkits.jetbrains.services.amazonq.profile.QEndpoints -import software.aws.toolkits.jetbrains.services.cwc.controller.chat.telemetry.getStartUrl import software.aws.toolkits.jetbrains.services.telemetry.ClientMetadata import software.aws.toolkits.jetbrains.settings.LspSettings -import software.aws.toolkits.jetbrains.utils.notifyInfo -import software.aws.toolkits.resources.message -import software.aws.toolkits.telemetry.Telemetry import java.io.IOException import java.io.OutputStreamWriter import java.io.PipedInputStream @@ -96,7 +89,6 @@ import java.net.Proxy import java.net.URI import java.nio.charset.StandardCharsets import java.nio.file.Files -import java.nio.file.Path import java.util.concurrent.Future import java.util.concurrent.TimeUnit import kotlin.time.Duration.Companion.seconds @@ -460,7 +452,7 @@ private class AmazonQServerInstance(private val project: Project, private val cs } val node = if (SystemInfo.isWindows) "node.exe" else "node" - val nodePath = getNodeRuntimePath(artifact.resolve(node)) + val nodePath = NodeExePatcher.getNodeRuntimePath(project, artifact.resolve(node)) val emptyFile = Files.createTempFile("empty", null).toAbsolutePath().toString() val cmd = NodeExePatcher.patch(nodePath) @@ -597,124 +589,6 @@ private class AmazonQServerInstance(private val project: Project, private val cs } } - /** - * Resolves the path to a valid Node.js runtime in the following order of preference: - * 1. Uses the provided nodePath if it exists and is executable - * 2. Uses user-specified runtime path from LSP settings if available - * 3. Uses system Node.js if version 18+ is available - * 4. Falls back to original nodePath with a notification to configure runtime - * - * @param nodePath The initial Node.js runtime path to check, typically from the artifact directory - * @return Path The resolved Node.js runtime path to use for the LSP server - * - * Side effects: - * - Logs warnings if initial runtime path is invalid - * - Logs info when using alternative runtime path - * - Shows notification to user if no valid Node.js runtime is found - * - * Note: The function will return a path even if no valid runtime is found, but the LSP server - * may fail to start in that case. The caller should handle potential runtime initialization failures. - */ - private fun getNodeRuntimePath(nodePath: Path): Path { - val resolveNodeMetric = { isBundled: Boolean, success: Boolean -> - Telemetry.languageserver.setup.use { - it.id("q") - it.metadata("languageServerSetupStage", "resolveNode") - it.metadata("credentialStartUrl", getStartUrl(project)) - it.setAttribute("isBundledNode", isBundled) - it.success(success) - } - } - - // attempt to use user provided node runtime path - val nodeRuntime = LspSettings.getInstance().getNodeRuntimePath() - if (!nodeRuntime.isNullOrEmpty()) { - LOG.info { "Using node from $nodeRuntime " } - - resolveNodeMetric(false, true) - return Path.of(nodeRuntime) - } - - // attempt to use bundled node - if (Files.exists(nodePath) && Files.isExecutable(nodePath) && validateNode(nodePath) != null) { - resolveNodeMetric(true, true) - return nodePath - } else { - // use alternative node runtime if it is not found - LOG.warn { "Node Runtime download failed. Fallback to user environment search" } - - val localNode = locateNodeCommand() - if (localNode != null) { - LOG.info { "Using node from ${localNode.toAbsolutePath()}" } - - resolveNodeMetric(false, true) - return localNode - } - notifyInfo( - "Amazon Q", - message("amazonqFeatureDev.placeholder.node_runtime_message"), - project = project, - listOf( - NotificationAction.create( - message("codewhisperer.actions.open_settings.title") - ) { _, notification -> - ShowSettingsUtil.getInstance().showSettingsDialog(project, message("aws.settings.codewhisperer.configurable.title")) - }, - NotificationAction.create( - message("codewhisperer.notification.custom.simple.button.got_it") - ) { _, notification -> notification.expire() } - ) - ) - - resolveNodeMetric(false, false) - return nodePath - } - } - - /** - * Locates node executable ≥18 in system PATH. - * Uses IntelliJ's PathEnvironmentVariableUtil to find executables. - * - * @return Path? The absolute path to node ≥18 if found, null otherwise - */ - private fun locateNodeCommand(): Path? { - val exeName = if (SystemInfo.isWindows) "node.exe" else "node" - - return PathEnvironmentVariableUtil.findAllExeFilesInPath(exeName) - .asSequence() - .map { it.toPath() } - .filter { Files.isRegularFile(it) && Files.isExecutable(it) } - .firstNotNullOfOrNull(::validateNode) - } - - /** @return null if node is not suitable **/ - private fun validateNode(path: Path) = try { - val process = ProcessBuilder(path.toString(), "--version") - .redirectErrorStream(true) - .start() - - if (!process.waitFor(5, TimeUnit.SECONDS)) { - process.destroy() - null - } else if (process.exitValue() == 0) { - val version = process.inputStream.bufferedReader().readText().trim() - val majorVersion = version.removePrefix("v").split(".")[0].toIntOrNull() - - if (majorVersion != null && majorVersion >= 18) { - path.toAbsolutePath() - } else { - LOG.debug { "Node version < 18 found at: $path (version: $version)" } - null - } - } else { - LOG.debug { "Failed to get version from node at: $path" } - null - } - } catch (e: Exception) { - LOG.debug(e) { "Failed to check version for node at: $path" } - null - } - override fun dispose() { if (!launcherFuture.isDone) { try { diff --git a/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/NodeExePatcher.kt b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/NodeExePatcher.kt index d942ac67ad6..938953d2175 100644 --- a/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/NodeExePatcher.kt +++ b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/NodeExePatcher.kt @@ -4,11 +4,25 @@ package software.aws.toolkits.jetbrains.services.amazonq.lsp import com.intellij.execution.configurations.GeneralCommandLine +import com.intellij.execution.configurations.PathEnvironmentVariableUtil +import com.intellij.execution.util.ExecUtil +import com.intellij.notification.NotificationAction +import com.intellij.openapi.options.ShowSettingsUtil +import com.intellij.openapi.project.Project +import com.intellij.openapi.util.SystemInfo import com.intellij.util.system.CpuArch import com.intellij.util.text.nullize +import software.aws.toolkits.core.utils.debug import software.aws.toolkits.core.utils.exists import software.aws.toolkits.core.utils.getLogger import software.aws.toolkits.core.utils.info +import software.aws.toolkits.core.utils.warn +import software.aws.toolkits.jetbrains.services.cwc.controller.chat.telemetry.getStartUrl +import software.aws.toolkits.jetbrains.settings.LspSettings +import software.aws.toolkits.jetbrains.utils.notifyInfo +import software.aws.toolkits.resources.message +import software.aws.toolkits.telemetry.Telemetry +import java.nio.file.Files import java.nio.file.Path import java.nio.file.Paths @@ -38,6 +52,128 @@ object NodeExePatcher { } } + /** + * Resolves the path to a valid Node.js runtime in the following order of preference: + * 1. Uses the provided nodePath if it exists and is executable + * 2. Uses user-specified runtime path from LSP settings if available + * 3. Uses system Node.js if version 18+ is available + * 4. Falls back to original nodePath with a notification to configure runtime + * + * @param nodePath The initial Node.js runtime path to check, typically from the artifact directory + * @return Path The resolved Node.js runtime path to use for the LSP server + * + * Side effects: + * - Logs warnings if initial runtime path is invalid + * - Logs info when using alternative runtime path + * - Shows notification to user if no valid Node.js runtime is found + * + * Note: The function will return a path even if no valid runtime is found, but the LSP server + * may fail to start in that case. The caller should handle potential runtime initialization failures. + */ + fun getNodeRuntimePath(project: Project, nodePath: Path): Path { + val resolveNodeMetric = { isBundled: Boolean, success: Boolean -> + Telemetry.languageserver.setup.use { + it.id("q") + .metadata("languageServerSetupStage", "resolveNode") + .metadata("credentialStartUrl", getStartUrl(project)) + .metadata("isBundledNode", isBundled.toString()) + .success(success) + } + } + + // attempt to use user provided node runtime path + val nodeRuntime = LspSettings.getInstance().getNodeRuntimePath() + if (!nodeRuntime.isNullOrEmpty()) { + LOG.info { "Using node from $nodeRuntime " } + + resolveNodeMetric(false, true) + return Path.of(nodeRuntime) + } + + // attempt to use bundled node + if (Files.exists(nodePath) && validateNode(nodePath) != null) { + resolveNodeMetric(true, true) + return nodePath + } else { + // use alternative node runtime if it is not found + LOG.warn { "Node Runtime download failed. Fallback to user environment search" } + + val localNode = locateNodeCommand() + if (localNode != null) { + LOG.info { "Using node from ${localNode.toAbsolutePath()}" } + + resolveNodeMetric(false, true) + return localNode + } + notifyInfo( + "Amazon Q", + message("amazonqFeatureDev.placeholder.node_runtime_message"), + project = project, + listOf( + NotificationAction.create( + message("codewhisperer.actions.open_settings.title") + ) { _, notification -> + ShowSettingsUtil.getInstance().showSettingsDialog(project, message("aws.settings.codewhisperer.configurable.title")) + }, + NotificationAction.create( + message("codewhisperer.notification.custom.simple.button.got_it") + ) { _, notification -> notification.expire() } + ) + ) + + resolveNodeMetric(false, false) + return nodePath + } + } + + /** + * Locates node executable ≥18 in system PATH. + * Uses IntelliJ's PathEnvironmentVariableUtil to find executables. + * + * @return Path? The absolute path to node ≥18 if found, null otherwise + */ + private fun locateNodeCommand(): Path? { + val exeName = if (SystemInfo.isWindows) "node.exe" else "node" + + return PathEnvironmentVariableUtil.findAllExeFilesInPath(exeName) + .asSequence() + .map { it.toPath() } + .filter { Files.isRegularFile(it) && Files.isExecutable(it) } + .firstNotNullOfOrNull(::validateNode) + } + + /** @return null if node is not suitable **/ + private fun validateNode(path: Path) = try { + val process = patch(path) + .withParameters("--version") + .withRedirectErrorStream(true) + val output = ExecUtil.execAndGetOutput( + process, + 5000 + ) + + LOG.debug { "$process: ${output.stdout.trim()}" } + + if (output.exitCode == 0) { + val version = output.stdout.trim() + val majorVersion = version.removePrefix("v").split(".")[0].toIntOrNull() + + if (majorVersion != null && majorVersion >= 18) { + LOG.debug { "Node $version found at: $path" } + path.toAbsolutePath() + } else { + LOG.debug { "Node version < 18 found at: $path (version: $version)" } + null + } + } else { + LOG.debug { "Failed to get version from node at: $path" } + null + } + } catch (e: Exception) { + LOG.debug(e) { "Failed to check version for node at: $path" } + null + } + private val linker get() = System.getenv(GLIBC_LINKER_VAR).nullize(true) ?: let { if (CpuArch.isArm64()) { @@ -51,4 +187,6 @@ object NodeExePatcher { private val glibc get() = System.getenv(GLIBC_PATH_VAR).nullize(true) ?: INTERNAL_GLIBC_PATH + + private val LOG = getLogger() } diff --git a/plugins/amazonq/shared/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/amazonq/lsp/NodeExePatcherTest.kt b/plugins/amazonq/shared/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/amazonq/lsp/NodeExePatcherTest.kt index 9a0be9a6e67..9ee22f23d52 100644 --- a/plugins/amazonq/shared/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/amazonq/lsp/NodeExePatcherTest.kt +++ b/plugins/amazonq/shared/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/amazonq/lsp/NodeExePatcherTest.kt @@ -4,15 +4,24 @@ package software.aws.toolkits.jetbrains.services.amazonq.lsp import com.intellij.execution.configurations.GeneralCommandLine +import com.intellij.execution.process.ProcessOutput +import com.intellij.execution.util.ExecUtil +import com.intellij.testFramework.ApplicationRule import com.intellij.testFramework.rules.TempDirectory import com.intellij.testFramework.utils.io.createFile import com.intellij.util.system.CpuArch +import io.mockk.every +import io.mockk.junit4.MockKRule +import io.mockk.mockk +import io.mockk.mockkStatic +import io.mockk.slot import org.assertj.core.api.Assertions.assertThat import org.junit.Assume.assumeTrue import org.junit.Rule import org.junit.Test import software.aws.toolkits.core.rules.EnvironmentVariableHelper import software.aws.toolkits.core.utils.exists +import software.aws.toolkits.jetbrains.services.cwc.controller.chat.telemetry.getStartUrl import java.nio.file.Paths class NodeExePatcherTest { @@ -22,6 +31,12 @@ class NodeExePatcherTest { @get:Rule val tempDir = TempDirectory() + @get:Rule + val appRule = ApplicationRule() + + @get:Rule + val mockKRule = MockKRule(this) + private val pathToNode = Paths.get("/path/to/node").toAbsolutePath().toString() @Test @@ -56,4 +71,55 @@ class NodeExePatcherTest { .usingComparator(Comparator.comparing { it.commandLineString }) .isEqualTo(GeneralCommandLine(pathToNode)) } + + @Test + fun `getNodeRuntimePath prefers patched runtime`() { + val path = tempDir.newDirectory("vsc-sysroot").toPath().toAbsolutePath() + val linker = Paths.get(path.toString(), "someSharedLibrary").createFile() + val fakeNode = tempDir.newFile("fake-node").toPath().toAbsolutePath() + + envVarHelper[NodeExePatcher.GLIBC_LINKER_VAR] = linker.toString() + envVarHelper[NodeExePatcher.GLIBC_PATH_VAR] = path.toString() + + val cmdlineSlot = slot() + mockkStatic(ExecUtil::class) + mockkStatic(::getStartUrl) + every { getStartUrl(any()) } returns "https://start.url" + every { ExecUtil.execAndGetOutput(capture(cmdlineSlot), any()) } returns ProcessOutput("v99.0.0", "", 0, false, false) + + assertThat(NodeExePatcher.getNodeRuntimePath(mockk(), fakeNode)) + .isEqualTo(fakeNode) + + assertThat(cmdlineSlot.captured) + .usingComparator(Comparator.comparing { it.commandLineString }) + .isEqualTo(GeneralCommandLine(linker.toString(), "--library-path", path.toString(), fakeNode.toString(), "--version")) + } + + @Test + fun `getNodeRuntimePath can run without patching`() { + val fakeNode = tempDir.newFile("fake-node").toPath().toAbsolutePath() + val cmdlineSlot = slot() + mockkStatic(ExecUtil::class) + mockkStatic(::getStartUrl) + every { getStartUrl(any()) } returns "https://start.url" + every { ExecUtil.execAndGetOutput(capture(cmdlineSlot), any()) } returns ProcessOutput("v99.0.0", "", 0, false, false) + + assertThat(NodeExePatcher.getNodeRuntimePath(mockk(), fakeNode)) + .isEqualTo(fakeNode) + + assertThat(cmdlineSlot.captured) + .usingComparator(Comparator.comparing { it.commandLineString }) + .isEqualTo(GeneralCommandLine(fakeNode.toString(), "--version")) + } + + @Test + fun `getNodeRuntimePath searches environment if artifact not available`() { + mockkStatic(ExecUtil::class) + mockkStatic(::getStartUrl) + every { getStartUrl(any()) } returns "https://start.url" + every { ExecUtil.execAndGetOutput(any(), any()) } returns ProcessOutput("v99.0.0", "", 0, false, false) + + assertThat(NodeExePatcher.getNodeRuntimePath(mockk(), Paths.get(pathToNode))) + .isNotEqualTo(Paths.get(pathToNode)) + } }