Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
Original file line number Diff line number Diff line change
Expand Up @@ -77,10 +77,12 @@ import software.aws.toolkits.jetbrains.services.amazonq.lsp.textdocument.TextDoc
import software.aws.toolkits.jetbrains.services.amazonq.lsp.util.WorkspaceFolderUtil.createWorkspaceFolders
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.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
Expand Down Expand Up @@ -526,20 +528,35 @@ private class AmazonQServerInstance(private val project: Project, private val cs
* 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.metadata("languageServerSetupStage", "resolveNode")
it.metadata("credentialStartUrl", getStartUrl(project))
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

getStartUrl may be null if user has not login but I think it's fine

it.setAttribute("isBundledNode", isBundled)
it.success(success)
}
}

if (Files.exists(nodePath) && Files.isExecutable(nodePath)) {
resolveNodeMetric(true, true)
return nodePath
}

// use alternative node runtime if it is not found
LOG.warn { "Node Runtime download failed. Fallback to user specified node runtime " }
// 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)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: is it possible to add parameter names?

return Path.of(nodeRuntime)
} else {
val localNode = locateNodeCommand()
if (localNode != null) {
LOG.info { "Using node from ${localNode.toAbsolutePath()}" }

resolveNodeMetric(false, true)
return localNode
}
notifyInfo(
Expand All @@ -557,6 +574,8 @@ private class AmazonQServerInstance(private val project: Project, private val cs
) { _, notification -> notification.expire() }
)
)

resolveNodeMetric(false, false)
return nodePath
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,10 @@ 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.core.saveFileFromUrl
import software.aws.toolkits.jetbrains.services.cwc.controller.chat.telemetry.getStartUrl
import software.aws.toolkits.resources.AwsCoreBundle
import software.aws.toolkits.telemetry.LanguageServerSetupStage
import software.aws.toolkits.telemetry.Telemetry
import java.nio.file.Files
import java.nio.file.Path
import java.nio.file.Paths
Expand Down Expand Up @@ -106,33 +109,33 @@ class ArtifactHelper(private val lspArtifactsPath: Path = DEFAULT_ARTIFACT_PATH,
}

