mirror of
https://github.com/jlengrand/compose-multiplatform.git
synced 2026-03-10 08:11:20 +00:00
Improve handling of preview errors (#1502)
This commit is contained in:
@@ -9,6 +9,7 @@ data class Command(val type: Type, val args: List<String>) {
|
||||
enum class Type {
|
||||
ATTACH,
|
||||
FRAME,
|
||||
ERROR,
|
||||
PREVIEW_CONFIG,
|
||||
PREVIEW_CLASSPATH,
|
||||
PREVIEW_FQ_NAME,
|
||||
|
||||
@@ -8,4 +8,6 @@ package org.jetbrains.compose.desktop.ui.tooling.preview.rpc
|
||||
internal object ExitCodes {
|
||||
const val OK = 0
|
||||
const val COULD_NOT_CONNECT_TO_PREVIEW_MANAGER = 1
|
||||
const val RECEIVER_FATAL_ERROR = 2
|
||||
const val SENDER_FATAL_ERROR = 3
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
/*
|
||||
* Copyright 2020-2021 JetBrains s.r.o. and respective authors and developers.
|
||||
* Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE.txt file.
|
||||
*/
|
||||
|
||||
package org.jetbrains.compose.desktop.ui.tooling.preview.rpc
|
||||
|
||||
interface PreviewErrorReporter {
|
||||
fun report(e: Throwable, details: String? = null)
|
||||
fun report(e: String, details: String? = null)
|
||||
}
|
||||
|
||||
object StderrPreviewErrorReporter : PreviewErrorReporter {
|
||||
override fun report(e: Throwable, details: String?) {
|
||||
report(e.stackTraceString)
|
||||
}
|
||||
|
||||
override fun report(e: String, details: String?) {
|
||||
System.err.println(e)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
/*
|
||||
* Copyright 2020-2021 JetBrains s.r.o. and respective authors and developers.
|
||||
* Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE.txt file.
|
||||
*/
|
||||
|
||||
package org.jetbrains.compose.desktop.ui.tooling.preview.rpc
|
||||
|
||||
class PreviewException(message: String) : RuntimeException(message)
|
||||
@@ -5,6 +5,7 @@
|
||||
|
||||
package org.jetbrains.compose.desktop.ui.tooling.preview.rpc
|
||||
|
||||
import org.jetbrains.compose.desktop.ui.tooling.preview.rpc.utils.RingBuffer
|
||||
import java.io.IOException
|
||||
import java.net.ServerSocket
|
||||
import java.net.SocketTimeoutException
|
||||
@@ -49,8 +50,10 @@ private data class RunningPreview(
|
||||
}
|
||||
|
||||
class PreviewManagerImpl(
|
||||
private val previewListener: PreviewListener = PreviewListenerBase()
|
||||
private val previewListener: PreviewListener = PreviewListenerBase(),
|
||||
private val errorReporter: PreviewErrorReporter = StderrPreviewErrorReporter
|
||||
) : PreviewManager {
|
||||
// todo: add quiet mode
|
||||
private val log = PrintStreamLogger("SERVER")
|
||||
private val previewSocket = newServerSocket()
|
||||
private val gradleCallbackSocket = newServerSocket()
|
||||
@@ -78,9 +81,9 @@ class PreviewManagerImpl(
|
||||
PREVIEW_HOST_CLASS_NAME,
|
||||
previewSocket.localPort.toString()
|
||||
).apply {
|
||||
// todo: non verbose mode
|
||||
redirectOutput(ProcessBuilder.Redirect.INHERIT)
|
||||
redirectError(ProcessBuilder.Redirect.INHERIT)
|
||||
redirectOutput(ProcessBuilder.Redirect.PIPE)
|
||||
redirectError(ProcessBuilder.Redirect.PIPE)
|
||||
redirectErrorStream(true)
|
||||
}.start()
|
||||
|
||||
val runningPreview = runningPreview.get()
|
||||
@@ -91,6 +94,39 @@ class PreviewManagerImpl(
|
||||
connection?.receiveAttach(listener = previewListener) {
|
||||
this.runningPreview.set(RunningPreview(connection, process))
|
||||
}
|
||||
val processLogLines = RingBuffer<String>(512)
|
||||
val exception = StringBuilder()
|
||||
var exceptionMarker = false
|
||||
process.inputStream.bufferedReader().forEachLine { line ->
|
||||
if (exceptionMarker) {
|
||||
exception.appendLine(line)
|
||||
} else {
|
||||
if (line.startsWith(PREVIEW_START_OF_STACKTRACE_MARKER)) {
|
||||
exceptionMarker = true
|
||||
} else {
|
||||
processLogLines.add(line)
|
||||
}
|
||||
}
|
||||
}
|
||||
while (process.isAlive) {
|
||||
process.waitFor(5, TimeUnit.SECONDS)
|
||||
if (process.isAlive) {
|
||||
process.destroyForcibly()
|
||||
process.waitFor(5, TimeUnit.SECONDS)
|
||||
}
|
||||
}
|
||||
if (process.isAlive) error("Preview process does not finish!")
|
||||
|
||||
val exitCode = process.exitValue()
|
||||
if (exitCode != ExitCodes.OK) {
|
||||
val errorMessage = buildString {
|
||||
appendLine("Preview process exited unexpectedly: exitCode=$exitCode")
|
||||
if (exceptionMarker) {
|
||||
appendLine(exception)
|
||||
}
|
||||
}
|
||||
errorReporter.report(PreviewException(errorMessage), details = processLogLines.joinToString("\n"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -115,13 +151,21 @@ class PreviewManagerImpl(
|
||||
|
||||
private val receivePreviewResponseThread = repeatWhileAliveThread("receivePreviewResponse") {
|
||||
withLivePreviewConnection {
|
||||
receiveFrame { renderedFrame ->
|
||||
inProcessRequest.get()?.let { request ->
|
||||
processedRequest.set(request)
|
||||
inProcessRequest.compareAndSet(request, null)
|
||||
receiveFrame(
|
||||
onFrame = { renderedFrame ->
|
||||
inProcessRequest.get()?.let { request ->
|
||||
processedRequest.set(request)
|
||||
inProcessRequest.compareAndSet(request, null)
|
||||
}
|
||||
previewListener.onRenderedFrame(renderedFrame)
|
||||
},
|
||||
onError = { error ->
|
||||
errorReporter.report(PreviewException(error))
|
||||
previewHostConfig.set(null)
|
||||
previewClasspath.set(null)
|
||||
inProcessRequest.set(null)
|
||||
}
|
||||
previewListener.onRenderedFrame(renderedFrame)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -244,12 +288,12 @@ class PreviewManagerImpl(
|
||||
Thread.sleep(sleepDelayMs)
|
||||
} catch (e: InterruptedException) {
|
||||
continue
|
||||
} catch (e: Throwable) {
|
||||
e.printStackTrace(System.err)
|
||||
break
|
||||
}
|
||||
}
|
||||
}.also {
|
||||
it.uncaughtExceptionHandler = Thread.UncaughtExceptionHandler { thread, e ->
|
||||
errorReporter.report(e)
|
||||
}
|
||||
threads.add(it)
|
||||
it.start()
|
||||
}
|
||||
|
||||
@@ -69,31 +69,46 @@ internal class PreviewHost(private val log: PreviewLogger, connection: RemoteCon
|
||||
Thread.sleep(DEFAULT_SLEEP_DELAY_MS)
|
||||
} catch (e: InterruptedException) {
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val receiverThread = thread {
|
||||
try {
|
||||
while (connection.isAlive) {
|
||||
try {
|
||||
connection.receivePreviewRequest(
|
||||
onPreviewClasspath = {
|
||||
previewClasspath.set(it)
|
||||
senderThread.interrupt()
|
||||
},
|
||||
onFrameRequest = {
|
||||
previewRequest.set(it)
|
||||
senderThread.interrupt()
|
||||
}
|
||||
)
|
||||
} catch (e: SocketTimeoutException) {
|
||||
continue
|
||||
} catch (e: Exception) {
|
||||
if (connection.isAlive) {
|
||||
connection.sendError(e)
|
||||
} else {
|
||||
throw IllegalStateException("Could not report an exception: IDE connection is not alive", e)
|
||||
}
|
||||
}
|
||||
} catch (e: Throwable) {
|
||||
e.printStackTrace(System.err)
|
||||
exitProcess(1)
|
||||
}
|
||||
}.setUpUnhandledExceptionHandler(ExitCodes.SENDER_FATAL_ERROR)
|
||||
|
||||
val receiverThread = thread {
|
||||
while (connection.isAlive) {
|
||||
try {
|
||||
connection.receivePreviewRequest(
|
||||
onPreviewClasspath = {
|
||||
previewClasspath.set(it)
|
||||
senderThread.interrupt()
|
||||
},
|
||||
onFrameRequest = {
|
||||
previewRequest.set(it)
|
||||
senderThread.interrupt()
|
||||
}
|
||||
)
|
||||
} catch (e: SocketTimeoutException) {
|
||||
continue
|
||||
} catch (e: InterruptedException) {
|
||||
continue
|
||||
}
|
||||
}
|
||||
}.setUpUnhandledExceptionHandler(ExitCodes.RECEIVER_FATAL_ERROR)
|
||||
|
||||
private fun Thread.setUpUnhandledExceptionHandler(exitCode: Int): Thread = apply {
|
||||
uncaughtExceptionHandler = Thread.UncaughtExceptionHandler { t, e ->
|
||||
try {
|
||||
System.err.println()
|
||||
System.err.println(PREVIEW_START_OF_STACKTRACE_MARKER)
|
||||
e.printStackTrace(System.err)
|
||||
} finally {
|
||||
exitProcess(exitCode)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -32,14 +32,30 @@ internal fun RemoteConnection.sendFrame(frame: RenderedFrame) {
|
||||
sendData(frame.bytes)
|
||||
}
|
||||
|
||||
internal fun RemoteConnection.receiveFrame(fn: (RenderedFrame) -> Unit) {
|
||||
internal fun RemoteConnection.sendError(e: Exception) {
|
||||
sendCommand(Command.Type.ERROR)
|
||||
sendUtf8StringData(e.stackTraceString)
|
||||
}
|
||||
|
||||
internal fun RemoteConnection.receiveFrame(
|
||||
onFrame: (RenderedFrame) -> Unit,
|
||||
onError: (String) -> Unit
|
||||
) {
|
||||
receiveCommand { (type, args) ->
|
||||
if (type == Command.Type.FRAME) {
|
||||
receiveData { bytes ->
|
||||
val (w, h) = args
|
||||
val frame = RenderedFrame(bytes, width = w.toInt(), height = h.toInt())
|
||||
fn(frame)
|
||||
when (type) {
|
||||
Command.Type.FRAME -> {
|
||||
receiveData { bytes ->
|
||||
val (w, h) = args
|
||||
val frame = RenderedFrame(bytes, width = w.toInt(), height = h.toInt())
|
||||
onFrame(frame)
|
||||
}
|
||||
}
|
||||
Command.Type.ERROR -> {
|
||||
receiveUtf8StringData { stacktrace ->
|
||||
onError(stacktrace)
|
||||
}
|
||||
}
|
||||
else -> error("Received unexpected command type: $type")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,3 +11,4 @@ internal const val MAX_CMD_SIZE = 8 * 1024
|
||||
// 100 Mb should be enough even for 8K screenshots
|
||||
internal const val MAX_BINARY_SIZE = 100 * 1024 * 1024
|
||||
internal const val MAX_BUF_SIZE = 8 * 1024
|
||||
internal const val PREVIEW_START_OF_STACKTRACE_MARKER = "<!--START OF COMPOSE PREVIEW PROCESS FATAL EXCEPTION--!>"
|
||||
|
||||
@@ -5,4 +5,4 @@
|
||||
|
||||
package org.jetbrains.compose.desktop.ui.tooling.preview.rpc
|
||||
|
||||
const val PROTOCOL_VERSION = 1
|
||||
const val PROTOCOL_VERSION = 2
|
||||
@@ -0,0 +1,51 @@
|
||||
/*
|
||||
* Copyright 2020-2021 JetBrains s.r.o. and respective authors and developers.
|
||||
* Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE.txt file.
|
||||
*/
|
||||
|
||||
package org.jetbrains.compose.desktop.ui.tooling.preview.rpc.utils
|
||||
|
||||
internal class RingBuffer<T : Any>(internal val maxSize: Int) : Iterable<T> {
|
||||
private var start = 0
|
||||
private var size = 0
|
||||
private val values = arrayOfNulls<Any?>(maxSize)
|
||||
|
||||
init {
|
||||
check(maxSize > 0) { "Max size should be a positive number: $maxSize" }
|
||||
}
|
||||
|
||||
fun add(element: T) {
|
||||
if (size < maxSize) {
|
||||
size++
|
||||
} else {
|
||||
start = (start + 1) % maxSize
|
||||
}
|
||||
values[(start + size - 1) % maxSize] = element
|
||||
}
|
||||
|
||||
fun addAll(elements: Iterable<T>) {
|
||||
elements.forEach { add(it) }
|
||||
}
|
||||
|
||||
fun clear() {
|
||||
start = 0
|
||||
size = 0
|
||||
for (i in values.indices) {
|
||||
values[i] = null
|
||||
}
|
||||
}
|
||||
|
||||
override fun iterator(): Iterator<T> =
|
||||
object : Iterator<T> {
|
||||
private var i = 0
|
||||
|
||||
override fun hasNext(): Boolean = i < size
|
||||
|
||||
override fun next(): T {
|
||||
if (!hasNext()) throw NoSuchElementException()
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
return values[(start + i++) % maxSize] as T
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
/*
|
||||
* Copyright 2020-2021 JetBrains s.r.o. and respective authors and developers.
|
||||
* Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE.txt file.
|
||||
*/
|
||||
|
||||
package org.jetbrains.compose.desktop.ui.tooling.preview.rpc.utils
|
||||
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
import org.junit.jupiter.api.Assertions.*
|
||||
|
||||
internal class RingBufferTest {
|
||||
private fun numbers(size: Int): IntArray = IntArray(size) { it }
|
||||
private fun testBuffer() = RingBuffer<Int>(4)
|
||||
|
||||
@Test
|
||||
fun empty() {
|
||||
val it = testBuffer().iterator()
|
||||
assertFalse(it.hasNext())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun addSmall() {
|
||||
val buf = testBuffer()
|
||||
val expected = numbers(buf.maxSize / 2)
|
||||
buf.addAll(expected.asIterable())
|
||||
assertArrayEquals(expected, buf.toList().toIntArray())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun addMedium() {
|
||||
val buf = testBuffer()
|
||||
val expected = numbers(buf.maxSize + buf.maxSize / 2)
|
||||
buf.addAll(expected.asIterable())
|
||||
checkAllEquals(expected.takeLast(buf.maxSize), buf.toList())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun addBig() {
|
||||
val buf = testBuffer()
|
||||
val expected = numbers(buf.maxSize * 3)
|
||||
buf.addAll(expected.asIterable())
|
||||
checkAllEquals(expected.takeLast(buf.maxSize), buf.toList())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testClear() {
|
||||
val buf = testBuffer()
|
||||
val expected = numbers(buf.maxSize * 3)
|
||||
buf.addAll(expected.asIterable())
|
||||
buf.clear()
|
||||
checkAllEquals(emptyList(), buf.toList())
|
||||
}
|
||||
|
||||
private fun <T> checkAllEquals(expected: Collection<T>, actual: Collection<T>) {
|
||||
val expectedString = expected.joinToString(", ", prefix = "[", postfix = "]")
|
||||
val actualString = actual.joinToString(", ", prefix = "[", postfix = "]")
|
||||
assertEquals(expectedString, actualString)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
/*
|
||||
* Copyright 2020-2021 JetBrains s.r.o. and respective authors and developers.
|
||||
* Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE.txt file.
|
||||
*/
|
||||
|
||||
package org.jetbrains.compose.desktop.ide.preview
|
||||
|
||||
import com.intellij.openapi.diagnostic.Logger
|
||||
import org.jetbrains.compose.desktop.ui.tooling.preview.rpc.PreviewErrorReporter
|
||||
|
||||
internal class IdePreviewErrorReporter(
|
||||
private val logger: Logger,
|
||||
private val previewStateService: PreviewStateService
|
||||
) : PreviewErrorReporter {
|
||||
override fun report(e: Throwable, details: String?) {
|
||||
report(e.stackTraceToString(), details)
|
||||
}
|
||||
|
||||
override fun report(e: String, details: String?) {
|
||||
if (details != null) {
|
||||
logger.error(e, details)
|
||||
} else {
|
||||
logger.error(e)
|
||||
}
|
||||
previewStateService.clearPreviewOnError()
|
||||
}
|
||||
}
|
||||
@@ -29,7 +29,7 @@ internal class PreviewPanel : JPanel() {
|
||||
}
|
||||
}
|
||||
|
||||
fun previewImage(image: BufferedImage, imageDimension: Dimension) {
|
||||
fun previewImage(image: BufferedImage?, imageDimension: Dimension?) {
|
||||
synchronized(this) {
|
||||
this.image = image
|
||||
this.imageDimension = imageDimension
|
||||
|
||||
@@ -9,6 +9,7 @@ import com.intellij.notification.NotificationGroupManager
|
||||
import com.intellij.notification.NotificationType
|
||||
import com.intellij.openapi.Disposable
|
||||
import com.intellij.openapi.components.Service
|
||||
import com.intellij.openapi.diagnostic.Logger
|
||||
import com.intellij.openapi.externalSystem.model.task.*
|
||||
import com.intellij.openapi.externalSystem.service.notification.ExternalSystemProgressNotificationManager
|
||||
import com.intellij.openapi.module.Module
|
||||
@@ -24,12 +25,16 @@ import javax.swing.event.AncestorListener
|
||||
|
||||
@Service
|
||||
class PreviewStateService(private val myProject: Project) : Disposable {
|
||||
private val idePreviewLogger = Logger.getInstance("org.jetbrains.compose.desktop.ide.preview")
|
||||
private val previewListener = CompositePreviewListener()
|
||||
private val previewManager: PreviewManager = PreviewManagerImpl(previewListener)
|
||||
private val errorReporter = IdePreviewErrorReporter(idePreviewLogger, this)
|
||||
private val previewManager: PreviewManager = PreviewManagerImpl(previewListener, errorReporter)
|
||||
val gradleCallbackPort: Int
|
||||
get() = previewManager.gradleCallbackPort
|
||||
private val configurePreviewTaskNameCache =
|
||||
ConfigurePreviewTaskNameCache(ConfigurePreviewTaskNameProviderImpl())
|
||||
private var previewPanel: PreviewPanel? = null
|
||||
private var loadingPanel: JBLoadingPanel? = null
|
||||
|
||||
init {
|
||||
val projectRefreshListener = ConfigurePreviewTaskNameCacheInvalidator(configurePreviewTaskNameCache)
|
||||
@@ -44,12 +49,17 @@ class PreviewStateService(private val myProject: Project) : Disposable {
|
||||
override fun dispose() {
|
||||
previewManager.close()
|
||||
configurePreviewTaskNameCache.invalidate()
|
||||
previewPanel = null
|
||||
loadingPanel = null
|
||||
}
|
||||
|
||||
internal fun registerPreviewPanels(
|
||||
previewPanel: PreviewPanel,
|
||||
loadingPanel: JBLoadingPanel
|
||||
) {
|
||||
this.previewPanel = previewPanel
|
||||
this.loadingPanel = loadingPanel
|
||||
|
||||
val previewResizeListener = PreviewResizeListener(previewManager)
|
||||
previewPanel.addAncestorListener(previewResizeListener)
|
||||
Disposer.register(this) { previewPanel.removeAncestorListener(previewResizeListener) }
|
||||
@@ -73,6 +83,11 @@ class PreviewStateService(private val myProject: Project) : Disposable {
|
||||
})
|
||||
}
|
||||
|
||||
internal fun clearPreviewOnError() {
|
||||
loadingPanel?.stopLoading()
|
||||
previewPanel?.previewImage(null, null)
|
||||
}
|
||||
|
||||
internal fun buildStarted() {
|
||||
previewListener.onNewBuildRequest()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user