Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 15 additions & 3 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,9 +60,21 @@ whenever a target framework, a backend package or the `buildTransitive` assets c
- Never swallow caller cancellation as a successful exec/stop. Startup is a total five-minute managed
budget; exec defaults to one minute, stop to 15 seconds and cleanup to 30 seconds. Synchronous WSLC
native calls cannot be safely interrupted; never advertise hard zero-hang guarantees.
- Reuse is explicit, named and incompatible with auto-remove. Docker follows Testcontainers;
WSLC reuse is session-scoped. Explicit deletion bypasses reuse retention. Dispose containers before
managed networks or built images. CLI diagnostics must not echo environment/build output.
- Disposal cleanup mirrors Testcontainers (`WithCleanUp`, default `true`): disposing removes the
container, and Docker also removes anonymous volumes; named `WithVolumeMount` volumes are retained.
`WithCleanUp(false)` stops and retains the container. Auto-remove is off by default, is independent
of cleanup, and wins once the init process exits.
- WSLC named volumes are session-scoped but persist in the session's storage VHD. Named volumes on both
backends are retained by default; `ContainerCleanup.CleanUpVolumesOnExit` (or
`PURVIEW_CONTAINERS_CLEAN_VOLUMES=true`) deletes the volumes the backend created at process/session
termination, after removing the containers that used them.
`WslContainerRuntimeOptions.CleanUpVolumesOnTerminate` overrides the shared switch;
`IContainerBackend.CleanUpAsync` is the explicit path. Never delete the shared image store or host
bind mounts.
- Reuse is explicit, named, incompatible with auto-remove, and implies cleanup off. Docker follows
Testcontainers; WSLC reuse is session-scoped. Explicit deletion bypasses reuse retention. Dispose
containers before managed networks or built images. CLI diagnostics must not echo environment/build
output.
- Restart an exited WSLC reusable container through the CLI **before** reopening its SDK event handle.
Calling native `Start()` after attaching that handle fails with "Process handle has already been attached".
- Docker pre-start resource archives run inside Testcontainers' synchronous `Created` event, before init
Expand Down
9 changes: 7 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,9 @@ It requires no Docker installation. The Docker backend is built on
Both backends expose file copy/read, one-shot exit waits, managed bridge networks/aliases and
Dockerfile builds through the same contract. Startup has a five-minute total budget; exec defaults
to one minute, stop to 15 seconds and cleanup to 30 seconds, all configurable. Cancellation is
propagated; synchronous native WSLC calls cannot be forcibly interrupted. See
propagated; synchronous native WSLC calls cannot be forcibly interrupted. Disposal follows
Testcontainers' `WithCleanUp`: containers are removed by default, `.WithCleanUp(false)` retains them
for inspection, and `.WithAutoRemove()` is opted into separately. See
[Capabilities and Cancellation](docs/wiki/Backend-Capabilities.md) for APIs, ownership and limits.

> **Experimental.** This project is an experiment in running throwaway containers through Microsoft
Expand All @@ -41,7 +43,10 @@ propagated; synchronous native WSLC calls cannot be forcibly interrupted. See
> by default** (`StorageMode.Shared`): sessions reuse a stable image store
> (`%LOCALAPPDATA%\Purview\WslContainers\images`) so images are pulled once, not per session;
> `StorageMode.PerSession` provides isolation. Move the store with `PURVIEW_CONTAINERS_STORAGE_PATH` or
> `WslContainerRuntimeOptions.StoragePath`. Runnable samples live in `samples/getting-started`.
> `WslContainerRuntimeOptions.StoragePath`. Named volumes are retained by default; set
> `PURVIEW_CONTAINERS_CLEAN_VOLUMES=true` (or `ContainerCleanup.CleanUpVolumesOnExit`) to delete the
> volumes the library created when the process or session ends. Runnable samples live in
> `samples/getting-started`.

## Prerequisites

Expand Down
23 changes: 20 additions & 3 deletions docs/wiki/Backend-Capabilities.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ do not preserve an image's default command.
| Managed bridge networks and aliases | `CreateNetworkAsync`, `WithNetwork`, `WithNetworkAliases` | CLI network and container creation; alias DNS |
| Dockerfile builds | `BuildImageAsync` | CLI build in the owning SDK session/store |
| Reuse | `WithName(...).WithReuse()` | Same configuration/name within the owning session |
| Disposal cleanup | `WithCleanUp(bool)` (default `true`) | Stop then force-delete; `false` stops and retains the container |
| Explicit deletion | `DeleteAsync(ct)` | Force-delete, including reusable containers |

Use absolute Linux destinations for copy/mapping operations. A file source names the destination
Expand Down Expand Up @@ -111,12 +112,28 @@ Use unique names for independently owned resources.

