diff --git a/.fleet/settings.json b/.fleet/settings.json new file mode 100644 index 00000000..b635f9f4 --- /dev/null +++ b/.fleet/settings.json @@ -0,0 +1,3 @@ +{ + "backend.maxHeapSizeMb": 3072 +} \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 3855bb39..65861baf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ ### Added - Stop Resource action to the dashboard +- Hot reload while running ## [0.7.0] - 2024-05-08 diff --git a/src/main/kotlin/me/rafaelldi/aspire/run/AspireHostConfig.kt b/src/main/kotlin/me/rafaelldi/aspire/run/AspireHostConfig.kt index cd7197b1..6fe4e1fb 100644 --- a/src/main/kotlin/me/rafaelldi/aspire/run/AspireHostConfig.kt +++ b/src/main/kotlin/me/rafaelldi/aspire/run/AspireHostConfig.kt @@ -12,6 +12,6 @@ data class AspireHostConfig( val resourceServiceEndpointUrl: String?, val resourceServiceApiKey: String?, val openTelemetryProtocolEndpointUrl: String?, - val openTelemetryProtocolServerPort: Int, + val openTelemetryProtocolServerPort: Int?, val aspireHostLifetime: Lifetime ) \ No newline at end of file diff --git a/src/main/kotlin/me/rafaelldi/aspire/run/AspireHostProgramRunner.kt b/src/main/kotlin/me/rafaelldi/aspire/run/AspireHostProgramRunner.kt index cbce0d20..3ef089a5 100644 --- a/src/main/kotlin/me/rafaelldi/aspire/run/AspireHostProgramRunner.kt +++ b/src/main/kotlin/me/rafaelldi/aspire/run/AspireHostProgramRunner.kt @@ -27,6 +27,7 @@ import kotlinx.coroutines.withContext import me.rafaelldi.aspire.generated.aspireSessionHostModel import me.rafaelldi.aspire.services.AspireServiceManager import me.rafaelldi.aspire.sessionHost.SessionHostManager +import me.rafaelldi.aspire.settings.AspireSettings import me.rafaelldi.aspire.util.DEBUG_SESSION_PORT import me.rafaelldi.aspire.util.DEBUG_SESSION_TOKEN import me.rafaelldi.aspire.util.DOTNET_DASHBOARD_OTLP_ENDPOINT_URL @@ -65,7 +66,9 @@ class AspireHostProgramRunner : DotNetProgramRunner() { val resourceServiceApiKey = environmentVariables[DOTNET_DASHBOARD_RESOURCESERVICE_APIKEY] val openTelemetryProtocolEndpointUrl = environmentVariables[DOTNET_DASHBOARD_OTLP_ENDPOINT_URL] - val openTelemetryProtocolServerPort = NetUtils.findFreePort(57100) + val openTelemetryProtocolServerPort = + if (AspireSettings.getInstance().collectTelemetry) NetUtils.findFreePort(57100) + else null val debuggingMode = environment.executor.id == DefaultDebugExecutor.EXECUTOR_ID diff --git a/src/main/kotlin/me/rafaelldi/aspire/sessionHost/SessionExecutableFactory.kt b/src/main/kotlin/me/rafaelldi/aspire/sessionHost/SessionExecutableFactory.kt index 34fb4385..eeaad843 100644 --- a/src/main/kotlin/me/rafaelldi/aspire/sessionHost/SessionExecutableFactory.kt +++ b/src/main/kotlin/me/rafaelldi/aspire/sessionHost/SessionExecutableFactory.kt @@ -8,11 +8,9 @@ import com.intellij.util.io.systemIndependentPath import com.jetbrains.rider.model.RunnableProject import com.jetbrains.rider.model.runnableProjectsModel import com.jetbrains.rider.projectView.solution -import com.jetbrains.rider.run.configurations.RunnableProjectKinds import com.jetbrains.rider.runtime.DotNetExecutable import com.jetbrains.rider.runtime.dotNetCore.DotNetCoreRuntimeType import me.rafaelldi.aspire.generated.SessionModel -import me.rafaelldi.aspire.settings.AspireSettings import me.rafaelldi.aspire.util.MSBuildPropertyService import java.nio.file.Path import kotlin.io.path.Path @@ -21,30 +19,22 @@ import kotlin.io.path.Path class SessionExecutableFactory(private val project: Project) { companion object { fun getInstance(project: Project) = project.service() - - private const val OTEL_EXPORTER_OTLP_ENDPOINT = "OTEL_EXPORTER_OTLP_ENDPOINT" - private fun getOtlpEndpoint(port: Int) = "http://localhost:$port" } - suspend fun createExecutable(sessionModel: SessionModel, openTelemetryPort: Int): DotNetExecutable? { - val runnableProjects = project.solution.runnableProjectsModel.projects.valueOrNull + suspend fun createExecutable(sessionModel: SessionModel): DotNetExecutable? { val sessionProjectPath = Path(sessionModel.projectPath) - val sessionProjectPathString = sessionProjectPath.systemIndependentPath - val runnableProject = runnableProjects?.singleOrNull { - it.projectFilePath == sessionProjectPathString && it.kind == RunnableProjectKinds.DotNetCore - } + val runnableProject = project.solution.runnableProjectsModel.findBySessionProject(sessionProjectPath) return if (runnableProject != null) { - getExecutableForRunnableProject(runnableProject, sessionModel, openTelemetryPort) + getExecutableForRunnableProject(runnableProject, sessionModel) } else { - getExecutableForExternalProject(sessionProjectPath, sessionModel, openTelemetryPort) + getExecutableForExternalProject(sessionProjectPath, sessionModel) } } private fun getExecutableForRunnableProject( runnableProject: RunnableProject, - sessionModel: SessionModel, - openTelemetryPort: Int + sessionModel: SessionModel ): DotNetExecutable? { val output = runnableProject.projectOutputs.firstOrNull() ?: return null val executablePath = output.exePath @@ -52,10 +42,7 @@ class SessionExecutableFactory(private val project: Project) { if (sessionModel.args?.isNotEmpty() == true) sessionModel.args.toList() else output.defaultArguments val params = ParametersListUtil.join(arguments) - val envs = sessionModel.envs?.associate { it.key to it.value }?.toMutableMap() ?: mutableMapOf() - if (AspireSettings.getInstance().collectTelemetry) { - envs[OTEL_EXPORTER_OTLP_ENDPOINT] = getOtlpEndpoint(openTelemetryPort) - } + val envs = sessionModel.envs?.associate { it.key to it.value } ?: mapOf() return DotNetExecutable( executablePath, @@ -76,8 +63,7 @@ class SessionExecutableFactory(private val project: Project) { private suspend fun getExecutableForExternalProject( sessionProjectPath: Path, - sessionModel: SessionModel, - openTelemetryPort: Int + sessionModel: SessionModel ): DotNetExecutable? { val propertyService = MSBuildPropertyService.getInstance(project) val properties = propertyService.getProjectRunProperties(sessionProjectPath) ?: return null @@ -86,10 +72,7 @@ class SessionExecutableFactory(private val project: Project) { if (sessionModel.args?.isNotEmpty() == true) sessionModel.args.toList() else properties.arguments val params = ParametersListUtil.join(arguments) - val envs = sessionModel.envs?.associate { it.key to it.value }?.toMutableMap() ?: mutableMapOf() - if (AspireSettings.getInstance().collectTelemetry) { - envs[OTEL_EXPORTER_OTLP_ENDPOINT] = getOtlpEndpoint(openTelemetryPort) - } + val envs = sessionModel.envs?.associate { it.key to it.value } ?: mapOf() return DotNetExecutable( executablePath, diff --git a/src/main/kotlin/me/rafaelldi/aspire/sessionHost/SessionLauncher.kt b/src/main/kotlin/me/rafaelldi/aspire/sessionHost/SessionLauncher.kt index 5b537b48..15ee57a1 100644 --- a/src/main/kotlin/me/rafaelldi/aspire/sessionHost/SessionLauncher.kt +++ b/src/main/kotlin/me/rafaelldi/aspire/sessionHost/SessionLauncher.kt @@ -14,18 +14,26 @@ import com.jetbrains.rd.framework.* import com.jetbrains.rd.util.lifetime.Lifetime import com.jetbrains.rd.util.lifetime.isNotAlive import com.jetbrains.rd.util.put +import com.jetbrains.rd.util.reactive.hasTrueValue import com.jetbrains.rd.util.threading.coroutines.nextTrueValueAsync import com.jetbrains.rdclient.protocol.RdDispatcher +import com.jetbrains.rider.RiderEnvironment import com.jetbrains.rider.RiderEnvironment.createRunCmdForLauncherInfo import com.jetbrains.rider.debugger.DebuggerWorkerProcessHandler import com.jetbrains.rider.debugger.RiderDebuggerWorkerModelManager import com.jetbrains.rider.debugger.createAndStartSession +import com.jetbrains.rider.debugger.editAndContinue.DotNetRunHotReloadProcess +import com.jetbrains.rider.debugger.editAndContinue.hotReloadManager import com.jetbrains.rider.debugger.targets.DEBUGGER_WORKER_LAUNCHER +import com.jetbrains.rider.hotReload.HotReloadHost +import com.jetbrains.rider.model.HotReloadSupportedInfo import com.jetbrains.rider.model.debuggerWorker.* import com.jetbrains.rider.model.debuggerWorkerConnectionHelperModel +import com.jetbrains.rider.model.runnableProjectsModel import com.jetbrains.rider.projectView.solution import com.jetbrains.rider.run.* import com.jetbrains.rider.run.configurations.RunnableProjectKinds +import com.jetbrains.rider.run.configurations.launchSettings.LaunchSettingsJsonService import com.jetbrains.rider.runtime.DotNetExecutable import com.jetbrains.rider.runtime.DotNetRuntime import com.jetbrains.rider.runtime.RiderDotNetActiveRuntimeHost @@ -38,6 +46,8 @@ import kotlinx.coroutines.withContext import me.rafaelldi.aspire.generated.SessionModel import me.rafaelldi.aspire.util.decodeAnsiCommandsToString import org.jetbrains.annotations.Nls +import java.nio.file.Path +import java.util.* import kotlin.io.path.Path import kotlin.io.path.nameWithoutExtension @@ -47,6 +57,13 @@ class SessionLauncher(private val project: Project) { fun getInstance(project: Project) = project.service() private val LOG = logger() + + private const val DOTNET_MODIFIABLE_ASSEMBLIES = "DOTNET_MODIFIABLE_ASSEMBLIES" + private const val DOTNET_HOTRELOAD_NAMEDPIPE_NAME = "DOTNET_HOTRELOAD_NAMEDPIPE_NAME" + private const val DOTNET_STARTUP_HOOKS = "DOTNET_STARTUP_HOOKS" + private const val OTEL_EXPORTER_OTLP_ENDPOINT = "OTEL_EXPORTER_OTLP_ENDPOINT" + + private fun getOtlpEndpoint(port: Int) = "http://localhost:$port" } suspend fun launchSession( @@ -55,7 +72,7 @@ class SessionLauncher(private val project: Project) { sessionLifetime: Lifetime, sessionEvents: MutableSharedFlow, debuggingMode: Boolean, - openTelemetryPort: Int + openTelemetryPort: Int? ) { LOG.info("Starting a session for the project ${sessionModel.projectPath}") @@ -65,7 +82,7 @@ class SessionLauncher(private val project: Project) { } val factory = SessionExecutableFactory.getInstance(project) - val executable = factory.createExecutable(sessionModel, openTelemetryPort) + val executable = factory.createExecutable(sessionModel) if (executable == null) { LOG.warn("Unable to create executable for $sessionId (project: ${sessionModel.projectPath})") return @@ -84,38 +101,52 @@ class SessionLauncher(private val project: Project) { return } + val sessionProjectPath = Path(sessionModel.projectPath) if (debuggingMode || sessionModel.debug) { launchDebugSession( sessionId, - Path(sessionModel.projectPath).nameWithoutExtension, + sessionProjectPath.nameWithoutExtension, executable, runtime, + openTelemetryPort, sessionLifetime, sessionEvents ) } else { launchRunSession( sessionId, + sessionProjectPath, executable, runtime, + sessionModel.launchProfile, + openTelemetryPort, sessionLifetime, sessionEvents ) } } - private fun launchRunSession( + private suspend fun launchRunSession( sessionId: String, + sessionProjectPath: Path, executable: DotNetExecutable, runtime: DotNetCoreRuntime, + launchProfile: String?, + openTelemetryPort: Int?, sessionLifetime: Lifetime, sessionEvents: MutableSharedFlow ) { LOG.trace("Starting the session in the run mode") - val commandLine = executable.createRunCommandLine(runtime) + + val executableToRun = + modifyExecutableToRun(executable, sessionProjectPath, launchProfile, openTelemetryPort, sessionLifetime) + + val commandLine = executableToRun.createRunCommandLine(runtime) val handler = KillableProcessHandler(commandLine) subscribeToSessionEvents(sessionId, handler, sessionEvents) + addHotReloadListener(handler, sessionLifetime, commandLine.environment) + sessionLifetime.onTermination { if (!handler.isProcessTerminating && !handler.isProcessTerminated) { LOG.trace("Killing session process (id: $sessionId)") @@ -126,25 +157,100 @@ class SessionLauncher(private val project: Project) { handler.startNotify() } + private suspend fun modifyExecutableToRun( + executable: DotNetExecutable, + sessionProjectPath: Path, + launchProfile: String?, + openTelemetryPort: Int?, + lifetime: Lifetime + ): DotNetExecutable { + val envs = executable.environmentVariables.toMutableMap() + + val isHotReloadAvailable = isHotReloadAvailable(executable, sessionProjectPath, launchProfile, lifetime) + if (isHotReloadAvailable) { + val pipeName = UUID.randomUUID().toString() + envs[DOTNET_MODIFIABLE_ASSEMBLIES] = "debug" + envs[DOTNET_HOTRELOAD_NAMEDPIPE_NAME] = pipeName + val deltaApplierPath = + RiderEnvironment.getBundledFile("JetBrains.Microsoft.Extensions.DotNetDeltaApplier.dll").absolutePath + envs[DOTNET_STARTUP_HOOKS] = deltaApplierPath + } + + val isOpenTelemetryAvailable = openTelemetryPort != null + if (isOpenTelemetryAvailable) { + envs[OTEL_EXPORTER_OTLP_ENDPOINT] = getOtlpEndpoint(requireNotNull(openTelemetryPort)) + } + + return if (isHotReloadAvailable || isOpenTelemetryAvailable) { + executable.copy(environmentVariables = envs) + } else { + executable + } + } + + private suspend fun isHotReloadAvailable( + executable: DotNetExecutable, + sessionProjectPath: Path, + launchProfile: String?, + lifetime: Lifetime + ): Boolean { + if (executable.projectTfm == null) return false + + val hotReloadHost = HotReloadHost.getInstance(project) + if (!hotReloadHost.runtimeHotReloadEnabled.hasTrueValue) return false + + val runnableProject = + project.solution.runnableProjectsModel.findBySessionProject(sessionProjectPath) ?: return false + + if (launchProfile != null) { + val launchSettings = LaunchSettingsJsonService.loadLaunchSettings(runnableProject) + if (launchSettings != null) { + val profile = launchSettings.profiles?.get(launchProfile) + if (profile?.hotReloadEnabled == false) return false + } + } + + val info = HotReloadSupportedInfo(runnableProject.name, requireNotNull(executable.projectTfm).presentableName) + return withContext(Dispatchers.EDT) { + hotReloadHost.checkProjectConfigRuntimeHotReload(lifetime, info) + } + } + + private fun addHotReloadListener(handler: KillableProcessHandler, lifetime: Lifetime, envs: Map) { + val pipeName = envs[DOTNET_HOTRELOAD_NAMEDPIPE_NAME] + if (pipeName.isNullOrEmpty()) return + + handler.addProcessListener(object : ProcessAdapter() { + override fun startNotified(event: ProcessEvent) { + val runSession = DotNetRunHotReloadProcess(lifetime, pipeName) + project.hotReloadManager.addProcess(runSession) + } + }) + } + private suspend fun launchDebugSession( sessionId: String, @Nls sessionName: String, executable: DotNetExecutable, runtime: DotNetCoreRuntime, + openTelemetryPort: Int?, sessionLifetime: Lifetime, sessionEvents: MutableSharedFlow ) { LOG.trace("Starting the session in the debug mode") + + val executableToDebug = modifyExecutableToDebug(executable, openTelemetryPort) + val startInfo = DotNetCoreExeStartInfo( DotNetCoreInfo(runtime.cliExePath), - executable.projectTfm?.let { EncInfo(it) }, - executable.exePath, - executable.workingDirectory, - executable.programParameterString, - executable.environmentVariables.toModelMap, - executable.runtimeArguments, - executable.executeAsIs, - executable.useExternalConsole + executableToDebug.projectTfm?.let { EncInfo(it) }, + executableToDebug.exePath, + executableToDebug.workingDirectory, + executableToDebug.programParameterString, + executableToDebug.environmentVariables.toModelMap, + executableToDebug.runtimeArguments, + executableToDebug.executeAsIs, + executableToDebug.useExternalConsole ) withContext(Dispatchers.EDT) { @@ -158,6 +264,24 @@ class SessionLauncher(private val project: Project) { } } + private suspend fun modifyExecutableToDebug( + executable: DotNetExecutable, + openTelemetryPort: Int? + ): DotNetExecutable { + val envs = executable.environmentVariables.toMutableMap() + + val isOpenTelemetryAvailable = openTelemetryPort != null + if (isOpenTelemetryAvailable) { + envs[OTEL_EXPORTER_OTLP_ENDPOINT] = getOtlpEndpoint(requireNotNull(openTelemetryPort)) + } + + return if (isOpenTelemetryAvailable) { + executable.copy(environmentVariables = envs) + } else { + executable + } + } + private suspend fun createAndStartDebugSession( sessionId: String, @Nls sessionName: String, diff --git a/src/main/kotlin/me/rafaelldi/aspire/sessionHost/SessionManager.kt b/src/main/kotlin/me/rafaelldi/aspire/sessionHost/SessionManager.kt index d5471ba5..a9bd35c2 100644 --- a/src/main/kotlin/me/rafaelldi/aspire/sessionHost/SessionManager.kt +++ b/src/main/kotlin/me/rafaelldi/aspire/sessionHost/SessionManager.kt @@ -194,7 +194,7 @@ class SessionManager(private val project: Project, scope: CoroutineScope) { val model: SessionModel, val lifetimes: SequentialLifetimes, val events: MutableSharedFlow, - val openTelemetryProtocolServerPort: Int + val openTelemetryProtocolServerPort: Int? ) interface LaunchSessionCommand diff --git a/src/main/kotlin/me/rafaelldi/aspire/sessionHost/SessionUtils.kt b/src/main/kotlin/me/rafaelldi/aspire/sessionHost/SessionUtils.kt new file mode 100644 index 00000000..d28ef04a --- /dev/null +++ b/src/main/kotlin/me/rafaelldi/aspire/sessionHost/SessionUtils.kt @@ -0,0 +1,16 @@ +package me.rafaelldi.aspire.sessionHost + +import com.intellij.util.io.systemIndependentPath +import com.jetbrains.rider.model.RunnableProject +import com.jetbrains.rider.model.RunnableProjectsModel +import com.jetbrains.rider.run.configurations.RunnableProjectKinds +import java.nio.file.Path + + +fun RunnableProjectsModel.findBySessionProject(sessionProjectPath: Path): RunnableProject? { + val runnableProjects = projects.valueOrNull ?: return null + val sessionProjectPathString = sessionProjectPath.systemIndependentPath + return runnableProjects.singleOrNull { + it.projectFilePath == sessionProjectPathString && it.kind == RunnableProjectKinds.DotNetCore + } +} \ No newline at end of file