diff --git a/python/python-process-output/impl/resources/messages/ProcessOutputBundle.properties b/python/python-process-output/impl/resources/messages/ProcessOutputBundle.properties index f9ce3ea33cd3..66ed567d0064 100644 --- a/python/python-process-output/impl/resources/messages/ProcessOutputBundle.properties +++ b/python/python-process-output/impl/resources/messages/ProcessOutputBundle.properties @@ -30,6 +30,10 @@ process.output.output.sections.info.cwd=cwd process.output.output.sections.info.target=target process.output.output.sections.info.env=env +process.output.output.tag.stdout=stdout +process.output.output.tag.stderr=stderr +process.output.output.tag.exit=exit + process.output.output.copySection.tooltip=Copy Section process.output.output.sections.output=Process Output diff --git a/python/python-process-output/impl/src/com/intellij/python/processOutput/impl/ProcessOutputControllerService.kt b/python/python-process-output/impl/src/com/intellij/python/processOutput/impl/ProcessOutputControllerService.kt index 122bc8770108..5d79ec0da8b4 100644 --- a/python/python-process-output/impl/src/com/intellij/python/processOutput/impl/ProcessOutputControllerService.kt +++ b/python/python-process-output/impl/src/com/intellij/python/processOutput/impl/ProcessOutputControllerService.kt @@ -434,6 +434,15 @@ class ProcessOutputControllerService( list .filter { it.traceContext == NON_INTERACTIVE_ROOT_TRACE_CONTEXT } .forEach { process -> + val exitInfo = process.exitInfo.value + + if (exitInfo != null) { + if (exitInfo.exitValue != 0) { + backgroundErrorProcesses.value += process.id + } + return@forEach + } + backgroundObservingCoroutines += launch(CoroutineName(CoroutineNames.EXIT_INFO_COLLECTOR)) { process.exitInfo.collect { @@ -445,6 +454,7 @@ class ProcessOutputControllerService( } } } + } } } @@ -559,9 +569,9 @@ class ProcessOutputControllerService( } internal object Tag { - const val ERROR = "error" - const val OUTPUT = "output" - const val EXIT = "exit" + val ERROR = message("process.output.output.tag.stdout") + val OUTPUT = message("process.output.output.tag.stderr") + val EXIT = message("process.output.output.tag.exit") val maxLength: Int = Tag::class.java.declaredFields diff --git a/python/python-process-output/impl/src/com/intellij/python/processOutput/impl/ui/components/OutputSection.kt b/python/python-process-output/impl/src/com/intellij/python/processOutput/impl/ui/components/OutputSection.kt index fc75a44ea20f..0f7a6a7ddb02 100644 --- a/python/python-process-output/impl/src/com/intellij/python/processOutput/impl/ui/components/OutputSection.kt +++ b/python/python-process-output/impl/src/com/intellij/python/processOutput/impl/ui/components/OutputSection.kt @@ -41,12 +41,16 @@ import com.intellij.python.processOutput.impl.Tag import com.intellij.python.processOutput.impl.formatFull import com.intellij.python.processOutput.impl.ui.Colors import com.intellij.python.processOutput.impl.ui.collectReplayAsState +import com.intellij.python.processOutput.impl.ui.thenIfNotNull import kotlinx.collections.immutable.persistentListOf import org.jetbrains.jewel.foundation.theme.JewelTheme import org.jetbrains.jewel.ui.component.scrollbarContentSafePadding private object Styling { val COPY_SECTION_BUTTON_SPACE_SIZE = 18.dp + val LINE_START_PADDING = 8.dp + val LINE_HORIZONTAL_ALIGNMENT = 10.dp + val LINE_SPACER_HEIGHT = 4.dp } @Composable @@ -167,19 +171,17 @@ internal fun OutputSection(controller: ProcessOutputController) { items = lines, key = { index, _ -> index }, ) { index, line -> - val outputColor = when (line.kind) { - LoggedProcessLine.Kind.OUT -> Color.Unspecified - LoggedProcessLine.Kind.ERR -> Colors.Output.ErrorText - } + // when the kind of the current line does not match the kind of the + // previous line, it means that the current line is the start of a + // new section + val startOfNewSection = + lines.getOrNull(index - 1)?.kind != line.kind OutputLine( displayTags = isDisplayTags, sectionIndicator = - if (lines.getOrNull(index - 1)?.kind != line.kind) { - SectionIndicator( - line.kind.tag, - OutputSectionTestTags.COPY_OUTPUT_TAG_SECTION_BUTTON, - ) { + if (startOfNewSection) { + SectionIndicator(line.kind.tag) { controller.copyOutputTagAtIndexToClipboard( loggedProcess, index, @@ -189,16 +191,13 @@ internal fun OutputSection(controller: ProcessOutputController) { null }, text = line.text, - textStyle = SpanStyle( - color = outputColor, - ), ) } exitInfo?.also { exitInfo -> item(key = "exit") { OutputLine( - displayTags = true, + displayTags = isDisplayTags, sectionIndicator = SectionIndicator( Tag.EXIT, @@ -253,22 +252,31 @@ private fun OutputLine( Row( modifier = Modifier.fillMaxWidth() - .padding(end = scrollbarContentSafePadding()), - horizontalArrangement = Arrangement.spacedBy(10.dp), + .padding( + end = scrollbarContentSafePadding(), + start = Styling.LINE_START_PADDING, + ), + horizontalArrangement = Arrangement.spacedBy(Styling.LINE_HORIZONTAL_ALIGNMENT), ) { - DisableSelection { - val padding = Tag.maxLength + 3 + if (displayTags) { + DisableSelection { + val padding = Tag.maxLength + 1 - Text( - text = - if (displayTags && sectionIndicator != null) { - "${sectionIndicator.tag}:".padStart(padding, ' ') - } else { - " ".repeat(padding) - }, - style = JewelTheme.consoleTextStyle, - fontWeight = FontWeight.Thin, - ) + Text( + text = + if (sectionIndicator != null) { + "${sectionIndicator.tag}:".padStart(padding, ' ') + } else { + " ".repeat(padding) + }, + style = JewelTheme.consoleTextStyle, + fontWeight = FontWeight.Thin, + modifier = + Modifier.thenIfNotNull(sectionIndicator) { + testTag(OutputSectionTestTags.OUTPUT_SECTION_TAG) + }, + ) + } } Text( @@ -286,7 +294,7 @@ private fun OutputLine( ActionIconButton( modifier = Modifier .size(Styling.COPY_SECTION_BUTTON_SPACE_SIZE) - .testTag(sectionIndicator.testTag), + .testTag(sectionIndicator.copyButtonTestTag), iconKey = Icons.Keys.Copy, tooltipText = message("process.output.output.copySection.tooltip"), onClick = sectionIndicator.onCopy, @@ -302,7 +310,7 @@ private fun OutputLine( private data class SectionIndicator( val tag: String, - val testTag: String, + val copyButtonTestTag: String = OutputSectionTestTags.COPY_OUTPUT_TAG_SECTION_BUTTON, val onCopy: () -> Unit, ) @@ -338,7 +346,7 @@ private fun LazyListScope.infoLineItems( else -> 0 } } ?: 0 - val padding = maxLength + 2 + val padding = maxLength + 1 infoLines.forEach { infoLine -> when (infoLine) { @@ -375,8 +383,11 @@ private fun LazyListScope.infoLineItemSingle( Row( modifier = Modifier.fillMaxWidth() - .padding(end = scrollbarContentSafePadding()), - horizontalArrangement = Arrangement.spacedBy(10.dp), + .padding( + end = scrollbarContentSafePadding(), + start = Styling.LINE_START_PADDING, + ), + horizontalArrangement = Arrangement.spacedBy(Styling.LINE_HORIZONTAL_ALIGNMENT), ) { Text( text = @@ -406,7 +417,7 @@ private fun LazyListScope.infoLineItemSingle( @Composable private fun LineSpacer() { - Spacer(modifier = Modifier.height(4.dp)) + Spacer(modifier = Modifier.height(Styling.LINE_SPACER_HEIGHT)) } private sealed class InfoLine { @@ -427,6 +438,7 @@ internal object OutputSectionTestTags { const val NOT_SELECTED_TEXT = "ProcessOutput.Output.NotSelectedText" const val INFO_SECTION = "ProcessOutput.Output.InfoSection" const val OUTPUT_SECTION = "ProcessOutput.Output.OutputSection" + const val OUTPUT_SECTION_TAG = "ProcessOutput.Output.OutputSection.Tag" const val FILTERS_TAGS = "ProcessOutput.Output.FiltersTags" const val FILTERS_BUTTON = "ProcessOutput.Output.FiltersButton" const val FILTERS_MENU = "ProcessOutput.Output.FiltersMenu" diff --git a/python/python-process-output/impl/test/com/intellij/python/junit5Tests/env/ProcessOutputControllerServiceTest.kt b/python/python-process-output/impl/test/com/intellij/python/junit5Tests/env/ProcessOutputControllerServiceTest.kt index 356e21ac7138..d81ce925a476 100644 --- a/python/python-process-output/impl/test/com/intellij/python/junit5Tests/env/ProcessOutputControllerServiceTest.kt +++ b/python/python-process-output/impl/test/com/intellij/python/junit5Tests/env/ProcessOutputControllerServiceTest.kt @@ -196,7 +196,6 @@ class ProcessOutputControllerServiceTest { watcher.cancelAndJoin() } - @OptIn(ExperimentalCoroutinesApi::class) @Test fun `exit info collector coroutines get properly cleaned up`( @TempDir cwd: Path, @@ -213,32 +212,45 @@ class ProcessOutputControllerServiceTest { import sys print("test " + sys.argv[1]) + sys.stdin.read(1) """.trimIndent(), ) } // no exit info collector coroutines should exist - assertEquals( - 0, - DebugProbes.dumpCoroutinesInfo() - .filter { it.context[CoroutineName.Key]?.name == CoroutineNames.EXIT_INFO_COLLECTOR } - .size, - ) + assertEquals(0, exitInfoCollectorCoroutinesCount()) - // spawn 1024 processes - repeat(1024) { - runBin(binOnEel, Args(MAIN_PY, it.toString())) + // spawn 10 processes + val processes = mutableListOf() + repeat(10) { + processes += runBinWithInput(binOnEel, Args(MAIN_PY, it.toString())) } - sleepCancellable(1000) + // 10 collector coroutines should be active + waitUntil { + exitInfoCollectorCoroutinesCount() == 10 + } - // the count of active exit info collector coroutines should match MAX_PROCESSES - assertEquals( - ProcessOutputControllerServiceLimits.MAX_PROCESSES, - DebugProbes.dumpCoroutinesInfo() - .filter { it.context[CoroutineName.Key]?.name == CoroutineNames.EXIT_INFO_COLLECTOR } - .size, - ) + // spawn 1024 processes, instantly terminate them + repeat(1024) { + val process = runBinWithInput(binOnEel, Args(MAIN_PY, (it + 10).toString())) + inputAndAwaitExit(process) + } + + // 10 collection coroutines should be active + waitUntil { + exitInfoCollectorCoroutinesCount() == 10 + } + + // terminating all the processes + for (process in processes) { + inputAndAwaitExit(process) + } + + // no collection coroutines should be active + waitUntil { + exitInfoCollectorCoroutinesCount() == 0 + } } @Test @@ -403,6 +415,31 @@ class ProcessOutputControllerServiceTest { companion object { const val MAIN_PY = "main.py" + suspend fun runBinWithInput(binOnEel: BinOnEel, args: Args): Process = + ExecService().executeGetProcess( + binOnEel, + args, + CoroutineScope(NON_INTERACTIVE_ROOT_TRACE_CONTEXT), + ).getOrThrow() + + suspend fun inputAndAwaitExit(process: Process) { + process.outputStream.write(0) + process.outputStream.flush() + + coroutineScope { + listOf( + async(Dispatchers.IO) { + process.errorStream.readAllBytes() + }, + async(Dispatchers.IO) { + process.inputStream.readAllBytes() + }, + ).awaitAll() + + process.awaitExit() + } + } + suspend fun runBin(binOnEel: BinOnEel, args: Args) { withContext(NON_INTERACTIVE_ROOT_TRACE_CONTEXT) { val process = ExecService().executeGetProcess( @@ -425,5 +462,11 @@ class ProcessOutputControllerServiceTest { } } } + + @OptIn(ExperimentalCoroutinesApi::class) + private fun exitInfoCollectorCoroutinesCount(): Int = + DebugProbes.dumpCoroutinesInfo() + .filter { it.context[CoroutineName.Key]?.name == CoroutineNames.EXIT_INFO_COLLECTOR } + .size } } diff --git a/python/python-process-output/impl/test/com/intellij/python/processOutput/impl/ui/components/OutputSectionTest.kt b/python/python-process-output/impl/test/com/intellij/python/processOutput/impl/ui/components/OutputSectionTest.kt index f357dddcf18a..707e61049a0b 100644 --- a/python/python-process-output/impl/test/com/intellij/python/processOutput/impl/ui/components/OutputSectionTest.kt +++ b/python/python-process-output/impl/test/com/intellij/python/processOutput/impl/ui/components/OutputSectionTest.kt @@ -117,7 +117,7 @@ internal class OutputSectionTest : ProcessOutputTest() { // 3..6 - stderr // 7..9 - stdout val process = selectTestProcess( - listOf( + lines = listOf( outLine("out1"), outLine("out2"), outLine("out3"), @@ -207,6 +207,45 @@ internal class OutputSectionTest : ProcessOutputTest() { verify(exactly = 1) { controllerSpy.copyOutputExitInfoToClipboard(process) } } + @Test + fun `tags are displayed or hidden depending on show tags filter`() = processOutputTest { + // selecting a process with 3 tags: + // 0..2 - stdout + // 3..5 - stderr + // 6 - exit + selectTestProcess( + lines = listOf( + outLine("out1"), + outLine("out2"), + outLine("out3"), + + errLine("err4"), + errLine("err5"), + errLine("err6"), + ), + exitInfo = + LoggedProcessExitInfo( + exitedAt = Clock.System.now(), + exitValue = 0, + ), + ) + + // total displayed tags should be 3 + onAllNodesWithTag( + OutputSectionTestTags.OUTPUT_SECTION_TAG, + useUnmergedTree = true, + ).assertCountEquals(3) + + // remove show tags filter + processOutputFilters.remove(OutputFilter.ShowTags) + + // total displayed tags should be 0 + onAllNodesWithTag( + OutputSectionTestTags.OUTPUT_SECTION_TAG, + useUnmergedTree = true, + ).assertCountEquals(0) + } + private suspend fun selectTestProcess( lines: List = listOf(), exitInfo: LoggedProcessExitInfo? = null, diff --git a/python/python-process-output/impl/test/com/intellij/python/processOutput/impl/util.kt b/python/python-process-output/impl/test/com/intellij/python/processOutput/impl/util.kt index 9659d7faf0fb..4f16069d26e4 100644 --- a/python/python-process-output/impl/test/com/intellij/python/processOutput/impl/util.kt +++ b/python/python-process-output/impl/test/com/intellij/python/processOutput/impl/util.kt @@ -35,17 +35,19 @@ import org.jetbrains.jewel.intui.standalone.theme.IntUiTheme import org.junit.Rule internal abstract class ProcessOutputTest { - private val processTree = MutableStateFlow(buildTree {}) - private val processTreeFilters: SnapshotStateSet = mutableStateSetOf( + protected val processTree = MutableStateFlow(buildTree {}) + protected val processTreeFilters: SnapshotStateSet = mutableStateSetOf( TreeFilter.ShowTime, ) - private val processOutputFilters: SnapshotStateSet = mutableStateSetOf() - private val processOutputInfoExpanded = MutableStateFlow(false) - private val processOutputOutputExpanded = MutableStateFlow(true) + protected val processOutputFilters: SnapshotStateSet = mutableStateSetOf( + OutputFilter.ShowTags, + ) + protected val processOutputInfoExpanded = MutableStateFlow(false) + protected val processOutputOutputExpanded = MutableStateFlow(true) - private val testSelectedProcess: MutableStateFlow = MutableStateFlow(null) - private val testProcessTreeUiState: TreeUiState = run { + protected val testSelectedProcess: MutableStateFlow = MutableStateFlow(null) + protected val testProcessTreeUiState: TreeUiState = run { val selectableLazyListState = SelectableLazyListState(LazyListState()) TreeUiState( filters = processTreeFilters, @@ -55,7 +57,7 @@ internal abstract class ProcessOutputTest { tree = processTree, ) } - private val testProcessOutputUiState: OutputUiState = OutputUiState( + protected val testProcessOutputUiState: OutputUiState = OutputUiState( filters = processOutputFilters, isInfoExpanded = processOutputInfoExpanded, isOutputExpanded = processOutputOutputExpanded,