You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
308 lines
11 KiB
308 lines
11 KiB
/* |
|
* 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 |
|
|
|
import org.jetbrains.compose.desktop.ui.tooling.preview.rpc.utils.RingBuffer |
|
import java.io.IOException |
|
import java.net.ServerSocket |
|
import java.net.SocketTimeoutException |
|
import java.util.concurrent.TimeUnit |
|
import java.util.concurrent.atomic.AtomicBoolean |
|
import java.util.concurrent.atomic.AtomicLong |
|
import java.util.concurrent.atomic.AtomicReference |
|
import kotlin.concurrent.thread |
|
import kotlin.system.measureTimeMillis |
|
|
|
data class PreviewHostConfig( |
|
val javaExecutable: String, |
|
val hostClasspath: String |
|
) |
|
|
|
data class FrameConfig(val width: Int, val height: Int, val scale: Double?) { |
|
val scaledWidth: Int get() = scaledValue(width) |
|
val scaledHeight: Int get() = scaledValue(height) |
|
|
|
private fun scaledValue(value: Int): Int = |
|
if (scale != null) (value.toDouble() * scale).toInt() else value |
|
} |
|
|
|
data class FrameRequest( |
|
val id: Long, |
|
val composableFqName: String, |
|
val frameConfig: FrameConfig |
|
) |
|
|
|
interface PreviewManager { |
|
val gradleCallbackPort: Int |
|
fun updateFrameConfig(frameConfig: FrameConfig) |
|
fun close() |
|
} |
|
|
|
private data class RunningPreview( |
|
val connection: RemoteConnection, |
|
val process: Process |
|
) { |
|
val isAlive: Boolean |
|
get() = connection.isAlive && process.isAlive |
|
} |
|
|
|
class PreviewManagerImpl( |
|
private val previewListener: PreviewListener |
|
) : PreviewManager { |
|
// todo: add quiet mode |
|
private val log = PrintStreamLogger("SERVER") |
|
private val previewSocket = newServerSocket() |
|
private val gradleCallbackSocket = newServerSocket() |
|
private val connectionNumber = AtomicLong(0) |
|
private val isAlive = AtomicBoolean(true) |
|
|
|
// todo: restart when configuration changes |
|
private val previewHostConfig = AtomicReference<PreviewHostConfig>(null) |
|
private val previewClasspath = AtomicReference<String>(null) |
|
private val previewFqName = AtomicReference<String>(null) |
|
private val previewFrameConfig = AtomicReference<FrameConfig>(null) |
|
private val inProcessRequest = AtomicReference<FrameRequest>(null) |
|
private val processedRequest = AtomicReference<FrameRequest>(null) |
|
private val userRequestCount = AtomicLong(0) |
|
private val runningPreview = AtomicReference<RunningPreview>(null) |
|
private val threads = arrayListOf<Thread>() |
|
|
|
private val runPreviewThread = repeatWhileAliveThread("runPreview") { |
|
fun startPreviewProcess(config: PreviewHostConfig): Process = |
|
ProcessBuilder( |
|
config.javaExecutable, |
|
"-Djava.awt.headless=true", |
|
"-classpath", |
|
config.hostClasspath, |
|
PREVIEW_HOST_CLASS_NAME, |
|
previewSocket.localPort.toString() |
|
).apply { |
|
redirectOutput(ProcessBuilder.Redirect.PIPE) |
|
redirectError(ProcessBuilder.Redirect.PIPE) |
|
redirectErrorStream(true) |
|
}.start() |
|
|
|
val runningPreview = runningPreview.get() |
|
val previewConfig = previewHostConfig.get() |
|
if (previewConfig != null && runningPreview?.isAlive != true) { |
|
val process = startPreviewProcess(previewConfig) |
|
val connection = tryAcceptConnection(previewSocket, "PREVIEW") |
|
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) |
|
} |
|
} |
|
onError(errorMessage) |
|
} |
|
} |
|
} |
|
|
|
private val sendPreviewRequestThread = repeatWhileAliveThread("sendPreviewRequest") { |
|
withLivePreviewConnection { |
|
val classpath = previewClasspath.get() |
|
val fqName = previewFqName.get() |
|
val frameConfig = previewFrameConfig.get() |
|
|
|
if (classpath != null && frameConfig != null && fqName != null) { |
|
val request = FrameRequest(userRequestCount.get(), fqName, frameConfig) |
|
val prevRequest = processedRequest.get() |
|
if (inProcessRequest.get() == null && request != prevRequest) { |
|
if (inProcessRequest.compareAndSet(null, request)) { |
|
previewListener.onNewRenderRequest(request) |
|
sendPreviewRequest(classpath, request) |
|
} |
|
} |
|
} |
|
} |
|
} |
|
|
|
private val receivePreviewResponseThread = repeatWhileAliveThread("receivePreviewResponse") { |
|
withLivePreviewConnection { |
|
receiveFrame( |
|
onFrame = { renderedFrame -> |
|
inProcessRequest.get()?.let { request -> |
|
processedRequest.set(request) |
|
inProcessRequest.compareAndSet(request, null) |
|
} |
|
previewListener.onRenderedFrame(renderedFrame) |
|
}, |
|
onError = { error -> |
|
previewHostConfig.set(null) |
|
previewClasspath.set(null) |
|
inProcessRequest.set(null) |
|
onError(error) |
|
} |
|
) |
|
} |
|
} |
|
|
|
private val gradleCallbackThread = repeatWhileAliveThread("gradleCallback") { |
|
tryAcceptConnection(gradleCallbackSocket, "GRADLE_CALLBACK")?.let { connection -> |
|
while (isAlive.get() && connection.isAlive) { |
|
val config = connection.receiveConfigFromGradle() |
|
if (config != null) { |
|
previewClasspath.set(config.previewClasspath) |
|
previewFqName.set(config.previewFqName) |
|
previewHostConfig.set(config.previewHostConfig) |
|
userRequestCount.incrementAndGet() |
|
sendPreviewRequestThread.interrupt() |
|
} |
|
} |
|
} |
|
} |
|
|
|
override fun close() { |
|
if (!isAlive.compareAndSet(true, false)) return |
|
|
|
closeService("PREVIEW MANAGER") { |
|
val runningPreview = runningPreview.getAndSet(null) |
|
val previewConnection = runningPreview?.connection |
|
val previewProcess = runningPreview?.process |
|
threads.forEach { it.interrupt() } |
|
|
|
closeService("PREVIEW HOST CONNECTION") { previewConnection?.close() } |
|
closeService("PREVIEW SOCKET") { previewSocket.close() } |
|
closeService("GRADLE SOCKET") { gradleCallbackSocket.close() } |
|
closeService("THREADS") { |
|
for (i in 0..3) { |
|
var aliveThreads = 0 |
|
for (t in threads) { |
|
if (t.isAlive) { |
|
aliveThreads++ |
|
t.interrupt() |
|
} |
|
} |
|
if (aliveThreads == 0) break |
|
else Thread.sleep(300) |
|
} |
|
val aliveThreads = threads.filter { it.isAlive } |
|
if (aliveThreads.isNotEmpty()) { |
|
error("Could not stop threads: ${aliveThreads.joinToString(", ") { it.name }}") |
|
} |
|
} |
|
closeService("PREVIEW HOST PROCESS") { |
|
previewProcess?.let { process -> |
|
if (!process.waitFor(5, TimeUnit.SECONDS)) { |
|
log { "FORCIBLY DESTROYING PREVIEW HOST PROCESS" } |
|
// todo: check exit code |
|
process.destroyForcibly() |
|
} |
|
} |
|
} |
|
} |
|
} |
|
|
|
private inline fun closeService(name: String, doClose: () -> Unit) { |
|
try { |
|
log { "CLOSING $name" } |
|
val ms = measureTimeMillis { |
|
doClose() |
|
} |
|
log { "CLOSED $name in $ms ms" } |
|
} catch (e: Exception) { |
|
log.error { "ERROR CLOSING $name: ${e.stackTraceString}" } |
|
} |
|
} |
|
|
|
override fun updateFrameConfig(frameConfig: FrameConfig) { |
|
previewFrameConfig.set(frameConfig) |
|
sendPreviewRequestThread.interrupt() |
|
} |
|
|
|
override val gradleCallbackPort: Int |
|
get() = gradleCallbackSocket.localPort |
|
|
|
private fun tryAcceptConnection( |
|
serverSocket: ServerSocket, socketType: String |
|
): RemoteConnection? { |
|
while (isAlive.get()) { |
|
try { |
|
val socket = serverSocket.accept() |
|
return RemoteConnectionImpl( |
|
socket = socket, |
|
log = PrintStreamLogger("CONNECTION ($socketType) #${connectionNumber.incrementAndGet()}"), |
|
onClose = { |
|
// todo |
|
} |
|
) |
|
} catch (e: IOException) { |
|
if (e !is SocketTimeoutException) { |
|
if (isAlive.get()) { |
|
log.error { e.stackTraceToString() } |
|
} |
|
} |
|
} |
|
} |
|
|
|
return null |
|
} |
|
|
|
private inline fun withLivePreviewConnection(fn: RemoteConnection.() -> Unit) { |
|
val runningPreview = runningPreview.get() ?: return |
|
if (runningPreview.isAlive) { |
|
runningPreview.connection.fn() |
|
} |
|
} |
|
|
|
private inline fun repeatWhileAliveThread( |
|
name: String, |
|
sleepDelayMs: Long = DEFAULT_SLEEP_DELAY_MS, |
|
crossinline fn: () -> Unit |
|
) = thread(name = name, start = false) { |
|
while (isAlive.get()) { |
|
try { |
|
fn() |
|
Thread.sleep(sleepDelayMs) |
|
} catch (e: InterruptedException) { |
|
continue |
|
} |
|
} |
|
}.also { |
|
it.uncaughtExceptionHandler = Thread.UncaughtExceptionHandler { thread, e -> |
|
onError(e) |
|
} |
|
threads.add(it) |
|
it.start() |
|
} |
|
|
|
private fun onError(e: Throwable) { |
|
onError(e.stackTraceString) |
|
} |
|
|
|
private fun onError(error: String) { |
|
log.error { error } |
|
previewListener.onError(error) |
|
} |
|
}
|
|
|