## Reuse and platform limits

Reuse is opt-in, requires a stable explicit name, and cannot be combined with auto-remove.
`DisposeAsync` no longer promises deletion for a reusable container. Call `DeleteAsync(ct)` to
remove it deliberately.
Reuse is opt-in, requires a stable explicit name, cannot be combined with auto-remove, and implies
cleanup off (mirroring Testcontainers). `WithReuse().WithCleanUp(true)` is rejected at build time.
`DisposeAsync` no longer promises deletion for a reusable container; it stops and retains it. Call
`DeleteAsync(ct)` to remove it deliberately.
Delete before disposing the wrapper; to remove a retained container later, reopen it with matching
reuse settings and delete through that wrapper.

Disposal cleanup is on by default (`WithCleanUp(true)`). It removes the container, and the Docker
backend also removes the container's anonymous volumes (`RemoveVolumes = true`, as Testcontainers
does). Named `WithVolumeMount` volumes are shared data and are retained on both backends; WSLC
named volumes are recorded in the session's storage VHD and do not disappear automatically when the
session ends. `WithCleanUp(false)` stops the container and keeps it (and its volumes) for inspection,
exactly like Testcontainers' `WithCleanUp(false)`; remove it with `DeleteAsync` before disposal or
with the backend's tooling.

WSLC named volumes are retained across sessions by default, matching Docker. A test run that must
leave no volume state behind can set `ContainerCleanup.CleanUpVolumesOnExit = true`
(or `PURVIEW_CONTAINERS_CLEAN_VOLUMES=true`). Session termination then deletes the containers it
recorded and their named volumes; the shared image store and host bind mounts are never deleted.
`WslContainerRuntimeOptions.CleanUpVolumesOnTerminate` overrides the shared switch for one runtime, and
`IContainerBackend.CleanUpAsync()` performs the same cleanup explicitly.

Docker delegates reuse to Testcontainers: it survives wrapper/process lifetimes and opts out of
normal resource-reaper cleanup. WSLC reuse lasts only as long as its owning session, checks the
requested configuration fingerprint before reopening a named container, and is not cross-process
Expand Down
17 changes: 14 additions & 3 deletions docs/wiki/Backends.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,8 @@ Service modules (`Purview.Containers.PostgreSql`, `Redis`, …) are backend-neut
| **How containers run** | the managed SDK, with `wslc` CLI fallback for SDK-missing features | the Docker Engine API via Testcontainers |
| **Images** | a shared store (`%LOCALAPPDATA%\Purview\WslContainers\images`) reused across runs; the location is configurable | the daemon's own image store |
| **Leak protection** | session disposal plus a process-exit hook | the Testcontainers resource reaper (Ryuk) |
| **Disposal cleanup** | `WithCleanUp` (default `true`) stops and deletes the container | `WithCleanUp` (default `true`) delegates to Testcontainers, which also removes anonymous volumes |
| **Volume cleanup** | opt-in via `ContainerCleanup.CleanUpVolumesOnExit` / `PURVIEW_CONTAINERS_CLEAN_VOLUMES`; `CleanUpVolumesOnTerminate` overrides per runtime | opt-in via the same shared switch; removes volumes the backend created |
| **Check the host** | `wsl --version`, `wslc version` | `docker info` |
| **Typical fit** | local Windows development without Docker Desktop, fastest cold start | CI runners, non-Windows hosts, teams already running Docker |

