From e494f89e00db8f6dfd9066613e8b35c73d6e0e77 Mon Sep 17 00:00:00 2001 From: Andre Dietisheim Date: Mon, 7 Sep 2026 20:47:46 +0200 Subject: [PATCH 1/2] refactor(server): extract IDE readiness polling from RemoteIDEServer (CRW-12992) Move wait/refresh/polling logic into RemoteIDEServerReadiness so polling behavior is testable in isolation while RemoteIDEServer keeps K8s concerns. Co-authored-by: Cursor --- .../gateway/server/RemoteIDEServer.kt | 122 +------ .../server/RemoteIDEServerReadiness.kt | 142 ++++++++ .../server/RemoteIDEServerReadinessTest.kt | 302 ++++++++++++++++++ 3 files changed, 460 insertions(+), 106 deletions(-) create mode 100644 src/main/kotlin/com/redhat/devtools/gateway/server/RemoteIDEServerReadiness.kt create mode 100644 src/test/kotlin/com/redhat/devtools/gateway/server/RemoteIDEServerReadinessTest.kt diff --git a/src/main/kotlin/com/redhat/devtools/gateway/server/RemoteIDEServer.kt b/src/main/kotlin/com/redhat/devtools/gateway/server/RemoteIDEServer.kt index 5e16c7e1..2d3f1c88 100644 --- a/src/main/kotlin/com/redhat/devtools/gateway/server/RemoteIDEServer.kt +++ b/src/main/kotlin/com/redhat/devtools/gateway/server/RemoteIDEServer.kt @@ -15,7 +15,6 @@ import com.google.gson.Gson import com.intellij.openapi.diagnostic.thisLogger import com.redhat.devtools.gateway.DevSpacesContext import com.redhat.devtools.gateway.openshift.DevWorkspacePods -import com.redhat.devtools.gateway.util.isCancellationException import io.kubernetes.client.openapi.models.V1Container import io.kubernetes.client.openapi.models.V1Pod import kotlinx.coroutines.* @@ -45,9 +44,6 @@ class RemoteIDEServer(private val devSpacesContext: DevSpacesContext) { * status exec cannot burn the entire wait (CRW-11119). */ const val STATUS_EXEC_TIMEOUT: Long = 15 // seconds - - /** Number of consecutive pod-refresh failures before emitting a warning. */ - const val REFRESH_FAILURE_WARNING_THRESHOLD: Int = 10 } init { @@ -112,7 +108,7 @@ class RemoteIDEServer(private val devSpacesContext: DevSpacesContext) { */ @Throws(IOException::class) suspend fun waitServerReady(checkCancelled: (() -> Unit)? = null, timeout: Long = readyTimeout): Boolean { - return doWaitServerState(true, timeout, checkCancelled) + return waitForState(true, timeout, checkCancelled) .also { if (!it) throw IOException( "Workspace IDE is not ready after $timeout seconds.", @@ -120,113 +116,27 @@ class RemoteIDEServer(private val devSpacesContext: DevSpacesContext) { } } - /** - * Re-resolves the workspace pod and idea-server container. - * - * @return `true` when refreshed successfully, `false` on transient failures (retried). - * Terminal conditions (cancellation, missing idea-server container) are rethrown. - */ - @Throws(CancellationException::class) - private fun refreshPod(refreshFailures: IntArray): Boolean { - return try { - pod = findPod() - container = findContainer() - refreshFailures[0] = 0 - true - } catch (e: Exception) { - if (e.isCancellationException()) throw e - if (e is ServerContainerNotFoundException) throw e - refreshFailures[0]++ - thisLogger().debug("Failed to refresh workspace pod during IDE state check", e) - if (refreshFailures[0] == REFRESH_FAILURE_WARNING_THRESHOLD) { - thisLogger().warn( - "Pod/container refresh has failed ${refreshFailures[0]} consecutive times; " + - "stale pod references may cause incorrect status checks" - ) - } - false - } - } - - @Throws(CancellationException::class) - private suspend fun isServerState( - isReadyState: Boolean, - checkCancelled: (() -> Unit)? = null, - refreshPodBeforeCheck: Boolean = false, - refreshFailures: IntArray = intArrayOf(0), - ): Boolean { - return try { - // Re-resolve pod while waiting for ready so a recycled pod is not missed. - if (refreshPodBeforeCheck && !refreshPod(refreshFailures)) { - return false - } - getStatus(checkCancelled).isReady == isReadyState - } catch (e: Exception) { - if (e.isCancellationException()) throw e - if (e is ServerContainerNotFoundException) throw e - thisLogger().debug("Failed to check workspace IDE state.", e) - false - } - } - @Throws(IOException::class) suspend fun waitServerTerminated(timeout: Long = 10L): Boolean { - return doWaitServerState(false, timeout) + return waitForState(false, timeout) } - /** - * Waits for the server to have or not have projects according to the given parameter. - * Times out the wait if the expected state is not reached within specified timeout. - * - * @param isReadyState True if server up and running with the projects all set are expected, False otherwise, - * @return True if the expected state is achieved within the timeout, False otherwise. - */ - @Throws(IOException::class, CancellationException::class) - private suspend fun doWaitServerState( + private suspend fun waitForState( isReadyState: Boolean, - timeout: Long = readyTimeout, + timeout: Long, checkCancelled: (() -> Unit)? = null - ): Boolean = - @Suppress("ConvertLongToDuration") - withTimeoutOrNull(timeout * 1000L) { - thisLogger().info( - "Waiting for IDE server on pod '${pod.metadata?.name}' " + - "container '${container.name}' to ${if (isReadyState) "become ready" else "terminate"}; " + - "timeout: ${timeout}s." - ) - var pollCount = 0 - val refreshFailures = intArrayOf(0) - while (true) { - checkCancelled?.invoke() - if (isServerState( - isReadyState, - checkCancelled, - // Re-resolve pod while waiting for ready so a recycled pod is not missed. - refreshPodBeforeCheck = isReadyState, - refreshFailures, - ) - ) { - thisLogger().info( - "IDE server on pod '${pod.metadata?.name}' " + - "${if (isReadyState) "is ready" else "terminated"} after ${pollCount * 500}ms." - ) - return@withTimeoutOrNull true - } - - pollCount++ - if (pollCount % 10 == 0) { - thisLogger().debug( - "Still waiting for IDE server on pod '${pod.metadata?.name}' " + - "(${pollCount * 500}ms / ${timeout * 1000}ms)." - ) - } - yield() - delay(500L) - } - - @Suppress("UNREACHABLE_CODE") - false - } ?: false + ): Boolean { + return RemoteIDEServerReadiness( + targetDescription = { + "IDE server on pod '${pod.metadata?.name}' container '${container.name}'" + }, + isReady = { cancelled -> getStatus(cancelled).isReady }, + refresh = { + pod = findPod() + container = findContainer() + }, + ).waitFor(isReadyState, timeout, checkCancelled) + } @Throws(IOException::class) private fun findPod(): V1Pod { diff --git a/src/main/kotlin/com/redhat/devtools/gateway/server/RemoteIDEServerReadiness.kt b/src/main/kotlin/com/redhat/devtools/gateway/server/RemoteIDEServerReadiness.kt new file mode 100644 index 00000000..01c491e6 --- /dev/null +++ b/src/main/kotlin/com/redhat/devtools/gateway/server/RemoteIDEServerReadiness.kt @@ -0,0 +1,142 @@ +/* + * Copyright (c) 2026 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ +package com.redhat.devtools.gateway.server + +import com.intellij.openapi.diagnostic.thisLogger +import com.redhat.devtools.gateway.util.isCancellationException +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.delay +import kotlinx.coroutines.withTimeoutOrNull +import kotlinx.coroutines.yield +import java.io.IOException + +/** + * Polls a state check until the expected state is reached or the given timeout elapses. + * + * @param targetDescription Human-readable description of the waited target, used in log messages. + * @param isReady Returns whether the server is currently ready. Non-terminal exceptions are + * caught and treated as "not ready"; [CancellationException] and + * [ServerContainerNotFoundException] are rethrown. + * @param refresh Target re-resolution before each readiness probe (only used when waiting + * for ready). Failures are retried with a warning after [REFRESH_FAILURE_WARNING_THRESHOLD] + * consecutive failures. Terminal exceptions are rethrown. + */ +class RemoteIDEServerReadiness( + private val targetDescription: () -> String, + private val isReady: suspend (checkCancelled: (() -> Unit)?) -> Boolean, + private val refresh: (() -> Unit)? = null, +) { + /** + * Waits until [isReady] reports the expected state. + * + * @param isReadyState True if the server becoming ready is expected, false if termination is expected. + * @param timeout Maximum waiting period in seconds. + * @param checkCancelled Optional user-cancellation check invoked before every probe. + * @return True if the expected state is achieved within the timeout, false otherwise. + */ + @Throws(IOException::class, CancellationException::class) + suspend fun waitFor( + isReadyState: Boolean, + timeout: Long, + checkCancelled: (() -> Unit)? = null, + ): Boolean = + @Suppress("ConvertLongToDuration") + withTimeoutOrNull(timeout * MILLISECONDS_PER_SECOND) { + logWaitingForState(isReadyState, timeout) + val refreshFailures = intArrayOf(0) + var pollCount = 0 + var elapsedMillis = 0L + while (true) { + checkCancelled?.invoke() + // On a transient refresh failure the probe is skipped for this + // iteration, same as the old refreshPodBeforeCheck behavior. + val probeAllowed = skipCheck(isReadyState) || attemptRefresh(refreshFailures) + if (probeAllowed) { + val stateReached = try { + isReady(checkCancelled) == isReadyState + } catch (e: Exception) { + if (e.isCancellationException() || e is ServerContainerNotFoundException) throw e + thisLogger().debug("Failed to check ${targetDescription()} state.", e) + false + } + if (stateReached) { + logStateReached(isReadyState, elapsedMillis) + return@withTimeoutOrNull true + } + } + + pollCount++ + logStillWaiting(pollCount, elapsedMillis, timeout) + yield() + val delayMillis = PROBE_DELAY_MILLIS + elapsedMillis += delayMillis + delay(delayMillis) + } + + @Suppress("UNREACHABLE_CODE") + false + } ?: false + + private fun skipCheck(isReadyState: Boolean): Boolean = !isReadyState || refresh == null + + private fun attemptRefresh(refreshFailures: IntArray): Boolean { + val doRefresh = refresh ?: return true + return try { + doRefresh() + refreshFailures[0] = 0 + true + } catch (e: Exception) { + if (e.isCancellationException() || e is ServerContainerNotFoundException) throw e + refreshFailures[0]++ + thisLogger().debug("Failed to refresh ${targetDescription()} during state check", e) + if (refreshFailures[0] == REFRESH_FAILURE_WARNING_THRESHOLD) { + thisLogger().warn( + "Refresh of ${targetDescription()} has failed ${refreshFailures[0]} consecutive times; " + + "stale references may cause incorrect state checks" + ) + } + false + } + } + + private fun logWaitingForState(isReadyState: Boolean, timeout: Long) { + thisLogger().info( + "Waiting for ${targetDescription()} to ${if (isReadyState) "become ready" else "terminate"}; " + + "timeout: ${timeout}s." + ) + } + + private fun logStateReached(isReadyState: Boolean, elapsedMillis: Long) { + thisLogger().info( + "${targetDescription()} ${if (isReadyState) "is ready" else "terminated"} after ${elapsedMillis}ms." + ) + } + + private fun logStillWaiting(pollCount: Int, elapsedMillis: Long, timeout: Long) { + if (pollCount % STILL_WAITING_LOG_INTERVAL != 0) { + return + } + thisLogger().debug( + "Still waiting for ${targetDescription()} " + + "(${elapsedMillis}ms / ${timeout * MILLISECONDS_PER_SECOND}ms)." + ) + } + + companion object { + private const val MILLISECONDS_PER_SECOND = 1000L + private const val STILL_WAITING_LOG_INTERVAL = 10 + private const val PROBE_DELAY_MILLIS = 500L + + /** Number of consecutive refresh failures before emitting a warning. */ + private const val REFRESH_FAILURE_WARNING_THRESHOLD = 10 + } +} diff --git a/src/test/kotlin/com/redhat/devtools/gateway/server/RemoteIDEServerReadinessTest.kt b/src/test/kotlin/com/redhat/devtools/gateway/server/RemoteIDEServerReadinessTest.kt new file mode 100644 index 00000000..1d8d26ae --- /dev/null +++ b/src/test/kotlin/com/redhat/devtools/gateway/server/RemoteIDEServerReadinessTest.kt @@ -0,0 +1,302 @@ +/* + * Copyright (c) 2026 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ +package com.redhat.devtools.gateway.server + +import io.mockk.* +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.runBlocking +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertThrows +import java.io.IOException + +class RemoteIDEServerReadinessTest { + + @Test + fun `#waitFor returns true when becomes ready`() = runBlocking { + val isReady = mockk Unit)?) -> Boolean>() + coEvery { isReady(any()) } returns true + + val readiness = RemoteIDEServerReadiness( + targetDescription = { "test" }, + isReady = isReady, + ) + + val result = readiness.waitFor(isReadyState = true, timeout = 5) + + assertThat(result).isTrue + Unit + } + + @Test + fun `#waitFor returns false on timeout`() = runBlocking { + val isReady = mockk Unit)?) -> Boolean>() + coEvery { isReady(any()) } returns false + + val readiness = RemoteIDEServerReadiness( + targetDescription = { "test" }, + isReady = isReady, + ) + + val result = readiness.waitFor(isReadyState = true, timeout = 1) + + assertThat(result).isFalse + Unit + } + + @Test + fun `#waitFor propagates CancellationException from checkCancelled`() = runBlocking { + val isReady = mockk Unit)?) -> Boolean>() + coEvery { isReady(any()) } returns false + + val readiness = RemoteIDEServerReadiness( + targetDescription = { "test" }, + isReady = isReady, + ) + + assertThrows { + readiness.waitFor(isReadyState = true, timeout = 5) { + throw CancellationException("User cancelled") + } + } + Unit + } + + @Test + fun `#waitFor treats non-terminal isReady exception as not ready`() = runBlocking { + val isReady = mockk Unit)?) -> Boolean>() + coEvery { isReady(any()) } throws IOException("transient error") + + val readiness = RemoteIDEServerReadiness( + targetDescription = { "test" }, + isReady = isReady, + ) + + // should not throw — exception is caught and treated as "not ready" + val result = readiness.waitFor(isReadyState = true, timeout = 1) + assertThat(result).isFalse + Unit + } + + @Test + fun `#waitFor rethrows ServerContainerNotFoundException from isReady`() = runBlocking { + val isReady = mockk Unit)?) -> Boolean>() + coEvery { isReady(any()) } throws ServerContainerNotFoundException("container gone") + + val readiness = RemoteIDEServerReadiness( + targetDescription = { "test" }, + isReady = isReady, + ) + + assertThrows { + readiness.waitFor(isReadyState = true, timeout = 5) + } + Unit + } + + @Test + fun `#waitFor rethrows CancellationException from isReady`() = runBlocking { + val isReady = mockk Unit)?) -> Boolean>() + coEvery { isReady(any()) } throws CancellationException("cancelled") + + val readiness = RemoteIDEServerReadiness( + targetDescription = { "test" }, + isReady = isReady, + ) + + assertThrows { + readiness.waitFor(isReadyState = true, timeout = 5) + } + Unit + } + + @Test + fun `#waitFor rethrows ServerContainerNotFoundException from refresh`() = runBlocking { + val isReady = mockk Unit)?) -> Boolean>() + coEvery { isReady(any()) } returns false + val refresh = mockk<() -> Unit>() + every { refresh() } throws ServerContainerNotFoundException("container gone") + + val readiness = RemoteIDEServerReadiness( + targetDescription = { "test" }, + isReady = isReady, + refresh = refresh, + ) + + assertThrows { + readiness.waitFor(isReadyState = true, timeout = 5) + } + Unit + } + + @Test + fun `#waitFor retries refresh on transient failure`() = runBlocking { + val isReady = mockk Unit)?) -> Boolean>() + coEvery { isReady(any()) } returns false + + var refreshCalls = 0 + val refresh = mockk<() -> Unit>() + every { refresh() } answers { + refreshCalls++ + if (refreshCalls < 3) throw IOException("transient") else Unit + } + + val readiness = RemoteIDEServerReadiness( + targetDescription = { "test" }, + isReady = isReady, + refresh = refresh, + ) + + val result = readiness.waitFor(isReadyState = true, timeout = 2) + assertThat(result).isFalse + assertThat(refreshCalls).isGreaterThanOrEqualTo(3) + Unit + } + + @Test + fun `#waitFor keeps constant polling rate while not ready despite successful refresh`() = runBlocking { + val probeTimes = mutableListOf() + val isReady = mockk Unit)?) -> Boolean>() + coEvery { isReady(any()) } answers { + probeTimes.add(System.nanoTime()) + false + } + val refresh = mockk<() -> Unit>() + every { refresh() } returns Unit + + val readiness = RemoteIDEServerReadiness( + targetDescription = { "test" }, + isReady = isReady, + refresh = refresh, + ) + + val result = readiness.waitFor(isReadyState = true, timeout = 3) + assertThat(result).isFalse + + assertThat(probeTimes).hasSize(6) + val gaps = probeTimes.zipWithNext { a, b -> (b - a) / 1_000_000 } + // constant 500ms polling rate + gaps.forEach { gap -> + assertThat(gap).isBetween(400L, 900L) + } + Unit + } + + @Test + fun `#waitFor resets refresh failure counter on success`() = runBlocking { + var refreshCalls = 0 + val refresh = mockk<() -> Unit>() + every { refresh() } answers { + refreshCalls++ + // fail first 2, succeed, fail 2 more — counter resets, never reaches threshold + if (refreshCalls == 1 || refreshCalls == 2 || refreshCalls == 4 || refreshCalls == 5) { + throw IOException("transient") + } + } + + val isReady = mockk Unit)?) -> Boolean>() + coEvery { isReady(any()) } answers { refreshCalls >= 6 } + + val readiness = RemoteIDEServerReadiness( + targetDescription = { "test" }, + isReady = isReady, + refresh = refresh, + ) + + val result = readiness.waitFor(isReadyState = true, timeout = 3) + assertThat(result).isTrue + // 2 fail + 1 success + 2 fail + 1 success, then isReady exits. + // timeout must cover the polling delays (0.5s x 5 = 2.5s). + assertThat(refreshCalls).isEqualTo(6) + Unit + } + + @Test + fun `#waitFor skips refresh when waiting for termination`() = runBlocking { + val isReady = mockk Unit)?) -> Boolean>() + coEvery { isReady(any()) } returns false + val refresh = mockk<() -> Unit>() + + val readiness = RemoteIDEServerReadiness( + targetDescription = { "test" }, + isReady = isReady, + refresh = refresh, + ) + + val result = readiness.waitFor(isReadyState = false, timeout = 5) + assertThat(result).isTrue + verify(exactly = 0) { refresh() } + Unit + } + + @Test + fun `#waitFor skips refresh when no refresh callback`() = runBlocking { + val isReady = mockk Unit)?) -> Boolean>() + coEvery { isReady(any()) } returns true + + val readiness = RemoteIDEServerReadiness( + targetDescription = { "test" }, + isReady = isReady, + ) + + val result = readiness.waitFor(isReadyState = true, timeout = 5) + assertThat(result).isTrue + Unit + } + + @Test + fun `#waitFor skips isReady when refresh fails transiently`() = runBlocking { + val isReady = mockk Unit)?) -> Boolean>() + coEvery { isReady(any()) } returns false + + val refresh = mockk<() -> Unit>() + every { refresh() } throws IOException("transient") + + val readiness = RemoteIDEServerReadiness( + targetDescription = { "test" }, + isReady = isReady, + refresh = refresh, + ) + + // timeout 1s: first iteration refresh fails → isReady skipped → + // ~500ms delay leaves no time for a second iteration + val result = readiness.waitFor(isReadyState = true, timeout = 1) + assertThat(result).isFalse + coVerify(exactly = 0) { isReady(any()) } + Unit + } + + @Test + fun `#waitFor calls isReady after successful refresh following failures`() = runBlocking { + val isReady = mockk Unit)?) -> Boolean>() + coEvery { isReady(any()) } returns true + + var refreshCalls = 0 + val refresh = mockk<() -> Unit>() + every { refresh() } answers { + refreshCalls++ + if (refreshCalls < 3) throw IOException("transient") + } + + val readiness = RemoteIDEServerReadiness( + targetDescription = { "test" }, + isReady = isReady, + refresh = refresh, + ) + + val result = readiness.waitFor(isReadyState = true, timeout = 5) + assertThat(result).isTrue + assertThat(refreshCalls).isGreaterThanOrEqualTo(3) + coVerify(atLeast = 1) { isReady(any()) } + Unit + } +} From dfbf032e2d905d2426f5921338f3088616335516 Mon Sep 17 00:00:00 2001 From: Andre Dietisheim Date: Mon, 7 Sep 2026 20:54:39 +0200 Subject: [PATCH 2/2] feat(util): add ExponentialBackoff for capped delay sequences (CRW-12992) Introduce a reusable backoff helper that doubles delays from 500ms up to a 5s cap, with reset support for retry loops. Co-authored-by: Cursor --- .../server/RemoteIDEServerReadiness.kt | 11 +++-- .../gateway/util/ExponentialBackoff.kt | 33 +++++++++++++ .../server/RemoteIDEServerReadinessTest.kt | 20 ++++---- .../gateway/util/ExponentialBackoffTest.kt | 48 +++++++++++++++++++ 4 files changed, 101 insertions(+), 11 deletions(-) create mode 100644 src/main/kotlin/com/redhat/devtools/gateway/util/ExponentialBackoff.kt create mode 100644 src/test/kotlin/com/redhat/devtools/gateway/util/ExponentialBackoffTest.kt diff --git a/src/main/kotlin/com/redhat/devtools/gateway/server/RemoteIDEServerReadiness.kt b/src/main/kotlin/com/redhat/devtools/gateway/server/RemoteIDEServerReadiness.kt index 01c491e6..da6524af 100644 --- a/src/main/kotlin/com/redhat/devtools/gateway/server/RemoteIDEServerReadiness.kt +++ b/src/main/kotlin/com/redhat/devtools/gateway/server/RemoteIDEServerReadiness.kt @@ -12,6 +12,7 @@ package com.redhat.devtools.gateway.server import com.intellij.openapi.diagnostic.thisLogger +import com.redhat.devtools.gateway.util.ExponentialBackoff import com.redhat.devtools.gateway.util.isCancellationException import kotlinx.coroutines.CancellationException import kotlinx.coroutines.delay @@ -20,7 +21,8 @@ import kotlinx.coroutines.yield import java.io.IOException /** - * Polls a state check until the expected state is reached or the given timeout elapses. + * Polls a state check with exponential backoff until the expected state is reached + * or the given timeout elapses. * * @param targetDescription Human-readable description of the waited target, used in log messages. * @param isReady Returns whether the server is currently ready. Non-terminal exceptions are @@ -29,11 +31,15 @@ import java.io.IOException * @param refresh Target re-resolution before each readiness probe (only used when waiting * for ready). Failures are retried with a warning after [REFRESH_FAILURE_WARNING_THRESHOLD] * consecutive failures. Terminal exceptions are rethrown. + * @param backoff Delay sequence between probes. A successful refresh must NOT reset it: + * while the target is still not ready the delays keep growing (500ms, 1s, 2s, ... capped) + * instead of polling at a constant rate. */ class RemoteIDEServerReadiness( private val targetDescription: () -> String, private val isReady: suspend (checkCancelled: (() -> Unit)?) -> Boolean, private val refresh: (() -> Unit)? = null, + private val backoff: ExponentialBackoff = ExponentialBackoff(), ) { /** * Waits until [isReady] reports the expected state. @@ -77,7 +83,7 @@ class RemoteIDEServerReadiness( pollCount++ logStillWaiting(pollCount, elapsedMillis, timeout) yield() - val delayMillis = PROBE_DELAY_MILLIS + val delayMillis = backoff.nextDelayMillis() elapsedMillis += delayMillis delay(delayMillis) } @@ -134,7 +140,6 @@ class RemoteIDEServerReadiness( companion object { private const val MILLISECONDS_PER_SECOND = 1000L private const val STILL_WAITING_LOG_INTERVAL = 10 - private const val PROBE_DELAY_MILLIS = 500L /** Number of consecutive refresh failures before emitting a warning. */ private const val REFRESH_FAILURE_WARNING_THRESHOLD = 10 diff --git a/src/main/kotlin/com/redhat/devtools/gateway/util/ExponentialBackoff.kt b/src/main/kotlin/com/redhat/devtools/gateway/util/ExponentialBackoff.kt new file mode 100644 index 00000000..8fd19fcf --- /dev/null +++ b/src/main/kotlin/com/redhat/devtools/gateway/util/ExponentialBackoff.kt @@ -0,0 +1,33 @@ +/* + * Copyright (c) 2026 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ +package com.redhat.devtools.gateway.util + +/** + * Produces a sequence of delays that start at [initialMillis] and double per call, + * capped at [maxMillis]. + */ +class ExponentialBackoff( + private val initialMillis: Long = 500, + private val maxMillis: Long = 5000, +) { + private var current = initialMillis.coerceIn(0L, maxMillis) + + fun nextDelayMillis(): Long { + val delay = current + current = if (current >= maxMillis / 2) maxMillis else current * 2 + return delay + } + + fun reset() { + current = initialMillis.coerceIn(0L, maxMillis) + } +} diff --git a/src/test/kotlin/com/redhat/devtools/gateway/server/RemoteIDEServerReadinessTest.kt b/src/test/kotlin/com/redhat/devtools/gateway/server/RemoteIDEServerReadinessTest.kt index 1d8d26ae..0b2a34f0 100644 --- a/src/test/kotlin/com/redhat/devtools/gateway/server/RemoteIDEServerReadinessTest.kt +++ b/src/test/kotlin/com/redhat/devtools/gateway/server/RemoteIDEServerReadinessTest.kt @@ -11,6 +11,7 @@ */ package com.redhat.devtools.gateway.server +import com.redhat.devtools.gateway.util.ExponentialBackoff import io.mockk.* import kotlinx.coroutines.CancellationException import kotlinx.coroutines.runBlocking @@ -163,7 +164,7 @@ class RemoteIDEServerReadinessTest { } @Test - fun `#waitFor keeps constant polling rate while not ready despite successful refresh`() = runBlocking { + fun `#waitFor keeps growing backoff while not ready despite successful refresh`() = runBlocking { val probeTimes = mutableListOf() val isReady = mockk Unit)?) -> Boolean>() coEvery { isReady(any()) } answers { @@ -173,21 +174,24 @@ class RemoteIDEServerReadinessTest { val refresh = mockk<() -> Unit>() every { refresh() } returns Unit + val backoff = ExponentialBackoff() + val readiness = RemoteIDEServerReadiness( targetDescription = { "test" }, isReady = isReady, refresh = refresh, + backoff = backoff, ) val result = readiness.waitFor(isReadyState = true, timeout = 3) assertThat(result).isFalse - assertThat(probeTimes).hasSize(6) + assertThat(probeTimes).hasSize(3) val gaps = probeTimes.zipWithNext { a, b -> (b - a) / 1_000_000 } - // constant 500ms polling rate - gaps.forEach { gap -> - assertThat(gap).isBetween(400L, 900L) - } + // refresh success must NOT reset the backoff: 500ms then 1000ms, + // not a constant 500ms polling rate + assertThat(gaps[0]).isBetween(400L, 900L) + assertThat(gaps[1]).isBetween(800L, 1900L) Unit } @@ -212,10 +216,10 @@ class RemoteIDEServerReadinessTest { refresh = refresh, ) - val result = readiness.waitFor(isReadyState = true, timeout = 3) + val result = readiness.waitFor(isReadyState = true, timeout = 20) assertThat(result).isTrue // 2 fail + 1 success + 2 fail + 1 success, then isReady exits. - // timeout must cover the polling delays (0.5s x 5 = 2.5s). + // timeout must cover the growing backoff delays (0.5+1+2+4+5 = 12.5s). assertThat(refreshCalls).isEqualTo(6) Unit } diff --git a/src/test/kotlin/com/redhat/devtools/gateway/util/ExponentialBackoffTest.kt b/src/test/kotlin/com/redhat/devtools/gateway/util/ExponentialBackoffTest.kt new file mode 100644 index 00000000..eee60dc8 --- /dev/null +++ b/src/test/kotlin/com/redhat/devtools/gateway/util/ExponentialBackoffTest.kt @@ -0,0 +1,48 @@ +/* + * Copyright (c) 2026 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ +package com.redhat.devtools.gateway.util + +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test + +class ExponentialBackoffTest { + + @Test + fun `delays double per call and cap at max`() { + val backoff = ExponentialBackoff() + val delays = List(8) { backoff.nextDelayMillis() } + assertThat(delays).containsExactly(500L, 1000L, 2000L, 4000L, 5000L, 5000L, 5000L, 5000L) + } + + @Test + fun `reset restarts at initial delay`() { + val backoff = ExponentialBackoff() + repeat(3) { backoff.nextDelayMillis() } + backoff.reset() + assertThat(backoff.nextDelayMillis()).isEqualTo(500L) + } + + @Test + fun `custom initial and max values are honored`() { + val backoff = ExponentialBackoff(initialMillis = 100L, maxMillis = 250L) + val delays = List(4) { backoff.nextDelayMillis() } + assertThat(delays).containsExactly(100L, 200L, 250L, 250L) + } + + @Test + fun `sequence stays bounded even after many calls`() { + val backoff = ExponentialBackoff() + repeat(200) { + assertThat(backoff.nextDelayMillis()).isBetween(500L, 5000L) + } + } +}