Skip to content

Commit 7e64c9a

Browse files
fix(server): give docker wait a timeout the container can outlive (#1482)
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 0c20702 commit 7e64c9a

8 files changed

Lines changed: 131 additions & 19 deletions

File tree

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
---
2+
---
3+
4+
No user-facing or operator-facing effect. Waiting for an agent container to exit shared a Docker
5+
connection with ordinary requests, and that connection carried a socket timeout — so a review still
6+
working after thirty minutes had its wait torn down and was reported as failed. The wait now runs on
7+
its own connection whose timeout sits above the longest run a sandbox is allowed, which cannot cut a
8+
legitimate wait short but still reclaims the connection if the daemon stops answering. None of this
9+
reached a release: agent sandboxes have never shipped, so there is no version an operator can be
10+
upgrading from where a long review failed this way.

server/src/main/java/de/tum/cit/aet/hephaestus/agent/sandbox/docker/DockerClientOperations.java

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
import de.tum.cit.aet.hephaestus.agent.sandbox.spi.SandboxInfrastructureException;
1818
import java.io.InputStream;
1919
import java.nio.charset.StandardCharsets;
20+
import java.time.Duration;
2021
import java.time.Instant;
2122
import java.util.List;
2223
import java.util.Map;
@@ -37,6 +38,9 @@ public class DockerClientOperations
3738
private static final Logger log = LoggerFactory.getLogger(DockerClientOperations.class);
3839
private static final int LOG_COLLECTION_TIMEOUT_SECONDS = 30;
3940

41+
/** A pull that has not finished by here is treated as failed; the RPC client's idle timeout sits above it. */
42+
static final Duration IMAGE_PULL_TIMEOUT = Duration.ofMinutes(5);
43+
4044
/**
4145
* Maximum log output to collect from a container (prevents OOM from runaway output). This is
4246
* the <em>collection</em> limit; see {@link DockerSandboxAdapter#MAX_LOG_EVENT_BYTES} for the
@@ -46,8 +50,12 @@ public class DockerClientOperations
4650

4751
private final DockerClient dockerClient;
4852

49-
public DockerClientOperations(DockerClient dockerClient) {
53+
/** `docker wait` is silent until the container exits, so it needs a socket timeout no RPC call wants. */
54+
private final DockerClient streamingClient;
55+
56+
public DockerClientOperations(DockerClient dockerClient, DockerClient streamingClient) {
5057
this.dockerClient = dockerClient;
58+
this.streamingClient = streamingClient;
5159
}
5260

5361
@Override
@@ -83,10 +91,13 @@ public boolean pullImage(String image) {
8391
try {
8492
log.info("Pulling Docker image: {}", image);
8593
long startMs = System.currentTimeMillis();
86-
boolean completed = dockerClient.pullImageCmd(image).start().awaitCompletion(5, TimeUnit.MINUTES);
94+
boolean completed = dockerClient
95+
.pullImageCmd(image)
96+
.start()
97+
.awaitCompletion(IMAGE_PULL_TIMEOUT.toMillis(), TimeUnit.MILLISECONDS);
8798
long durationMs = System.currentTimeMillis() - startMs;
8899
if (!completed) {
89-
log.warn("Docker pull timed out after 5 min: image={}", image);
100+
log.warn("Docker pull timed out after {}: image={}", IMAGE_PULL_TIMEOUT, image);
90101
return false;
91102
}
92103
log.info("Pulled Docker image in {} ms: {}", durationMs, image);
@@ -249,7 +260,7 @@ public void startContainer(String containerId) {
249260
@Override
250261
public DockerOperations.WaitResult waitContainer(String containerId) {
251262
try {
252-
var callback = dockerClient.waitContainerCmd(containerId).start();
263+
var callback = streamingClient.waitContainerCmd(containerId).start();
253264
try {
254265
int exitCode = callback.awaitStatusCode();
255266
log.debug("Container {} exited with code {}", containerId, exitCode);

server/src/main/java/de/tum/cit/aet/hephaestus/agent/sandbox/docker/DockerSandboxConfiguration.java

Lines changed: 57 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
import de.tum.cit.aet.hephaestus.agent.sandbox.docker.interactive.InteractiveSandboxRegistry;
1515
import de.tum.cit.aet.hephaestus.agent.sandbox.docker.interactive.StdinWriteWatchdog;
1616
import de.tum.cit.aet.hephaestus.agent.sandbox.spi.InteractiveSandboxService;
17+
import de.tum.cit.aet.hephaestus.agent.sandbox.spi.ResourceLimits;
1718
import de.tum.cit.aet.hephaestus.agent.sandbox.spi.SandboxException;
1819
import de.tum.cit.aet.hephaestus.agent.sandbox.spi.SandboxManager;
1920
import de.tum.cit.aet.hephaestus.core.runtime.RuntimeRole;
@@ -27,6 +28,7 @@
2728
import java.util.concurrent.ThreadPoolExecutor;
2829
import org.slf4j.Logger;
2930
import org.slf4j.LoggerFactory;
31+
import org.springframework.beans.factory.annotation.Qualifier;
3032
import org.springframework.beans.factory.annotation.Value;
3133
import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
3234
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
@@ -55,16 +57,55 @@ public class DockerSandboxConfiguration {
5557

5658
private static final Logger log = LoggerFactory.getLogger(DockerSandboxConfiguration.class);
5759

58-
/** Connections per container: create/start, wait, logs/copy. */
59-
private static final int CONNECTIONS_PER_CONTAINER = 3;
60+
/** RPC connections per container: create/start, logs, and a copy-out lease held while it is read. */
61+
private static final int RPC_CONNECTIONS_PER_CONTAINER = 3;
6062

6163
private static final Duration HTTP_CONNECTION_TIMEOUT = Duration.ofSeconds(5);
6264

63-
/** docker wait/logs can block for the full container lifetime. */
64-
private static final Duration HTTP_RESPONSE_TIMEOUT = Duration.ofMinutes(30);
65+
/**
66+
* Idle timeout, not a deadline: Apache installs responseTimeout as the socket timeout for the
67+
* whole exchange, so it bounds the gap between reads and a call that keeps producing bytes runs
68+
* as long as it likes. Every RPC call carries its own budget, so this only has to reclaim the
69+
* connection when the daemon goes silent — generous enough that a slow image layer or a large
70+
* archive upload never trips it.
71+
*/
72+
static final Duration HTTP_RESPONSE_TIMEOUT = Duration.ofMinutes(30);
73+
74+
/**
75+
* `docker wait` sends nothing until the container exits, so for it the idle timeout is a ceiling
76+
* on the container's life. Sitting above {@link ResourceLimits#MAX_RUNTIME} it can never cut a
77+
* legitimate wait short, while still reclaiming a connection the daemon has abandoned —
78+
* docker-java's reader thread only ever gets an interrupt, which a blocking read ignores.
79+
*/
80+
static final Duration HTTP_STREAMING_RESPONSE_TIMEOUT = ResourceLimits.MAX_RUNTIME.plusMinutes(10);
81+
82+
/** Calls whose response body is the stream. One wait per container, and nothing else. */
83+
@Bean(name = "dockerStreamingClient", destroyMethod = "close")
84+
public DockerClient dockerStreamingClient(SandboxProperties properties) {
85+
return buildClient(
86+
properties,
87+
HTTP_STREAMING_RESPONSE_TIMEOUT,
88+
properties.maxConcurrentContainers(),
89+
"streaming"
90+
);
91+
}
6592

6693
@Bean(destroyMethod = "close")
6794
public DockerClient dockerClient(SandboxProperties properties) {
95+
return buildClient(
96+
properties,
97+
HTTP_RESPONSE_TIMEOUT,
98+
properties.maxConcurrentContainers() * RPC_CONNECTIONS_PER_CONTAINER,
99+
"rpc"
100+
);
101+
}
102+
103+
private DockerClient buildClient(
104+
SandboxProperties properties,
105+
Duration responseTimeout,
106+
int maxConnections,
107+
String kind
108+
) {
68109
var configBuilder = DefaultDockerClientConfig.createDefaultConfigBuilder()
69110
.withDockerHost(properties.dockerHost())
70111
.withDockerTlsVerify(properties.tlsVerify());
@@ -78,24 +119,30 @@ public DockerClient dockerClient(SandboxProperties properties) {
78119
var httpClient = new ApacheDockerHttpClient.Builder()
79120
.dockerHost(config.getDockerHost())
80121
.sslConfig(config.getSSLConfig())
81-
.maxConnections(properties.maxConcurrentContainers() * CONNECTIONS_PER_CONTAINER)
122+
.maxConnections(maxConnections)
82123
.connectionTimeout(HTTP_CONNECTION_TIMEOUT)
83-
.responseTimeout(HTTP_RESPONSE_TIMEOUT)
124+
.responseTimeout(responseTimeout)
84125
.build();
85126

86127
DockerClient client = DockerClientImpl.getInstance(config, httpClient);
87128
log.info(
88-
"Docker sandbox client configured: host={}, tlsVerify={}",
129+
"Docker sandbox client configured: kind={}, host={}, tlsVerify={}, responseTimeout={}, maxConnections={}",
130+
kind,
89131
properties.dockerHost(),
90-
properties.tlsVerify()
132+
properties.tlsVerify(),
133+
responseTimeout,
134+
maxConnections
91135
);
92136

93137
return client;
94138
}
95139

96140
@Bean
97-
public DockerClientOperations dockerClientOperations(DockerClient dockerClient) {
98-
return new DockerClientOperations(dockerClient);
141+
public DockerClientOperations dockerClientOperations(
142+
DockerClient dockerClient,
143+
@Qualifier("dockerStreamingClient") DockerClient dockerStreamingClient
144+
) {
145+
return new DockerClientOperations(dockerClient, dockerStreamingClient);
99146
}
100147

101148
@Bean

server/src/test/java/de/tum/cit/aet/hephaestus/agent/sandbox/docker/DockerClientOperationsTest.java

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
import static org.mockito.Mockito.mock;
1010
import static org.mockito.Mockito.never;
1111
import static org.mockito.Mockito.verify;
12+
import static org.mockito.Mockito.verifyNoInteractions;
1213
import static org.mockito.Mockito.when;
1314

1415
import com.github.dockerjava.api.DockerClient;
@@ -52,11 +53,14 @@ class DockerClientOperationsTest extends BaseUnitTest {
5253
@Mock
5354
private DockerClient dockerClient;
5455

56+
@Mock
57+
private DockerClient streamingClient;
58+
5559
private DockerClientOperations ops;
5660

5761
@BeforeEach
5862
void setUp() {
59-
ops = new DockerClientOperations(dockerClient);
63+
ops = new DockerClientOperations(dockerClient, streamingClient);
6064
}
6165

6266
@Nested
@@ -498,14 +502,27 @@ class WaitContainer {
498502
void shouldReturnExitCode() {
499503
WaitContainerCmd cmd = mock(WaitContainerCmd.class);
500504
WaitContainerResultCallback callback = mock(WaitContainerResultCallback.class);
501-
when(dockerClient.waitContainerCmd("ctr-1")).thenReturn(cmd);
505+
when(streamingClient.waitContainerCmd("ctr-1")).thenReturn(cmd);
502506
when(cmd.start()).thenReturn(callback);
503507
when(callback.awaitStatusCode()).thenReturn(42);
504508

505509
DockerOperations.WaitResult result = ops.waitContainer("ctr-1");
506510

507511
assertThat(result.exitCode()).isEqualTo(42);
508512
}
513+
514+
@Test
515+
void shouldNotUseTheRpcClientWhenWaitingForAContainer() {
516+
WaitContainerCmd cmd = mock(WaitContainerCmd.class);
517+
WaitContainerResultCallback callback = mock(WaitContainerResultCallback.class);
518+
when(streamingClient.waitContainerCmd("ctr-1")).thenReturn(cmd);
519+
when(cmd.start()).thenReturn(callback);
520+
when(callback.awaitStatusCode()).thenReturn(0);
521+
522+
ops.waitContainer("ctr-1");
523+
524+
verifyNoInteractions(dockerClient);
525+
}
509526
}
510527

511528
@Nested
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
package de.tum.cit.aet.hephaestus.agent.sandbox.docker;
2+
3+
import static org.assertj.core.api.Assertions.assertThat;
4+
5+
import de.tum.cit.aet.hephaestus.agent.sandbox.spi.ResourceLimits;
6+
import de.tum.cit.aet.hephaestus.testconfig.BaseUnitTest;
7+
import org.junit.jupiter.api.Test;
8+
9+
class DockerSandboxConfigurationTest extends BaseUnitTest {
10+
11+
@Test
12+
void shouldOutliveTheLongestSandboxWhenWaitingForAContainerToExit() {
13+
// `docker wait` is silent until the container exits, so this timeout is a ceiling on the
14+
// container's life. At or below MAX_RUNTIME it would cut a legitimate wait short.
15+
assertThat(DockerSandboxConfiguration.HTTP_STREAMING_RESPONSE_TIMEOUT).isGreaterThan(
16+
ResourceLimits.MAX_RUNTIME
17+
);
18+
}
19+
20+
@Test
21+
void shouldGiveOrdinaryRequestsMoreThanTheLongestBudgetedCallCanTake() {
22+
// pullImage budgets itself 5 minutes; a shorter idle timeout would pre-empt that budget.
23+
assertThat(DockerSandboxConfiguration.HTTP_RESPONSE_TIMEOUT).isGreaterThan(
24+
DockerClientOperations.IMAGE_PULL_TIMEOUT
25+
);
26+
}
27+
}

server/src/test/java/de/tum/cit/aet/hephaestus/agent/sandbox/docker/DockerSandboxLiveTest.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,7 @@ void setUp() {
7878
new ApacheDockerHttpClient.Builder().dockerHost(URI.create("unix:///var/run/docker.sock")).build()
7979
);
8080

81-
dockerOps = new DockerClientOperations(dockerClient);
81+
dockerOps = new DockerClientOperations(dockerClient, dockerClient);
8282
dockerWaitExecutor = Executors.newCachedThreadPool();
8383
containerManager = new SandboxContainerManager(dockerOps, image -> {}, properties, dockerWaitExecutor);
8484
networkManager = new SandboxNetworkManager(dockerOps, properties);

server/src/test/java/de/tum/cit/aet/hephaestus/agent/sandbox/docker/RepositoryTreeStagingLiveTest.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -98,7 +98,7 @@ void setUp() {
9898
DefaultDockerClientConfig.createDefaultConfigBuilder().build(),
9999
new ApacheDockerHttpClient.Builder().dockerHost(URI.create("unix:///var/run/docker.sock")).build()
100100
);
101-
DockerClientOperations dockerOps = new DockerClientOperations(dockerClient);
101+
DockerClientOperations dockerOps = new DockerClientOperations(dockerClient, dockerClient);
102102
dockerWaitExecutor = Executors.newCachedThreadPool();
103103
containerManager = new SandboxContainerManager(dockerOps, image -> {}, properties, dockerWaitExecutor);
104104
networkManager = new SandboxNetworkManager(dockerOps, properties);

server/src/test/java/de/tum/cit/aet/hephaestus/agent/sandbox/docker/interactive/DockerInteractiveSandboxLiveTest.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -124,7 +124,7 @@ void setUp() throws Exception {
124124
new ApacheDockerHttpClient.Builder().dockerHost(URI.create("unix:///var/run/docker.sock")).build()
125125
);
126126

127-
dockerOps = new DockerClientOperations(dockerClient);
127+
dockerOps = new DockerClientOperations(dockerClient, dockerClient);
128128
dockerWaitExecutor = Executors.newCachedThreadPool();
129129
containerManager = new SandboxContainerManager(dockerOps, image -> {}, sandboxProperties, dockerWaitExecutor);
130130
networkManager = new SandboxNetworkManager(dockerOps, sandboxProperties);

0 commit comments

Comments
 (0)