Skip to content

Commit b6c243b

Browse files
authored
[JBGateway] connection-based client side additional heartbeat (#20319)
* [supervisor-api] Protobuf update and code generating * [supervisor] implement `sendHeartbeat` method * [JBGW] update jetbrains gateway (WIP) * add thinClient a condition for additional heartbeat * Update checkbox description
1 parent 18b92d8 commit b6c243b

File tree

15 files changed

+1434
-78
lines changed

15 files changed

+1434
-78
lines changed

components/ide/jetbrains/gateway-plugin/build.gradle.kts

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
// See License.AGPL.txt in the project root for license information.
44

55
import io.gitlab.arturbosch.detekt.Detekt
6+
import org.jetbrains.changelog.date
67
import org.jetbrains.changelog.markdownToHTML
78
import org.jetbrains.intellij.platform.gradle.IntelliJPlatformType
89
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
@@ -36,6 +37,8 @@ if (environmentName.isNotBlank()) {
3637
pluginVersion += "-$environmentName"
3738
}
3839

40+
pluginVersion = pluginVersion.replace("{{LOCAL_VERSION}}", date("MMddhhmm") + "-local")
41+
3942
project(":") {
4043
kotlin {
4144
val excludedPackage = if (environmentName == "latest") "stable" else "latest"
@@ -176,3 +179,46 @@ tasks {
176179
}
177180
}
178181
}
182+
183+
tasks.register("installPlugin") {
184+
group = "gitpod"
185+
186+
println("Building plugin $pluginVersion")
187+
188+
dependsOn("buildPlugin")
189+
190+
doLast {
191+
val pluginTargetPath = "distributions/jetbrains-gateway-gitpod-plugin.zip"
192+
val pluginFile = layout.buildDirectory.file(pluginTargetPath).orNull?.asFile ?: {
193+
throw GradleException("Plugin file not found at $pluginTargetPath")
194+
}
195+
196+
// Example for macOS ~/Library/Application Support/JetBrains/JetBrainsGateway2024.3/plugins
197+
//
198+
// JB_GATEWAY_PLUGINS_DIR=/Users/hwen/Library/Application Support/JetBrains/JetBrainsGateway2024.3/plugins
199+
// JB_GATEWAY_IDEA_LOG_FILE=/Users/hwen/Library/Logs/JetBrains/JetBrainsGateway2024.3/idea.log
200+
val gatewayPluginsDir = System.getenv("JB_GATEWAY_PLUGINS_DIR")
201+
val gatewayIDEALogFile = System.getenv("JB_GATEWAY_IDEA_LOG_FILE")
202+
203+
if (gatewayPluginsDir.isNullOrEmpty()) {
204+
throw GradleException("Found no JB_GATEWAY_PLUGINS_DIR environment variable")
205+
}
206+
println("Copying plugin from $pluginFile to $gatewayPluginsDir")
207+
208+
copy {
209+
from(zipTree(pluginFile))
210+
into(file(gatewayPluginsDir))
211+
}
212+
213+
println("Plugin successfully copied to $gatewayPluginsDir")
214+
215+
exec {
216+
commandLine("sh", "-c", "pkill -f 'Gateway' || true")
217+
}
218+
if (!gatewayIDEALogFile.isNullOrEmpty()) {
219+
exec {
220+
commandLine("sh", "-c", "echo '' > $gatewayIDEALogFile")
221+
}
222+
}
223+
}
224+
}

components/ide/jetbrains/gateway-plugin/gradle.properties

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ pluginName=gitpod-gateway
88
latestPluginName=Gitpod Gateway
99
pluginId=io.gitpod.jetbrains.gateway
1010
# It is overriden by CI during the build.
11-
pluginVersion=0.0.1
11+
pluginVersion={{LOCAL_VERSION}}
1212
# IntelliJ Platform Properties -> https://plugins.jetbrains.com/docs/intellij/tools-gradle-intellij-plugin.html#intellij-extension-type
1313
platformType=GW
1414
platformDownloadSources=true

components/ide/jetbrains/gateway-plugin/src/main/kotlin/io/gitpod/jetbrains/gateway/GitpodConnectionProvider.kt

Lines changed: 93 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,6 @@ import io.gitpod.jetbrains.gateway.common.GitpodConnectionHandleFactory
4747
import io.gitpod.jetbrains.icons.GitpodIcons
4848
import kotlinx.coroutines.*
4949
import kotlinx.coroutines.future.await
50-
import java.awt.Component
5150
import java.net.URL
5251
import java.net.http.HttpClient
5352
import java.net.http.HttpRequest
@@ -58,6 +57,7 @@ import javax.swing.JLabel
5857
import kotlin.coroutines.coroutineContext
5958
import kotlin.io.path.absolutePathString
6059
import kotlin.io.path.writeText
60+
import kotlin.random.Random.Default.nextInt
6161

6262
@Suppress("UnstableApiUsage", "OPT_IN_USAGE")
6363
class GitpodConnectionProvider : GatewayConnectionProvider {
@@ -202,6 +202,39 @@ class GitpodConnectionProvider : GatewayConnectionProvider {
202202

203203
var lastUpdate: WorkspaceInstance? = null
204204
var canceledByGitpod = false
205+
206+
val ownerToken = client.server.getOwnerToken(connectParams.actualWorkspaceId).await()
207+
208+
if (settings.additionalHeartbeat) {
209+
thisLogger().info("gitpod: additional heartbeat enabled for ${connectParams.resolvedWorkspaceId}")
210+
connectionLifetime.launch {
211+
while (isActive) {
212+
val delaySeconds = 30 + nextInt(5, 15)
213+
if (thinClientJob?.isActive == true) {
214+
try {
215+
val ideUrlStr = lastUpdate?.ideUrl
216+
val ideUrl = if (ideUrlStr.isNullOrBlank()) {
217+
null
218+
} else {
219+
URL(ideUrlStr.replace(connectParams.actualWorkspaceId, connectParams.resolvedWorkspaceId))
220+
}
221+
if (lastUpdate?.status?.phase == "running" && ideUrl != null) {
222+
sendHeartBeatThroughSupervisor(ideUrl, ownerToken, connectParams)
223+
}
224+
} catch (t: Throwable) {
225+
thisLogger().error(
226+
"gitpod: failed to send additional heartbeat for ${connectParams.resolvedWorkspaceId}",
227+
t
228+
)
229+
}
230+
} else {
231+
thisLogger().debug("gitpod: thinClient is not active, skipping additional heartbeat for ${connectParams.resolvedWorkspaceId}")
232+
}
233+
delay(delaySeconds * 1000L)
234+
}
235+
}
236+
}
237+
205238
try {
206239
for (update in updates) {
207240
try {
@@ -518,7 +551,7 @@ class GitpodConnectionProvider : GatewayConnectionProvider {
518551
if (!connectParams.backendPort.isNullOrBlank()) {
519552
resolveJoinLinkUrl += "?backendPort=${connectParams.backendPort}"
520553
}
521-
var rawResp = fetchWS(resolveJoinLinkUrl, connectParams, ownerToken)
554+
var rawResp = retryFetchWS(resolveJoinLinkUrl, connectParams, ownerToken)
522555
if (rawResp != null) {
523556
return with(jacksonMapper) {
524557
propertyNamingStrategy = PropertyNamingStrategies.LowerCamelCaseStrategy()
@@ -531,13 +564,34 @@ class GitpodConnectionProvider : GatewayConnectionProvider {
531564
if (!connectParams.backendPort.isNullOrBlank()) {
532565
resolveJoinLinkUrl += "?backendPort=${connectParams.backendPort}"
533566
}
534-
rawResp = fetchWS(resolveJoinLinkUrl, connectParams, ownerToken)
567+
rawResp = retryFetchWS(resolveJoinLinkUrl, connectParams, ownerToken)
535568
if (rawResp != null) {
536569
return JoinLinkResp(-1, rawResp)
537570
}
538571
return null
539572
}
540573

574+
private var sendHeartBeatThroughSupervisorLogOnce = false
575+
private suspend fun sendHeartBeatThroughSupervisor(
576+
ideUrl: URL,
577+
ownerToken: String,
578+
connectParams: ConnectParams
579+
) {
580+
val resp = fetchWS("https://${ideUrl.host}/_supervisor/v1/send_heartbeat", ownerToken, 2000L)
581+
if (resp.statusCode != 200) {
582+
if (!resp.body.isNullOrBlank() && resp.body.contains("not implemented")) {
583+
if (!sendHeartBeatThroughSupervisorLogOnce) {
584+
thisLogger().warn("gitpod: sendHeartbeat ${connectParams.actualWorkspaceId} failed: method is not implemented in supervisor")
585+
sendHeartBeatThroughSupervisorLogOnce = true
586+
}
587+
return
588+
}
589+
thisLogger().error("gitpod: sendHeartbeat ${connectParams.actualWorkspaceId} failed: ${resp.statusCode}, body: ${resp.body}")
590+
return
591+
}
592+
thisLogger().debug("gitpod: sendHeartbeat succeed for ${connectParams.actualWorkspaceId}")
593+
}
594+
541595
private fun resolveCredentials(
542596
host: String,
543597
port: Int,
@@ -589,7 +643,7 @@ class GitpodConnectionProvider : GatewayConnectionProvider {
589643
ownerToken: String
590644
): CreateSSHKeyPairResponse? {
591645
val value =
592-
fetchWS("https://${ideUrl.host}/_supervisor/v1/ssh_keys/create", connectParams, ownerToken)
646+
retryFetchWS("https://${ideUrl.host}/_supervisor/v1/ssh_keys/create", connectParams, ownerToken)
593647
if (value.isNullOrBlank()) {
594648
return null
595649
}
@@ -604,7 +658,7 @@ class GitpodConnectionProvider : GatewayConnectionProvider {
604658
connectParams: ConnectParams
605659
): List<SSHHostKey>? {
606660
val hostKeysValue =
607-
fetchWS("https://${ideUrl.host}/_ssh/host_keys", connectParams, null)
661+
retryFetchWS("https://${ideUrl.host}/_ssh/host_keys", connectParams, null)
608662
if (hostKeysValue.isNullOrBlank()) {
609663
return null
610664
}
@@ -671,27 +725,50 @@ class GitpodConnectionProvider : GatewayConnectionProvider {
671725
return acceptHostKey
672726
}
673727

728+
data class HttpResponseData(val statusCode: Int, val body: String?) {
729+
fun statusCode() = statusCode
730+
fun body() = body
731+
}
732+
674733
private suspend fun fetchWS(
675734
endpointUrl: String,
676-
connectParams: ConnectParams,
677735
ownerToken: String?,
736+
timeoutMillis: Long,
737+
): HttpResponseData {
738+
var httpRequestBuilder = HttpRequest.newBuilder()
739+
.uri(URI.create(endpointUrl))
740+
.GET()
741+
.timeout(Duration.ofMillis(timeoutMillis))
742+
if (!ownerToken.isNullOrBlank()) {
743+
httpRequestBuilder = httpRequestBuilder.header("x-gitpod-owner-token", ownerToken)
744+
}
745+
val httpRequest = httpRequestBuilder.build()
746+
val responseFuture =
747+
httpClient.sendAsync(httpRequest, HttpResponse.BodyHandlers.ofString())
748+
749+
try {
750+
val response = responseFuture.await()
751+
return HttpResponseData(response.statusCode(), response.body())
752+
} catch (e: Exception) {
753+
if (responseFuture.isCancelled) {
754+
throw CancellationException()
755+
}
756+
throw e
757+
}
758+
}
759+
760+
private suspend fun retryFetchWS(
761+
endpointUrl: String,
762+
connectParams: ConnectParams,
763+
ownerToken: String?
678764
): String? {
679765
val maxRequestTimeout = 30 * 1000L
680766
val timeoutDelayGrowFactor = 1.5
681767
var requestTimeout = 2 * 1000L
682768
while (true) {
683769
coroutineContext.job.ensureActive()
684770
try {
685-
var httpRequestBuilder = HttpRequest.newBuilder()
686-
.uri(URI.create(endpointUrl))
687-
.GET()
688-
.timeout(Duration.ofMillis(requestTimeout))
689-
if (!ownerToken.isNullOrBlank()) {
690-
httpRequestBuilder = httpRequestBuilder.header("x-gitpod-owner-token", ownerToken)
691-
}
692-
val httpRequest = httpRequestBuilder.build()
693-
val response =
694-
httpClient.sendAsync(httpRequest, HttpResponse.BodyHandlers.ofString()).await()
771+
val response = fetchWS(endpointUrl, ownerToken, requestTimeout)
695772
if (response.statusCode() == 200) {
696773
return response.body()
697774
}

components/ide/jetbrains/gateway-plugin/src/main/kotlin/io/gitpod/jetbrains/gateway/GitpodSettingsConfigurable.kt

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,11 @@ class GitpodSettingsConfigurable : BoundConfigurable("Gitpod") {
3131
.comment("Helpful if you are behind a firewall/proxy that blocks SSH or " +
3232
"have complicated SSH setup (bastions, proxy jumps, etc.)")
3333
}
34+
row {
35+
checkBox("Persistent connection heartbeats")
36+
.bindSelected(state::additionalHeartbeat)
37+
.comment("Keep workspaces running as long as the IDE connection remains active.")
38+
}
3439

3540
}
3641
}

components/ide/jetbrains/gateway-plugin/src/main/kotlin/io/gitpod/jetbrains/gateway/GitpodSettingsState.kt

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,15 @@ class GitpodSettingsState : PersistentStateComponent<GitpodSettingsState> {
4545
dispatcher.multicaster.didChange()
4646
}
4747

48+
var additionalHeartbeat: Boolean = false
49+
set(value) {
50+
if (value == field) {
51+
return
52+
}
53+
field = value
54+
dispatcher.multicaster.didChange()
55+
}
56+
4857
private interface Listener : EventListener {
4958
fun didChange()
5059
}

components/ide/jetbrains/gateway-plugin/src/main/kotlin/io/gitpod/jetbrains/gateway/GitpodWorkspacesView.kt

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,13 @@ package io.gitpod.jetbrains.gateway
66

77
import com.intellij.icons.AllIcons
88
import com.intellij.ide.BrowserUtil
9+
import com.intellij.ide.plugins.PluginManagerCore
910
import com.intellij.openapi.CompositeDisposable
1011
import com.intellij.openapi.actionSystem.AnActionEvent
1112
import com.intellij.openapi.application.ApplicationManager
1213
import com.intellij.openapi.components.service
1314
import com.intellij.openapi.diagnostic.thisLogger
15+
import com.intellij.openapi.extensions.PluginId
1416
import com.intellij.openapi.project.DumbAwareAction
1517
import com.intellij.openapi.wm.impl.welcomeScreen.WelcomeScreenUIManager
1618
import com.intellij.remoteDev.util.onTerminationOrNow
@@ -106,10 +108,12 @@ class GitpodWorkspacesView(
106108
}
107109
}.visibleIf(loggedIn.not())
108110

111+
val pluginVersion = PluginManagerCore.getPlugin(PluginId.getId("io.gitpod.jetbrains.gateway"))?.version
112+
val pluginVersionLabel = if (pluginVersion?.contains("-local") == true) " (${pluginVersion})" else ""
109113
rowsRange {
110114
row {
111115
icon(GitpodIcons.Logo).gap(RightGap.SMALL)
112-
label("Gitpod").applyToComponent {
116+
label("Gitpod${pluginVersionLabel}").applyToComponent {
113117
this.font = JBFont.h3().asBold()
114118
}
115119
label("").resizableColumn().align(AlignX.FILL)

components/supervisor-api/control.proto

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,13 @@ service ControlService {
2828

2929
// CreateDebugEnv creates a debug workspace envs
3030
rpc CreateDebugEnv(CreateDebugEnvRequest) returns (CreateDebugEnvResponse) {}
31+
32+
// SendHeartBeat sends a heartbeat to server to keep the workspace alive
33+
rpc SendHeartBeat(SendHeartBeatRequest) returns (SendHeartBeatResponse) {
34+
option (google.api.http) = {
35+
get: "/v1/send_heartbeat"
36+
};
37+
}
3138
}
3239

3340
message ExposePortRequest {
@@ -73,3 +80,7 @@ message CreateDebugEnvRequest {
7380
message CreateDebugEnvResponse {
7481
repeated string envs = 1;
7582
}
83+
84+
message SendHeartBeatRequest {}
85+
86+
message SendHeartBeatResponse {}

0 commit comments

Comments
 (0)