Skip to content

Commit b0c1c30

Browse files
cppudgeclaude
andcommitted
core: close the container-handle UX gaps (audit group B)
The public umbrella now really covers the API it promises: Network.hpp and Volume.hpp were unreachable from testcontainers.hpp (GenericImage only forward-declares Network), so umbrella-only users could not compile Network::create() / Volume::builder(). Compile-checked by tc_smoke. Container::stop() gains the grace-period parameter stop_container already had (unset = create-time StopTimeout, 0 = kill now, negative = wait indefinitely); new Container::start() restarts a stopped handle through the OWNED client (a custom-daemon container restarts against its own daemon) - a plain daemon start: waits/hooks do not re-run, the once-fired stopping hooks stay fired, ephemeral ports re-bind (live get_host_port() re-resolves; cached module getters keep their values, as their docs already state). Integration test Lifecycle.StopStartRoundTrip covers stop(0), restart, exec-after- restart, and the already-running 304 path. Pipeline: MSVC 559 units, full Linux core integration 135 pass/44 skip, WSL gcc 553 units, clang-tidy and clang-format clean. Opus review: SHIP; both nits applied (PID-1 SIGTERM comment wording). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0141x8KrNdD9NfmCh1vSMbpg
1 parent ce7c6df commit b0c1c30

6 files changed

Lines changed: 59 additions & 6 deletions

File tree

docs/feature-notes.md

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -135,7 +135,14 @@ composition over `GenericImage`.
135135
`with_startup_attempts(n)` (the whole create→start→wait retried, each failed partial removed;
136136
the reuse-adopt path is not retried; no inter-attempt backoff). A throwing created/starting/
137137
started hook aborts start() and cleans up; stopping fires once on teardown — never for a
138-
persistent (reuse) handle — and its exceptions are swallowed.
138+
persistent (reuse) handle — and its exceptions are swallowed. `Container::stop(timeout_secs)`
139+
(2026-07-12) forwards the daemon grace period (unset = the container's create-time
140+
StopTimeout, default 10s; 0 = kill now; negative = wait indefinitely), and
141+
`Container::start()` restarts a stopped handle through the OWNED client (so a
142+
custom-daemon container restarts against its own daemon) — a plain daemon start: waits and
143+
hooks do not re-run, the once-fired stopping hooks stay fired, and ephemeral published
144+
ports are re-bound (re-resolve via `get_host_port()`; a module's Started* getters keep
145+
their start()-time values).
139146

140147
**Reaper (Ryuk)** — containers, networks, named volumes, and built images carry the session
141148
label; compose stacks are covered by an extra per-project filter registered over the same

docs/public-api-test-coverage.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -160,7 +160,8 @@ on Windows only the inline-Dockerfile + build-error round-trip is exercised.
160160
| `read_file(path)` ||[b] | ✅ Copy.ReadFileRoundTrip, Copy.LargeFileRoundTrip, Copy.ReadFileRejectsDirectory | ✅ WindowsCopy.ReadFileRoundTrip, WindowsCopy.LargeFileRoundTrip, WindowsCopy.ReadFileRejectsDirectory |
161161
| `copy_file_from(path, host)` ||[b] | ✅ Copy.CopyFileFromWritesHost | ✅ WindowsCopy.CopyFileFromWritesHost |
162162
| `resize_tty(size)` ||| ✅ Tty.ResizeTtyChangesWindowSize | ❌ (ConPTY resize untested; Linux path covers the client-side wire) |
163-
| `stop()` ||| ✅ Lifecycle.StoppingHookFiresOnStop ||
163+
| `stop(timeout_secs)` ||| ✅ Lifecycle.StoppingHookFiresOnStop, Lifecycle.StopStartRoundTrip (explicit zero grace) ||
164+
| `start()` (restart a stopped handle) ||| ✅ Lifecycle.StopStartRoundTrip (incl. the already-running 304 path) ||
164165
| `is_running()` ||| ✅ RedisMvp, WaitStrategies.* | ✅ WindowsContainer.ExecRunsInRunningContainer |
165166
| `keep(bool)` ||| ✅ Lifecycle.KeepLeavesContainerRunning (+ unit Runner.KeepSkipsRemovalOnDrop, Runner.KeepFalseRearmsRemovalOnDrop) | ❌ (client-side flag; engine-independent) |
166167
| `inspect(id)` (static) ||| ✅ Lifecycle.StaticInspectById (found + NotFoundError) | ❌ (same code path as `inspect_container`) |