Expand Down Expand Up @@ -104,9 +106,16 @@ ContainerBackends.Use(new WslContainerBackend(WslContainerRuntimeOptions.Default
`StorageMode.Shared` (the default) keeps a stable, warm image store at `StoragePath` (or the default above
when it is unset); `StorageMode.PerSession` gives each session a throwaway store under
`%LOCALAPPDATA%\Purview\WslContainers\sessions\{name}`. The full `WslContainerRuntimeOptions` set (CPU,
memory, GPU, session name, `StoragePath`, `StorageMode`, timeout) is honoured on both a platform-neutral
`net10.0` consumer — where the facade forwards it to the Windows build at run time — and a Windows target
framework. The pre-rename `WSL_CONTAINERS_STORAGE_PATH` is still honoured as a fallback.
memory, GPU, session name, `StoragePath`, `StorageMode`, timeout, `CleanUpVolumesOnTerminate`) is honoured
on both a platform-neutral `net10.0` consumer — where the facade forwards it to the Windows build at run
time — and a Windows target framework. The pre-rename `WSL_CONTAINERS_STORAGE_PATH` is still honoured as a
fallback.

WSLC named volumes are retained by default, matching Docker. Set `ContainerCleanup.CleanUpVolumesOnExit = true`
(or `PURVIEW_CONTAINERS_CLEAN_VOLUMES=true`) to delete the volumes the library created when the process
exits or the session terminates; `WslContainerRuntimeOptions.CleanUpVolumesOnTerminate` overrides the shared
switch for one runtime, and `CleanUpAsync` performs the cleanup explicitly. Containers are deleted first;
the shared image store and host bind mounts are untouched.

## Switch between them without touching test code:

Expand Down Expand Up @@ -461,6 +470,8 @@ release its image-store VHD.
| Reuse | session-scoped, explicit name | Testcontainers reuse |
| Log tailing | polled until the container stops | polled until the container stops |
| Leak protection | session disposal + process-exit hook | Testcontainers resource reaper (Ryuk) |
| Disposal cleanup | `WithCleanUp`, default `true` | `WithCleanUp`, default `true`, anonymous volumes removed |
| Volume cleanup | opt-in via `ContainerCleanup.CleanUpVolumesOnExit` (session override available) | opt-in via the same shared switch |
| Image store | shared WSLC store, warm across runs | the daemon's store |
| Lifetime cost | one process-wide session, cheap start | first pull per image, per daemon |

Expand Down
15 changes: 14 additions & 1 deletion docs/wiki/Lifecycle.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,12 +25,25 @@ WSLC `ContainerState`: `Invalid → Created → Running → Exited → Deleted`.
| `StartAsync` | Creates the session if needed, ensures image (per pull policy), `CreateContainer`, then `Container.Start()`. A missing init binary throws `ArgumentException 0x80070057` with the OCI message → surfaced as `WslContainerStartupException`. |
| `StopAsync` | Signal the init process with SIGTERM, allow 10 seconds to exit, then send SIGKILL and wait up to 5 seconds. Managed polling avoids the native blocking `Container.Stop` call and does not hold the session gate during the grace period. **Idempotent.** |
| `DeleteAsync` | `Container.Delete(Force)`. Double-delete throws `0x80010108` (`RPC_E_DISCONNECTED`) → swallowed. |
| `DisposeAsync` | Normally stop (if running), delete and release handles. Reusable containers are retained. **Idempotent.** |
| `DisposeAsync` | Normally stop (if running), delete and release handles. Reusable containers and `WithCleanUp(false)` containers are stopped and retained. **Idempotent.** |

## Cleanup guarantees

- Use `await using` so caller failures always reach `DisposeAsync`. The runtime process-exit hook
owns session cleanup; it is not a substitute for disposing each container.
- `EnableCleanUp` defaults to `true`, mirroring Testcontainers' `WithCleanUp`: `DisposeAsync` stops and
removes the container. Docker also removes the container's anonymous volumes; named
`WithVolumeMount` volumes are shared data and are retained (WSLC volumes belong to the session).
Set `.WithCleanUp(false)` to stop and retain the container (and its volumes) for inspection, then
remove it deliberately with `DeleteAsync` before disposal (or with the backend's own tooling).
`WithReuse()` implies cleanup off; `WithReuse().WithCleanUp(true)` is rejected at build time.
- WSLC named volumes live on the session and are retained by default, matching Docker. Set
`ContainerCleanup.CleanUpVolumesOnExit = true`, or `PURVIEW_CONTAINERS_CLEAN_VOLUMES=true` before the
first container is created, to delete every volume the session recorded when it terminates. That is the
session-wide, test-run switch: it removes the session's containers first and then those volumes, while
leaving the shared image store intact. `WslContainerRuntimeOptions.CleanUpVolumesOnTerminate` overrides
the shared switch (`null` inherits it), and `IContainerBackend.CleanUpAsync()` performs the cleanup
explicitly.
- `EnableAutoRemove` defaults to `false`: the library owns deletion via `DisposeAsync`. WSLC auto-removal would delete one-shot containers before their state/output could be inspected. Opt in with `.WithAutoRemove()` when desired.
- Partial startup failure (image pull / create / start / wait): the container is torn down via the same `DisposeAsync` path.
- Cancellation: a cancelled `StartAsync`/wait triggers the same teardown.
Expand Down
21 changes: 21 additions & 0 deletions docs/wiki/Testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,27 @@ reaper and the 4 GB WSLC session VM from being stampeded on a cold run.
In Visual Studio, untick **Run Tests in Parallel** (or set *Maximum Parallel Test Projects* to 1) before
running the WSLC integration suites.

## Cleaning up named volumes

Named volumes are retained by default on both backends, matching Docker/Testcontainers. A suite that must
leave no volume state behind can turn on the shared switch before the first container is created:

```bash
PURVIEW_CONTAINERS_CLEAN_VOLUMES=true dotnet test ...
```

```powershell
$env:PURVIEW_CONTAINERS_CLEAN_VOLUMES = "true"
dotnet test ...
```

The active backend then deletes the containers it recorded and the named volumes it created when the
process exits or its session terminates (including from the process-exit hook). Volumes that already
existed are never deleted, and the shared image store and host bind mounts are untouched. Set
`ContainerCleanup.CleanUpVolumesOnExit` in code instead if you prefer, or call
`ContainerBackends.CleanUpAsync()` / `IContainerBackend.CleanUpAsync()` for an explicit cleanup. On WSLC,
`WslContainerRuntimeOptions.CleanUpVolumesOnTerminate` can override the shared switch for one runtime.

## Running the WSLC suites in CI

The WSLC half used to be manual-only because no hosted runner was assumed to have WSL Containers. That is
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "containers",
"version": "1.0.0-prerelease.10",
"version": "1.0.0-prerelease.11",
"description": "Testcontainers-style library for .NET that runs throwaway Linux containers for integration testing, on Microsoft WSL Containers or Docker.",
"license": "MIT",
"author": {
Expand Down
17 changes: 15 additions & 2 deletions skills/purview-containers-lifecycle/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,9 +44,22 @@ These are the ones that matter when a test throws:

- Use `await using` so failures run `DisposeAsync`. The process-exit hook owns session cleanup,
not a per-container disposal registry.
- `EnableCleanUp` defaults to **`true`** (Testcontainers' `WithCleanUp`): `DisposeAsync` stops and
deletes the container; Docker also removes its anonymous volumes. Named `WithVolumeMount` volumes
are shared data and survive (WSLC volumes live on the session). `.WithCleanUp(false)` stops the
container and keeps it — and its volumes — for inspection; remove it with `DeleteAsync` before
disposal, or with the backend's own tooling.
- `EnableAutoRemove` defaults to **`false`**: the library owns deletion. WSLC auto-removal would
delete a one-shot container before its state or output could be inspected — precisely when you need
them. Opt in with `.WithAutoRemove()`.
them. Opt in with `.WithAutoRemove()`. It is independent of cleanup and wins when the init process
exits.
- `WithReuse()` implies cleanup off, and `WithReuse().WithCleanUp(true)` is rejected at build time.
- Named volumes are **retained by default**, matching Docker. Set
`ContainerCleanup.CleanUpVolumesOnExit = true`, or `PURVIEW_CONTAINERS_CLEAN_VOLUMES=true` before the
first container is created, to delete the volumes the active backend created when the process exits or
its session terminates. `ContainerBackends.CleanUpAsync()` / `IContainerBackend.CleanUpAsync()` does it
explicitly; pre-existing volumes, the shared image store and host bind mounts are never touched. On WSLC,
`WslContainerRuntimeOptions.CleanUpVolumesOnTerminate` overrides the shared switch.
- A **partial startup failure** — image pull, create, start or wait — tears down through the same
`DisposeAsync` path. So does a **cancelled** `StartAsync` or wait.
- Builder-produced containers serialize lifecycle operations; disposal cancels an in-flight startup
Expand Down Expand Up @@ -155,7 +168,7 @@ Readiness exhaustion throws `ContainerTimeoutException`. Live log tails need a c
Native synchronous WSLC calls cannot be interrupted safely. Managed waits and CLI processes are
bounded, but do not claim hard zero-hang guarantees for a blocked native runtime.
`Wait.ForExitCode(expected)` handles one-shot jobs; `WaitForExitAsync(ct)` returns the actual code.
`WithName(...).WithReuse()` retains a named container on disposal; `DeleteAsync(ct)` explicitly removes
`WithName(...).WithReuse()` retains a named container on disposal, with cleanup implied off; `DeleteAsync(ct)` explicitly removes
it. WSLC reuse ends with the owning session; Docker follows Testcontainers reuse semantics.

## Related skills
Expand Down
16 changes: 16 additions & 0 deletions src/src/Core/ContainerBackends.cs
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,22 @@ public static void Reset()
}
}

/// <summary>
/// Best-effort cleanup of resources owned by every registered backend, such as the named volumes they
/// created. Each backend's <see cref="IContainerBackend.CleanUpAsync" /> is called in registration order.
/// Disposing the containers that use those resources first is the caller's responsibility; a backend may
/// force-remove its own retained containers when <see cref="ContainerCleanup.CleanUpVolumesOnExit" /> is
/// enabled.
/// </summary>
public static async Task CleanUpAsync(CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
foreach (var backend in All)
{
await backend.CleanUpAsync(cancellationToken).ConfigureAwait(false);
}
}

static async Task<IContainerBackend> ResolveCoreAsync(
IContainerBackend? pinned,
ContainerBackendSelection selection,
Expand Down
Loading
Loading