suspend fun tryDownloadLspArtifacts(project: Project, targetVersion: Version, target: VersionTarget): Path? {
val temporaryDownloadPath = Files.createTempDirectory("lsp-dl")
val downloadPath = lspArtifactsPath.resolve(targetVersion.serverVersion.toString())
val destinationPath = lspArtifactsPath.resolve(targetVersion.serverVersion.toString())

while (currentAttempt.get() < maxDownloadAttempts) {
currentAttempt.incrementAndGet()
logger.info { "Attempt ${currentAttempt.get()} of $maxDownloadAttempts to download LSP artifacts" }
val temporaryDownloadPath = Files.createTempDirectory("lsp-dl")

try {
return withBackgroundProgress(
project,
AwsCoreBundle.message("amazonqFeatureDev.placeholder.downloading_and_extracting_lsp_artifacts"),
cancellable = true
) {
if (downloadLspArtifacts(temporaryDownloadPath, target) && !target.contents.isNullOrEmpty()) {
moveFilesFromSourceToDestination(temporaryDownloadPath, downloadPath)
if (downloadLspArtifacts(project, temporaryDownloadPath, target) && !target.contents.isNullOrEmpty()) {
moveFilesFromSourceToDestination(temporaryDownloadPath, destinationPath)
target.contents
.mapNotNull { it.filename }
.forEach { filename -> extractZipFile(downloadPath.resolve(filename), downloadPath) }
logger.info { "Successfully downloaded and moved LSP artifacts to $downloadPath" }
.forEach { filename -> extractZipFile(destinationPath.resolve(filename), destinationPath) }
logger.info { "Successfully downloaded and moved LSP artifacts to $destinationPath" }

val thirdPartyLicenses = targetVersion.thirdPartyLicenses
logger.info {
"Installing Amazon Q Language Server v${targetVersion.serverVersion} to: $downloadPath. " +
"Installing Amazon Q Language Server v${targetVersion.serverVersion} to: $destinationPath. " +
if (thirdPartyLicenses == null) "" else "Attribution notice can be found at $thirdPartyLicenses"
}

return@withBackgroundProgress downloadPath
return@withBackgroundProgress destinationPath
}

return@withBackgroundProgress null
Expand All @@ -146,15 +149,15 @@ class ArtifactHelper(private val lspArtifactsPath: Path = DEFAULT_ARTIFACT_PATH,
else -> { logger.error(e) { "Failed to download/move LSP artifacts on attempt ${currentAttempt.get()}" } }
}
temporaryDownloadPath.toFile().deleteRecursively()
downloadPath.toFile().deleteRecursively()
destinationPath.toFile().deleteRecursively()
}
}
logger.error { "Failed to download LSP artifacts after $maxDownloadAttempts attempts" }
return null
}

@VisibleForTesting
internal fun downloadLspArtifacts(downloadPath: Path, target: VersionTarget?): Boolean {
internal fun downloadLspArtifacts(project: Project, downloadPath: Path, target: VersionTarget?): Boolean {
if (target == null || target.contents.isNullOrEmpty()) {
logger.warn { "No target contents available for download" }
return false
Expand All @@ -171,7 +174,7 @@ class ArtifactHelper(private val lspArtifactsPath: Path = DEFAULT_ARTIFACT_PATH,
logger.warn { "No hash available for ${content.filename}" }
return@forEach
}
downloadAndValidateFile(content.url, filePath, contentHash)
downloadAndValidateFile(project, content.url, filePath, contentHash)
}
validateDownloadedFiles(downloadPath, target.contents)
} catch (e: Exception) {
Expand All @@ -182,18 +185,44 @@ class ArtifactHelper(private val lspArtifactsPath: Path = DEFAULT_ARTIFACT_PATH,
return true
}

private fun downloadAndValidateFile(url: String, filePath: Path, expectedHash: String) {
private fun downloadAndValidateFile(project: Project, url: String, filePath: Path, expectedHash: String) {
val recordDownload = { runnable: () -> Unit ->
Telemetry.languageserver.setup.use { telemetry ->
telemetry.languageServerSetupStage(LanguageServerSetupStage.GetServer)
telemetry.metadata("credentialStartUrl", getStartUrl(project))
telemetry.success(true)

try {
runnable()
} catch (t: Throwable) {
telemetry.success(false)
telemetry.recordException(t)
}
}
}

try {
if (!filePath.exists()) {
logger.info { "Downloading file: ${filePath.fileName}" }
saveFileFromUrl(url, filePath, ProgressManager.getInstance().progressIndicator)
recordDownload { saveFileFromUrl(url, filePath, ProgressManager.getInstance().progressIndicator) }
}
if (!validateFileHash(filePath, expectedHash)) {
logger.warn { "Hash mismatch for ${filePath.fileName}, re-downloading" }
filePath.deleteIfExists()
Comment on lines 206 to 212
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

not related to this PR but it seems like we can download twice if the first downloaded one has hash mismatch due to antivirus changing content, but still acceptable

saveFileFromUrl(url, filePath)
if (!validateFileHash(filePath, expectedHash)) {
throw LspException("Hash mismatch after re-download for ${filePath.fileName}", LspException.ErrorCode.HASH_MISMATCH)
recordDownload { saveFileFromUrl(url, filePath) }

Telemetry.languageserver.setup.use {
it.languageServerSetupStage(LanguageServerSetupStage.Validate)
it.metadata("credentialStartUrl", getStartUrl(project))
it.success(true)

if (!validateFileHash(filePath, expectedHash)) {
it.success(false)

val exception = LspException("Hash mismatch after re-download for ${filePath.fileName}", LspException.ErrorCode.HASH_MISMATCH)
Copy link
Contributor

@manodnyab manodnyab Jun 4, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we run this through the util that removes username(scrubNames)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it's path.fileName so should not have username because this is just the server version

it.recordException(exception)
throw exception
}
}
}
} catch (e: Exception) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,10 @@ import software.aws.toolkits.core.utils.info
import software.aws.toolkits.core.utils.warn
import software.aws.toolkits.jetbrains.AwsPlugin
import software.aws.toolkits.jetbrains.AwsToolkit
import software.aws.toolkits.jetbrains.services.cwc.controller.chat.telemetry.getStartUrl
import software.aws.toolkits.telemetry.LanguageServerSetupStage
import software.aws.toolkits.telemetry.MetricResult
import software.aws.toolkits.telemetry.Telemetry
import java.nio.file.Path

@Service
Expand Down Expand Up @@ -57,42 +61,72 @@ class ArtifactManager @NonInjectable internal constructor(private val manifestFe
return mutex.withLock {
coroutineScope {
async {
try {
val manifest = manifestFetcher.fetch() ?: throw LspException(
"Language Support is not available, as manifest is missing.",
LspException.ErrorCode.MANIFEST_FETCH_FAILED
)
val lspVersions = getLSPVersionsFromManifestWithSpecifiedRange(manifest)

artifactHelper.removeDelistedVersions(lspVersions.deListedVersions)

if (lspVersions.inRangeVersions.isEmpty()) {
// No versions are found which are in the given range. Fallback to local lsp artifacts.
val localLspArtifacts = artifactHelper.getAllLocalLspArtifactsWithinManifestRange(DEFAULT_VERSION_RANGE)
if (localLspArtifacts.isNotEmpty()) {
return@async localLspArtifacts.first().first
Telemetry.languageserver.setup.use { all ->
all.languageServerSetupStage(LanguageServerSetupStage.All)
all.metadata("credentialStartUrl", getStartUrl(project))
all.result(MetricResult.Succeeded)

try {
val lspVersions = Telemetry.languageserver.setup.use { telemetry ->
telemetry.languageServerSetupStage(LanguageServerSetupStage.GetManifest)
telemetry.metadata("credentialStartUrl", getStartUrl(project))

val exception = LspException(
"Language Support is not available, as manifest is missing.",
LspException.ErrorCode.MANIFEST_FETCH_FAILED
)
telemetry.recordException(exception)
val manifest = manifestFetcher.fetch() ?: throw exception

getLSPVersionsFromManifestWithSpecifiedRange(manifest)
}
throw LspException("Language server versions not found in manifest.", LspException.ErrorCode.NO_COMPATIBLE_LSP_VERSION)
}

val targetVersion = lspVersions.inRangeVersions.first()
artifactHelper.removeDelistedVersions(lspVersions.deListedVersions)

if (lspVersions.inRangeVersions.isEmpty()) {
// No versions are found which are in the given range. Fallback to local lsp artifacts.
val localLspArtifacts = artifactHelper.getAllLocalLspArtifactsWithinManifestRange(DEFAULT_VERSION_RANGE)
if (localLspArtifacts.isNotEmpty()) {
return@async localLspArtifacts.first().first
}
throw LspException("Language server versions not found in manifest.", LspException.ErrorCode.NO_COMPATIBLE_LSP_VERSION)
}

// If there is an LSP Manifest with the same version
val target = getTargetFromLspManifest(targetVersion)
// Get Local LSP files and check if we can re-use existing LSP Artifacts
val artifactPath: Path = if (artifactHelper.getExistingLspArtifacts(targetVersion, target)) {
artifactHelper.getAllLocalLspArtifactsWithinManifestRange(DEFAULT_VERSION_RANGE).first().first
} else {
artifactHelper.tryDownloadLspArtifacts(project, targetVersion, target)
?: throw LspException("Failed to download LSP artifacts", LspException.ErrorCode.DOWNLOAD_FAILED)
val targetVersion = lspVersions.inRangeVersions.first()

// If there is an LSP Manifest with the same version
val target = getTargetFromLspManifest(targetVersion)
// Get Local LSP files and check if we can re-use existing LSP Artifacts
val artifactPath: Path = if (artifactHelper.getExistingLspArtifacts(targetVersion, target)) {
artifactHelper.getAllLocalLspArtifactsWithinManifestRange(DEFAULT_VERSION_RANGE).first().first
} else {
artifactHelper.tryDownloadLspArtifacts(project, targetVersion, target)
?: throw LspException("Failed to download LSP artifacts", LspException.ErrorCode.DOWNLOAD_FAILED)
}

artifactHelper.deleteOlderLspArtifacts(DEFAULT_VERSION_RANGE)

Telemetry.languageserver.setup.use {
it.languageServerSetupStage(LanguageServerSetupStage.Launch)
it.metadata("credentialStartUrl", getStartUrl(project))
it.setAttribute("isBundledArtifact", false)
}
return@async artifactPath
} catch (e: Exception) {
logger.warn(e) { "Failed to resolve assets from Flare CDN" }
val path = AwsToolkit.PLUGINS_INFO[AwsPlugin.Q]?.path?.resolve("flare") ?: error("not even bundled")
logger.info { "Falling back to bundled assets at $path" }

all.recordException(e)
all.result(MetricResult.Failed)

Telemetry.languageserver.setup.use {
it.languageServerSetupStage(LanguageServerSetupStage.Launch)
it.metadata("credentialStartUrl", getStartUrl(project))
it.setAttribute("isBundledArtifact", true)
}
return@async path
}
artifactHelper.deleteOlderLspArtifacts(DEFAULT_VERSION_RANGE)
return@async artifactPath
} catch (e: Exception) {
logger.warn(e) { "Failed to resolve assets from Flare CDN" }
val path = AwsToolkit.PLUGINS_INFO[AwsPlugin.Q]?.path?.resolve("flare") ?: error("not even bundled")
logger.info { "Falling back to bundled assets at $path" }
return@async path
}
}
}.also {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -199,7 +199,7 @@ class ArtifactHelperTest {
val version = Version(serverVersion = "1.0.0")

val spyArtifactHelper = spyk(artifactHelper)
every { spyArtifactHelper.downloadLspArtifacts(any(), any()) } returns false
every { spyArtifactHelper.downloadLspArtifacts(mockProject, any(), any()) } returns false

assertThat(runBlocking { artifactHelper.tryDownloadLspArtifacts(mockProject, version, VersionTarget(contents = contents)) }).isEqualTo(null)
}
Expand All @@ -210,7 +210,7 @@ class ArtifactHelperTest {
val target = VersionTarget(contents = contents)
val spyArtifactHelper = spyk(artifactHelper)

every { spyArtifactHelper.downloadLspArtifacts(any(), any()) } returns true
every { spyArtifactHelper.downloadLspArtifacts(mockProject, any(), any()) } returns true
mockkStatic("software.aws.toolkits.jetbrains.services.amazonq.lsp.artifacts.LspUtilsKt")
every { moveFilesFromSourceToDestination(any(), any()) } just Runs
every { extractZipFile(any(), any()) } just Runs
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,9 @@ abstract class AbstractBaseSpan<SpanType : AbstractBaseSpan<SpanType>>(internal

override fun recordException(exception: Throwable): SpanType {
delegate.recordException(exception)

setAttribute("reason", exception::class.java.canonicalName)
setAttribute("reasonDesc", exception.message)
return this as SpanType
}

Expand Down
Loading