include/testcontainers/Container.hpp

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
#include <chrono>
44
#include <cstdint>
55
#include <filesystem>
6+
#include <optional>
67
#include <string>
78
#include <utility>
89
#include <vector>
@@ -211,8 +212,21 @@ class Container {
211212

212213
/// Stop the container (an auto-removing handle still removes it on
213214
/// destruction; a persistent handle — `with_reuse` / after `keep()` —
214-
/// does not).
215-
void stop();
215+
/// does not). `timeout_secs` is the grace period before the daemon kills
216+
/// the process: unset uses the container's create-time StopTimeout
217+
/// (default 10s), 0 kills immediately, negative waits indefinitely.
218+
/// Stopping hooks fire once, before the stop.
219+
void stop(std::optional<int> timeout_secs = std::nullopt);
220+
221+
/// Start the container again after a `stop()` (`POST
222+
/// /containers/{id}/start`; already-running is accepted). A plain
223+
/// daemon-side start: the request's wait strategies and lifecycle hooks
224+
/// do NOT re-run, and the stopping hooks — fired once by the earlier
225+
/// `stop()` — stay fired. The daemon re-binds ephemeral published ports
226+
/// on start: re-resolve them through `get_host_port()` (a module's
227+
/// Started* getters keep their original values). Throws DockerError on
228+
/// failure.
229+
void start();
216230

217231
/// Whether the container is currently running (per a fresh inspect).
218232
bool is_running() const;

include/testcontainers/testcontainers.hpp

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,9 @@
1010
#include "testcontainers/Error.hpp"
1111
#include "testcontainers/GenericBuildableImage.hpp"
1212
#include "testcontainers/GenericImage.hpp"
13+
#include "testcontainers/Network.hpp"
1314
#include "testcontainers/Ulimit.hpp"
15+
#include "testcontainers/Volume.hpp"
1416
#include "testcontainers/WaitFor.hpp"
1517
#include "testcontainers/docker/DockerClient.hpp"
1618
#include "testcontainers/docker/DockerHost.hpp"

src/Container.cpp

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -161,11 +161,17 @@ void Container::copy_file_from(const std::string& container_path,
161161
}
162162
}
163163

164-
void Container::stop() {
164+
void Container::stop(std::optional<int> timeout_secs) {
165165
// Explicit stop is a teardown point: fire the stopping hooks (once) before
166166
// the container is stopped.
167167
fire_stopping();
168-
client_.stop_container(id_);
168+
client_.stop_container(id_, timeout_secs);
169+
}
170+
171+
void Container::start() {
172+
// Through the OWNED client on purpose: a container run against a custom
173+
// daemon (run(client, request)) restarts against that same daemon.
174+
client_.start_container(id_);
169175
}
170176

171177
bool Container::is_running() const { return client_.inspect_container(id_).running; }

tests/integration/LifecycleTest.cpp

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77

88
#include "testcontainers/Container.hpp"
99
#include "testcontainers/Error.hpp"
10+
#include "testcontainers/ExecResult.hpp"
1011
#include "testcontainers/GenericImage.hpp"
1112
#include "testcontainers/Lifecycle.hpp"
1213
#include "testcontainers/WaitFor.hpp"
@@ -20,6 +21,7 @@
2021
// Lifecycle.HooksFireInOrder - created/starting/started hooks fire once, in that order, each seeing the live container id.
2122
// Lifecycle.StoppingHookFiresOnStop - a stopping hook fires when the container is explicitly stopped.
2223
// Lifecycle.StartupRetriesOnFailure - with_startup_attempts(2) retries the whole create→start→wait on failure, creating a fresh container each attempt.
24+
// Lifecycle.StopStartRoundTrip - stop(0) (explicit zero grace) stops the container, start() brings the same container back (a plain daemon start; exec works again), and a second start() is accepted as already-running.
2325
// Lifecycle.KeepLeavesContainerRunning - keep() releases removal ownership: after the handle drops, the container is still running (verified and cleaned up via an adopted RemoveOnDrop handle).
2426
// Lifecycle.StaticInspectById - the static Container::inspect(id) reads a running container's state without a handle and throws NotFoundError for an id that does not exist.
2527
// WindowsLifecycle.HooksFireInOrder - the same hook ordering against a Windows daemon (the hooks are client-side, but each leg drives real Windows-engine create/start calls).
@@ -104,6 +106,27 @@ TEST_F(Lifecycle, StartupRetriesOnFailure) {
104106
EXPECT_EQ(created_count, 2);
105107
}
106108

109+
TEST_F(Lifecycle, StopStartRoundTrip) {
110+
Container c = GenericImage::from_reference(kImage).with_cmd({"sleep", "60"}).start();
111+
112+
// Explicit zero grace: the container's sleep (PID 1, no SIGTERM handler)
113+
// would burn the full grace period before the daemon's SIGKILL — zero
114+
// grace just skips that wait.
115+
c.stop(0);
116+
EXPECT_FALSE(c.is_running());
117+
118+
// A plain daemon start on the SAME container: no waits or hooks re-run.
119+
c.start();
120+
EXPECT_TRUE(c.is_running());
121+
// exec proves the restarted container is live, not merely flagged running.
122+
const ExecResult res = c.exec({"echo", "back"});
123+
EXPECT_EQ(res.exit_code, 0);
124+
EXPECT_EQ(res.stdout_data, "back\n");
125+
126+
c.start(); // already running: the daemon's 304 is accepted, not an error
127+
EXPECT_TRUE(c.is_running());
128+
}
129+
107130
TEST_F(Lifecycle, KeepLeavesContainerRunning) {
108131
// keep() releases removal ownership: the handle's drop leaves the container
109132
// running, exactly like a with_reuse handle would.

0 commit comments

Comments
 (0)