From 8a2590207d0f6db49f5f35ab3ebfce6021ba5a96 Mon Sep 17 00:00:00 2001 From: Ferran Pons Serra Date: Sat, 13 Jun 2026 19:49:04 +0200 Subject: [PATCH 1/4] feat(sdks/kotlin): add SandboxManager.waitForSnapshotReady Snapshot creation is asynchronous: createSnapshot returns while the snapshot is still in the Creating state. This adds a client-side poller that waits until the snapshot reaches Ready, fails fast on Failed, and times out after a configurable deadline. - Add SnapshotState constants (Creating/Ready/Failed/Deleting/Unknown), mirroring SandboxState. - Add SnapshotFailedException and the SNAPSHOT_FAILED error code. - Add SandboxManager.waitForSnapshotReady(snapshotId, timeout, pollingInterval) with @JvmOverloads for Java callers. Pure client-side glue: no spec, server, or generated-code changes. Co-authored-by: Atenea Agent --- .../opensandbox/sandbox/SandboxManager.kt | 61 +++++++++++++++++++ .../domain/exceptions/SandboxException.kt | 15 +++++ .../domain/models/sandboxes/SandboxModels.kt | 24 ++++++++ .../opensandbox/sandbox/SandboxManagerTest.kt | 58 ++++++++++++++++++ 4 files changed, 158 insertions(+) diff --git a/sdks/sandbox/kotlin/sandbox/src/main/kotlin/com/alibaba/opensandbox/sandbox/SandboxManager.kt b/sdks/sandbox/kotlin/sandbox/src/main/kotlin/com/alibaba/opensandbox/sandbox/SandboxManager.kt index c98ee38b7..663972eca 100644 --- a/sdks/sandbox/kotlin/sandbox/src/main/kotlin/com/alibaba/opensandbox/sandbox/SandboxManager.kt +++ b/sdks/sandbox/kotlin/sandbox/src/main/kotlin/com/alibaba/opensandbox/sandbox/SandboxManager.kt @@ -19,6 +19,8 @@ package com.alibaba.opensandbox.sandbox import com.alibaba.opensandbox.sandbox.config.ConnectionConfig import com.alibaba.opensandbox.sandbox.domain.exceptions.InvalidArgumentException import com.alibaba.opensandbox.sandbox.domain.exceptions.SandboxException +import com.alibaba.opensandbox.sandbox.domain.exceptions.SandboxReadyTimeoutException +import com.alibaba.opensandbox.sandbox.domain.exceptions.SnapshotFailedException import com.alibaba.opensandbox.sandbox.domain.models.diagnostics.DiagnosticContent import com.alibaba.opensandbox.sandbox.domain.models.sandboxes.PagedSandboxInfos import com.alibaba.opensandbox.sandbox.domain.models.sandboxes.PagedSnapshotInfos @@ -27,6 +29,7 @@ import com.alibaba.opensandbox.sandbox.domain.models.sandboxes.SandboxInfo import com.alibaba.opensandbox.sandbox.domain.models.sandboxes.SandboxRenewResponse import com.alibaba.opensandbox.sandbox.domain.models.sandboxes.SnapshotFilter import com.alibaba.opensandbox.sandbox.domain.models.sandboxes.SnapshotInfo +import com.alibaba.opensandbox.sandbox.domain.models.sandboxes.SnapshotState import com.alibaba.opensandbox.sandbox.domain.services.Diagnostics import com.alibaba.opensandbox.sandbox.domain.services.Sandboxes import com.alibaba.opensandbox.sandbox.infrastructure.factory.AdapterFactory @@ -224,6 +227,64 @@ class SandboxManager internal constructor( fun deleteSnapshot(snapshotId: String) = sandboxService.deleteSnapshot(snapshotId) + /** + * Waits for a snapshot to reach the [SnapshotState.READY] state, polling at a fixed interval. + * + * Snapshot creation is asynchronous: [createSnapshot] returns as soon as the snapshot record + * exists (typically in the [SnapshotState.CREATING] state). This helper polls [getSnapshot] + * until the snapshot becomes ready, fails, or the timeout elapses. + * + * @param snapshotId Unique identifier of the snapshot to wait for + * @param timeout Maximum time to wait for the snapshot to become ready + * @param pollingInterval Time between successive [getSnapshot] polls + * @return The ready [SnapshotInfo] + * @throws SnapshotFailedException if the snapshot reaches the [SnapshotState.FAILED] state + * @throws SandboxReadyTimeoutException if the snapshot is not ready within [timeout] + * @throws InvalidArgumentException if [pollingInterval] is not positive + */ + @JvmOverloads + fun waitForSnapshotReady( + snapshotId: String, + timeout: Duration = Duration.ofMinutes(5), + pollingInterval: Duration = Duration.ofSeconds(2), + ): SnapshotInfo { + if (pollingInterval.isNegative || pollingInterval.isZero) { + throw InvalidArgumentException("Polling interval must be positive, got: $pollingInterval") + } + logger.info("Waiting for snapshot {} to become ready (timeout: {}s)", snapshotId, timeout.seconds) + + val deadline = System.currentTimeMillis() + timeout.toMillis() + var attempt = 0 + while (true) { + attempt++ + val snapshot = getSnapshot(snapshotId) + when (snapshot.status.state) { + SnapshotState.READY -> { + logger.info("Snapshot {} is ready after {} attempts", snapshotId, attempt) + return snapshot + } + SnapshotState.FAILED -> { + val detail = snapshot.status.message ?: snapshot.status.reason ?: "no detail provided" + throw SnapshotFailedException("Snapshot $snapshotId failed: $detail") + } + else -> + logger.debug( + "Snapshot {} not ready yet (state: {}, attempt #{})", + snapshotId, + snapshot.status.state, + attempt, + ) + } + + if (System.currentTimeMillis() + pollingInterval.toMillis() >= deadline) { + throw SandboxReadyTimeoutException( + "Snapshot $snapshotId did not become ready within ${timeout.seconds}s ($attempt attempts)", + ) + } + Thread.sleep(pollingInterval.toMillis()) + } + } + /** * Closes this resource, relinquishing any underlying resources. * diff --git a/sdks/sandbox/kotlin/sandbox/src/main/kotlin/com/alibaba/opensandbox/sandbox/domain/exceptions/SandboxException.kt b/sdks/sandbox/kotlin/sandbox/src/main/kotlin/com/alibaba/opensandbox/sandbox/domain/exceptions/SandboxException.kt index 25092de02..0732f4b66 100644 --- a/sdks/sandbox/kotlin/sandbox/src/main/kotlin/com/alibaba/opensandbox/sandbox/domain/exceptions/SandboxException.kt +++ b/sdks/sandbox/kotlin/sandbox/src/main/kotlin/com/alibaba/opensandbox/sandbox/domain/exceptions/SandboxException.kt @@ -92,6 +92,18 @@ class SandboxReadyTimeoutException( error = SandboxError(SandboxError.READY_TIMEOUT, message), ) +/** + * Thrown when a snapshot reaches the `Failed` state while waiting for it to become ready. + */ +class SnapshotFailedException( + message: String? = null, + cause: Throwable? = null, +) : SandboxException( + message = message, + cause = cause, + error = SandboxError(SandboxError.SNAPSHOT_FAILED, message), + ) + /** * Thrown when an invalid argument is provided to an SDK method. * Similar to [IllegalArgumentException] but within the SDK's exception hierarchy. @@ -180,6 +192,9 @@ data class SandboxError( const val INVALID_ARGUMENT = "INVALID_ARGUMENT" const val UNEXPECTED_RESPONSE = "UNEXPECTED_RESPONSE" + /** A snapshot reached the `Failed` state while waiting for it to become ready. */ + const val SNAPSHOT_FAILED = "SNAPSHOT_FAILED" + /** The requested file or directory does not exist (server responds with HTTP 404). */ const val FILE_NOT_FOUND = "FILE_NOT_FOUND" diff --git a/sdks/sandbox/kotlin/sandbox/src/main/kotlin/com/alibaba/opensandbox/sandbox/domain/models/sandboxes/SandboxModels.kt b/sdks/sandbox/kotlin/sandbox/src/main/kotlin/com/alibaba/opensandbox/sandbox/domain/models/sandboxes/SandboxModels.kt index 69a0ca419..cbc547ba8 100644 --- a/sdks/sandbox/kotlin/sandbox/src/main/kotlin/com/alibaba/opensandbox/sandbox/domain/models/sandboxes/SandboxModels.kt +++ b/sdks/sandbox/kotlin/sandbox/src/main/kotlin/com/alibaba/opensandbox/sandbox/domain/models/sandboxes/SandboxModels.kt @@ -753,6 +753,30 @@ class SandboxCreateResponse( val platform: PlatformSpec? = null, ) +/** + * Lifecycle state of a snapshot. + * + * Common state values: + * - Creating: Snapshot is being captured from the sandbox + * - Ready: Snapshot has been captured and can be used to restore a sandbox + * - Failed: Snapshot capture encountered a critical error + * - Deleting: Snapshot is being deleted + * + * State transitions: + * - Creating → Ready (capture completes successfully) + * - Creating → Failed (on error) + * + * Note: New state values may be added in future versions. + * Clients should handle unknown state values gracefully. + */ +object SnapshotState { + const val CREATING = "Creating" + const val READY = "Ready" + const val FAILED = "Failed" + const val DELETING = "Deleting" + const val UNKNOWN = "Unknown" +} + class SnapshotStatus( val state: String, val reason: String?, diff --git a/sdks/sandbox/kotlin/sandbox/src/test/kotlin/com/alibaba/opensandbox/sandbox/SandboxManagerTest.kt b/sdks/sandbox/kotlin/sandbox/src/test/kotlin/com/alibaba/opensandbox/sandbox/SandboxManagerTest.kt index f2a3210d8..c452fad0a 100644 --- a/sdks/sandbox/kotlin/sandbox/src/test/kotlin/com/alibaba/opensandbox/sandbox/SandboxManagerTest.kt +++ b/sdks/sandbox/kotlin/sandbox/src/test/kotlin/com/alibaba/opensandbox/sandbox/SandboxManagerTest.kt @@ -16,6 +16,9 @@ package com.alibaba.opensandbox.sandbox +import com.alibaba.opensandbox.sandbox.domain.exceptions.InvalidArgumentException +import com.alibaba.opensandbox.sandbox.domain.exceptions.SandboxReadyTimeoutException +import com.alibaba.opensandbox.sandbox.domain.exceptions.SnapshotFailedException import com.alibaba.opensandbox.sandbox.domain.models.diagnostics.DiagnosticContent import com.alibaba.opensandbox.sandbox.domain.models.sandboxes.PagedSandboxInfos import com.alibaba.opensandbox.sandbox.domain.models.sandboxes.PaginationInfo @@ -25,6 +28,9 @@ import com.alibaba.opensandbox.sandbox.domain.models.sandboxes.SandboxInfo import com.alibaba.opensandbox.sandbox.domain.models.sandboxes.SandboxRenewResponse import com.alibaba.opensandbox.sandbox.domain.models.sandboxes.SandboxState import com.alibaba.opensandbox.sandbox.domain.models.sandboxes.SandboxStatus +import com.alibaba.opensandbox.sandbox.domain.models.sandboxes.SnapshotInfo +import com.alibaba.opensandbox.sandbox.domain.models.sandboxes.SnapshotState +import com.alibaba.opensandbox.sandbox.domain.models.sandboxes.SnapshotStatus import com.alibaba.opensandbox.sandbox.domain.services.Diagnostics import com.alibaba.opensandbox.sandbox.domain.services.Sandboxes import io.mockk.Runs @@ -36,6 +42,7 @@ import io.mockk.mockk import io.mockk.verify import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertSame +import org.junit.jupiter.api.Assertions.assertThrows import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test import org.junit.jupiter.api.extension.ExtendWith @@ -190,4 +197,55 @@ class SandboxManagerTest { verify { httpClientProvider.close() } } + + private fun snapshot(state: String): SnapshotInfo = + SnapshotInfo( + id = "snapshot-id", + sandboxId = "sandbox-id", + name = "snap", + status = SnapshotStatus(state = state, reason = null, message = null, lastTransitionAt = null), + createdAt = OffsetDateTime.now(), + ) + + @Test + fun `waitForSnapshotReady returns once the snapshot becomes ready`() { + val sequence = listOf(snapshot(SnapshotState.CREATING), snapshot(SnapshotState.READY)) + var index = 0 + every { sandboxService.getSnapshot("snapshot-id") } answers { sequence[index++] } + + val result = + sandboxManager.waitForSnapshotReady( + "snapshot-id", + Duration.ofSeconds(5), + Duration.ofMillis(10), + ) + + assertEquals(SnapshotState.READY, result.status.state) + verify(exactly = 2) { sandboxService.getSnapshot("snapshot-id") } + } + + @Test + fun `waitForSnapshotReady throws SnapshotFailedException when the snapshot fails`() { + every { sandboxService.getSnapshot("snapshot-id") } returns snapshot(SnapshotState.FAILED) + + assertThrows(SnapshotFailedException::class.java) { + sandboxManager.waitForSnapshotReady("snapshot-id", Duration.ofSeconds(5), Duration.ofMillis(10)) + } + } + + @Test + fun `waitForSnapshotReady throws SandboxReadyTimeoutException when it never becomes ready`() { + every { sandboxService.getSnapshot("snapshot-id") } returns snapshot(SnapshotState.CREATING) + + assertThrows(SandboxReadyTimeoutException::class.java) { + sandboxManager.waitForSnapshotReady("snapshot-id", Duration.ofMillis(30), Duration.ofMillis(10)) + } + } + + @Test + fun `waitForSnapshotReady rejects a non-positive polling interval`() { + assertThrows(InvalidArgumentException::class.java) { + sandboxManager.waitForSnapshotReady("snapshot-id", Duration.ofSeconds(5), Duration.ZERO) + } + } } From 7533540dc6c53c20b4e6f247d3399f57f77a8c76 Mon Sep 17 00:00:00 2001 From: Ferran Pons Serra Date: Sat, 13 Jun 2026 20:02:22 +0200 Subject: [PATCH 2/4] fix(sdks/kotlin): poll snapshot until the real deadline waitForSnapshotReady gave up early when the remaining window was smaller than pollingInterval (and timed out after the first poll when pollingInterval >= timeout). Sleep for min(pollingInterval, remaining) and keep polling until the deadline so a snapshot that becomes Ready within the window is not reported as a false timeout. Co-authored-by: Atenea Agent --- .../opensandbox/sandbox/SandboxManager.kt | 7 +++++-- .../opensandbox/sandbox/SandboxManagerTest.kt | 18 ++++++++++++++++++ 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/sdks/sandbox/kotlin/sandbox/src/main/kotlin/com/alibaba/opensandbox/sandbox/SandboxManager.kt b/sdks/sandbox/kotlin/sandbox/src/main/kotlin/com/alibaba/opensandbox/sandbox/SandboxManager.kt index 663972eca..5aa2759f6 100644 --- a/sdks/sandbox/kotlin/sandbox/src/main/kotlin/com/alibaba/opensandbox/sandbox/SandboxManager.kt +++ b/sdks/sandbox/kotlin/sandbox/src/main/kotlin/com/alibaba/opensandbox/sandbox/SandboxManager.kt @@ -276,12 +276,15 @@ class SandboxManager internal constructor( ) } - if (System.currentTimeMillis() + pollingInterval.toMillis() >= deadline) { + val remaining = deadline - System.currentTimeMillis() + if (remaining <= 0) { throw SandboxReadyTimeoutException( "Snapshot $snapshotId did not become ready within ${timeout.seconds}s ($attempt attempts)", ) } - Thread.sleep(pollingInterval.toMillis()) + // Sleep for at most the remaining window so we keep polling until the real deadline + // instead of giving up early when one interval would overshoot it. + Thread.sleep(minOf(pollingInterval.toMillis(), remaining)) } } diff --git a/sdks/sandbox/kotlin/sandbox/src/test/kotlin/com/alibaba/opensandbox/sandbox/SandboxManagerTest.kt b/sdks/sandbox/kotlin/sandbox/src/test/kotlin/com/alibaba/opensandbox/sandbox/SandboxManagerTest.kt index c452fad0a..8b8264707 100644 --- a/sdks/sandbox/kotlin/sandbox/src/test/kotlin/com/alibaba/opensandbox/sandbox/SandboxManagerTest.kt +++ b/sdks/sandbox/kotlin/sandbox/src/test/kotlin/com/alibaba/opensandbox/sandbox/SandboxManagerTest.kt @@ -248,4 +248,22 @@ class SandboxManagerTest { sandboxManager.waitForSnapshotReady("snapshot-id", Duration.ofSeconds(5), Duration.ZERO) } } + + @Test + fun `waitForSnapshotReady polls until the deadline even when the interval exceeds the timeout`() { + // pollingInterval (1s) is larger than the timeout (200ms): the helper must still wait out + // the window and poll again instead of timing out after the first non-ready response. + val sequence = listOf(snapshot(SnapshotState.CREATING), snapshot(SnapshotState.READY)) + var index = 0 + every { sandboxService.getSnapshot("snapshot-id") } answers { sequence[index++] } + + val result = + sandboxManager.waitForSnapshotReady( + "snapshot-id", + Duration.ofMillis(200), + Duration.ofSeconds(1), + ) + + assertEquals(SnapshotState.READY, result.status.state) + } } From d62d7c7fca1aab13c2255a6ea3fec445308359db Mon Sep 17 00:00:00 2001 From: Ferran Pons Serra Date: Sat, 13 Jun 2026 20:19:51 +0200 Subject: [PATCH 3/4] fix(sdks/kotlin): enforce snapshot deadline before each poll Check the deadline at the top of the wait loop so a snapshot that only turns Ready after the timeout is reported as a SandboxReadyTimeoutException instead of a late success, and never sleep past the deadline. Adds a regression test for the late-ready case and keeps the within-window polling test. Co-authored-by: Atenea Agent --- .../opensandbox/sandbox/SandboxManager.kt | 18 +++++++----- .../opensandbox/sandbox/SandboxManagerTest.kt | 28 +++++++++++++++---- 2 files changed, 34 insertions(+), 12 deletions(-) diff --git a/sdks/sandbox/kotlin/sandbox/src/main/kotlin/com/alibaba/opensandbox/sandbox/SandboxManager.kt b/sdks/sandbox/kotlin/sandbox/src/main/kotlin/com/alibaba/opensandbox/sandbox/SandboxManager.kt index 5aa2759f6..233f16405 100644 --- a/sdks/sandbox/kotlin/sandbox/src/main/kotlin/com/alibaba/opensandbox/sandbox/SandboxManager.kt +++ b/sdks/sandbox/kotlin/sandbox/src/main/kotlin/com/alibaba/opensandbox/sandbox/SandboxManager.kt @@ -256,6 +256,13 @@ class SandboxManager internal constructor( val deadline = System.currentTimeMillis() + timeout.toMillis() var attempt = 0 while (true) { + // Enforce the deadline before each poll so a snapshot that only turns Ready after the + // timeout is reported as a timeout rather than a late success. + if (System.currentTimeMillis() >= deadline) { + throw SandboxReadyTimeoutException( + "Snapshot $snapshotId did not become ready within ${timeout.seconds}s ($attempt attempts)", + ) + } attempt++ val snapshot = getSnapshot(snapshotId) when (snapshot.status.state) { @@ -276,15 +283,12 @@ class SandboxManager internal constructor( ) } + // Sleep for at most the remaining window so we keep polling until the real deadline + // instead of giving up a full interval early, and never sleep past it. val remaining = deadline - System.currentTimeMillis() - if (remaining <= 0) { - throw SandboxReadyTimeoutException( - "Snapshot $snapshotId did not become ready within ${timeout.seconds}s ($attempt attempts)", - ) + if (remaining > 0) { + Thread.sleep(minOf(pollingInterval.toMillis(), remaining)) } - // Sleep for at most the remaining window so we keep polling until the real deadline - // instead of giving up early when one interval would overshoot it. - Thread.sleep(minOf(pollingInterval.toMillis(), remaining)) } } diff --git a/sdks/sandbox/kotlin/sandbox/src/test/kotlin/com/alibaba/opensandbox/sandbox/SandboxManagerTest.kt b/sdks/sandbox/kotlin/sandbox/src/test/kotlin/com/alibaba/opensandbox/sandbox/SandboxManagerTest.kt index 8b8264707..a22c499d4 100644 --- a/sdks/sandbox/kotlin/sandbox/src/test/kotlin/com/alibaba/opensandbox/sandbox/SandboxManagerTest.kt +++ b/sdks/sandbox/kotlin/sandbox/src/test/kotlin/com/alibaba/opensandbox/sandbox/SandboxManagerTest.kt @@ -250,20 +250,38 @@ class SandboxManagerTest { } @Test - fun `waitForSnapshotReady polls until the deadline even when the interval exceeds the timeout`() { - // pollingInterval (1s) is larger than the timeout (200ms): the helper must still wait out - // the window and poll again instead of timing out after the first non-ready response. - val sequence = listOf(snapshot(SnapshotState.CREATING), snapshot(SnapshotState.READY)) + fun `waitForSnapshotReady keeps polling within the window instead of giving up early`() { + // Several non-ready polls within a generous window must not trigger a premature timeout. + val sequence = + listOf( + snapshot(SnapshotState.CREATING), + snapshot(SnapshotState.CREATING), + snapshot(SnapshotState.READY), + ) var index = 0 every { sandboxService.getSnapshot("snapshot-id") } answers { sequence[index++] } val result = sandboxManager.waitForSnapshotReady( "snapshot-id", - Duration.ofMillis(200), Duration.ofSeconds(1), + Duration.ofMillis(20), ) assertEquals(SnapshotState.READY, result.status.state) + verify(exactly = 3) { sandboxService.getSnapshot("snapshot-id") } + } + + @Test + fun `waitForSnapshotReady does not accept a snapshot that turns ready only after the deadline`() { + // The interval (100ms) outlasts the timeout (80ms): after the single sleep the deadline has + // passed, so the late READY must be rejected with a timeout rather than returned as success. + val sequence = listOf(snapshot(SnapshotState.CREATING), snapshot(SnapshotState.READY)) + var index = 0 + every { sandboxService.getSnapshot("snapshot-id") } answers { sequence[index++] } + + assertThrows(SandboxReadyTimeoutException::class.java) { + sandboxManager.waitForSnapshotReady("snapshot-id", Duration.ofMillis(80), Duration.ofMillis(100)) + } } } From fe441b3a4e1a3c47fe784abe245e14e1a93bfe73 Mon Sep 17 00:00:00 2001 From: Ferran Pons Serra Date: Sat, 13 Jun 2026 20:30:12 +0200 Subject: [PATCH 4/4] fix(sdks/kotlin): honor snapshot deadline on slow polls and raise default - Re-check the deadline after getSnapshot returns READY: a poll that starts before the deadline but blocks past it (slow server) now surfaces a SandboxReadyTimeoutException instead of a late success. - Raise the default timeout from 5m to 900s to cover the server's snapshot_create_timeout_seconds (Kubernetes commit jobs may run up to the controller commitJobTimeout, 10m by default), so the default no longer times out while the backend is still within its normal snapshot window. Co-authored-by: Atenea Agent --- .../opensandbox/sandbox/SandboxManager.kt | 13 +++++++++++-- .../opensandbox/sandbox/SandboxManagerTest.kt | 16 ++++++++++++++++ 2 files changed, 27 insertions(+), 2 deletions(-) diff --git a/sdks/sandbox/kotlin/sandbox/src/main/kotlin/com/alibaba/opensandbox/sandbox/SandboxManager.kt b/sdks/sandbox/kotlin/sandbox/src/main/kotlin/com/alibaba/opensandbox/sandbox/SandboxManager.kt index 233f16405..b80589447 100644 --- a/sdks/sandbox/kotlin/sandbox/src/main/kotlin/com/alibaba/opensandbox/sandbox/SandboxManager.kt +++ b/sdks/sandbox/kotlin/sandbox/src/main/kotlin/com/alibaba/opensandbox/sandbox/SandboxManager.kt @@ -235,7 +235,9 @@ class SandboxManager internal constructor( * until the snapshot becomes ready, fails, or the timeout elapses. * * @param snapshotId Unique identifier of the snapshot to wait for - * @param timeout Maximum time to wait for the snapshot to become ready + * @param timeout Maximum time to wait for the snapshot to become ready. Defaults to 900s to + * cover the server's `snapshot_create_timeout_seconds` (Kubernetes deployments may take up to + * the controller `commitJobTimeout`, 10m by default, before a snapshot is Ready or Failed) * @param pollingInterval Time between successive [getSnapshot] polls * @return The ready [SnapshotInfo] * @throws SnapshotFailedException if the snapshot reaches the [SnapshotState.FAILED] state @@ -245,7 +247,7 @@ class SandboxManager internal constructor( @JvmOverloads fun waitForSnapshotReady( snapshotId: String, - timeout: Duration = Duration.ofMinutes(5), + timeout: Duration = Duration.ofSeconds(900), pollingInterval: Duration = Duration.ofSeconds(2), ): SnapshotInfo { if (pollingInterval.isNegative || pollingInterval.isZero) { @@ -267,6 +269,13 @@ class SandboxManager internal constructor( val snapshot = getSnapshot(snapshotId) when (snapshot.status.state) { SnapshotState.READY -> { + // getSnapshot itself may block past the deadline on a slow server; only accept + // READY if we are still within the timeout, otherwise surface a timeout. + if (System.currentTimeMillis() >= deadline) { + throw SandboxReadyTimeoutException( + "Snapshot $snapshotId did not become ready within ${timeout.seconds}s ($attempt attempts)", + ) + } logger.info("Snapshot {} is ready after {} attempts", snapshotId, attempt) return snapshot } diff --git a/sdks/sandbox/kotlin/sandbox/src/test/kotlin/com/alibaba/opensandbox/sandbox/SandboxManagerTest.kt b/sdks/sandbox/kotlin/sandbox/src/test/kotlin/com/alibaba/opensandbox/sandbox/SandboxManagerTest.kt index a22c499d4..f96039ea9 100644 --- a/sdks/sandbox/kotlin/sandbox/src/test/kotlin/com/alibaba/opensandbox/sandbox/SandboxManagerTest.kt +++ b/sdks/sandbox/kotlin/sandbox/src/test/kotlin/com/alibaba/opensandbox/sandbox/SandboxManagerTest.kt @@ -284,4 +284,20 @@ class SandboxManagerTest { sandboxManager.waitForSnapshotReady("snapshot-id", Duration.ofMillis(80), Duration.ofMillis(100)) } } + + @Test + fun `waitForSnapshotReady rejects a READY response that blocks past the deadline`() { + // Each poll blocks ~80ms; with a 100ms timeout the READY response is produced only after the + // deadline elapses, so it must surface as a timeout rather than a late success. + val sequence = listOf(snapshot(SnapshotState.CREATING), snapshot(SnapshotState.READY)) + var index = 0 + every { sandboxService.getSnapshot("snapshot-id") } answers { + Thread.sleep(80) + sequence[index++] + } + + assertThrows(SandboxReadyTimeoutException::class.java) { + sandboxManager.waitForSnapshotReady("snapshot-id", Duration.ofMillis(100), Duration.ofMillis(10)) + } + } }