diff --git a/CHANGELOG.md b/CHANGELOG.md
index c79ac48..dbeac51 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -22,6 +22,8 @@ This project follows [Semantic Versioning](https://semver.org/).
### Fixed
+- Service-level `on-failure:N` restart policies preserve the Docker retry limit and reject malformed suffixes with contextual validation errors.
+
- `WaitAsync` collects container exit codes without concurrent dictionary writes and chooses its aggregate code deterministically by ordinal container ID.
- `ListAsync` keeps projects with paused or restarting containers in the default listing, following Docker's non-`All` container results.
- `StartAsync` reports Docker start failures and excludes retained one-off containers from dependency starts and readiness checks.
diff --git a/README.md b/README.md
index 20006d0..2a32fe0 100644
--- a/README.md
+++ b/README.md
@@ -107,6 +107,8 @@ The loader can retain fields that the engine does not yet apply. Consult the [Co
## What the engine does today
+`restart: on-failure:N` applies the retry limit to Docker. Counts must be between 0 and 2147483647; zero or omission means unlimited retries. Malformed suffixes are rejected with source, service, and property diagnostics.
+
| Area | Current behavior |
| --- | --- |
| Project lifecycle | Creates project networks, creates/starts/removes labeled containers, lists project containers, removes project networks and optional volumes, and builds selected services through Docker Engine. |
diff --git a/README.zh-CN.md b/README.zh-CN.md
index de93a7e..04c342c 100644
--- a/README.zh-CN.md
+++ b/README.zh-CN.md
@@ -83,6 +83,8 @@ builder.Services.AddComposeSharp();
## 当前能力与边界
+`restart: on-failure:N` 会将重试次数传给 Docker。次数范围为 0 到 2147483647;零或省略次数表示无限重试。非法后缀会被拒绝,错误包含源文件、服务和属性信息。
+
| 能力 | 当前实现 |
| --- | --- |
| 项目生命周期 | 创建项目网络,创建/启动/删除带标签的容器,列举项目容器,按需删除网络和卷,并通过 Docker Engine 构建已选择的服务。 |
diff --git a/docs/compose-field-matrix.md b/docs/compose-field-matrix.md
index 991268a..3391215 100644
--- a/docs/compose-field-matrix.md
+++ b/docs/compose-field-matrix.md
@@ -26,7 +26,7 @@ ComposeSharp is an in-process SDK, not a Docker Compose CLI replacement. The loa
| `environment`, `env_file` | `Environment`, `EnvFile` | Partial | Inline environment and values read from scalar or list-of-string `env_file` entries are passed to the container, and their original paths are retained for inspection. Long `env_file` syntax and advanced Compose environment semantics are not implemented. |
| `ports` | `Ports` | Partial | Short `HOST:CONTAINER` string syntax creates a port binding when each container port has one mapping. Container-only entries expose the port but do not create a host binding. Multiple mappings for one container port, long syntax, host IP, ranges, and other per-port options are not modeled. |
| `volumes` | `Volumes` | Partial | Short POSIX bind and named-volume strings are resolved and passed as binds. Long syntax, Windows drive-letter binds, and top-level volume driver/options are not applied. |
-| `restart` | `Restart`, `RestartMaxRetries` | Partial | Docker restart policy name is applied. An `on-failure` retry count is parsed but not sent. |
+| `restart` | `Restart`, `RestartMaxRetries` | Applied | Docker restart policy name and `on-failure:N` retry count are applied. Counts range from 0 to 2147483647; zero or omission means unlimited retries. Malformed suffixes are rejected with source/service/property diagnostics. Verified by `CreateAsync_AppliesRestartRetryCount` and focused loader/mapping tests. |
| `healthcheck` | `Healthcheck` | Partial | Disable flag, list-form tests, supported interval/timeout/start-period values, and retries are sent in the create-container request. `start_interval` is not modeled. Durations accept .NET `TimeSpan` syntax or a single whole-number `ms`, `s`, `m`, or `h` unit; fractional and multi-component Compose durations are not accepted. A scalar `test` command is not converted to `CMD-SHELL`. |
| `depends_on` | `DependsOn` | Partial | Selected services include their transitive dependencies and start in stable topological order; missing services and cycles fail with diagnostics. A dependency with an effective Docker health check, including an image-inherited check, must become healthy before its dependents start; otherwise it must be running. Readiness waits continue until ready, failure, cancellation, or the optional `DependencyWaitTimeoutSeconds` limit. Long-form conditions are not retained, and targeted lifecycle operations still do not start unrelated services. |
| `networks` | `Networks` | Partial | The first declared service network becomes the container network mode. Per-network configuration and multi-network attachment are not applied. When a service declares no network and the project has custom networks, it attaches to the first custom network rather than an implicit default network. |
diff --git a/docs/roadmap.md b/docs/roadmap.md
index abb7394..d6a3168 100644
--- a/docs/roadmap.md
+++ b/docs/roadmap.md
@@ -39,6 +39,8 @@ Full Compose Specification parity in one release. Unsupported or partially appli
### Implemented contracts
+Service-level `restart: on-failure:N` now preserves the Docker retry limit and rejects malformed suffixes with loader diagnostics; zero or omission means unlimited retries.
+
`GenerateAsync` now returns a normalized YAML snapshot of the loader's retained model alongside its summary. It supports explicit service selection and an output project name, without Docker inspection or file writes. This is a subset model snapshot, not complete Compose CLI configuration output; see the [generation and watch contract](generate-watch.md).
`WatchAsync` now continuously monitors selected build contexts together, reports changes for each associated service, and propagates cancellation and watcher failures. It remains a notification API: starting, rebuilding, synchronizing, pruning, and interpreting `develop.watch` are outside its contract.
diff --git a/src/ComposeSharp.Engine/Internal/ContainerLifecycle.cs b/src/ComposeSharp.Engine/Internal/ContainerLifecycle.cs
index a80cdaa..f77b6df 100644
--- a/src/ComposeSharp.Engine/Internal/ContainerLifecycle.cs
+++ b/src/ComposeSharp.Engine/Internal/ContainerLifecycle.cs
@@ -479,11 +479,11 @@ private HostConfig BuildHostConfig(string projectName, ComposeProject project, S
return result.Count == 0 ? null : result;
}
- private static RestartPolicy? BuildRestartPolicy(string? restart)
+ internal static RestartPolicy? BuildRestartPolicy(string? restart)
{
if (string.IsNullOrWhiteSpace(restart)) return null;
- // "on-failure:N" splits off the max-retry suffix; only the policy kind is mapped here.
- var normalized = restart.Split(':').First();
+ var parts = restart.Split(':');
+ var normalized = parts[0];
var kind = normalized.ToLowerInvariant() switch
{
"always" => RestartPolicyKind.Always,
@@ -492,7 +492,12 @@ private HostConfig BuildHostConfig(string projectName, ComposeProject project, S
"no" => RestartPolicyKind.No,
_ => RestartPolicyKind.Undefined
};
- return new RestartPolicy { Name = kind };
+ var retries = 0;
+ if (parts.Length > 1 && (kind != RestartPolicyKind.OnFailure || parts.Length != 2 ||
+ !int.TryParse(parts[1], System.Globalization.NumberStyles.None,
+ System.Globalization.CultureInfo.InvariantCulture, out retries)))
+ throw new ArgumentException("Restart retry count must use on-failure:N with N between 0 and 2147483647.", nameof(restart));
+ return new RestartPolicy { Name = kind, MaximumRetryCount = retries };
}
private static HealthConfig? BuildHealthcheck(ComposeHealthcheck? healthcheck)
diff --git a/src/ComposeSharp.Loader/ComposeFileLoader.cs b/src/ComposeSharp.Loader/ComposeFileLoader.cs
index aeba902..f499758 100644
--- a/src/ComposeSharp.Loader/ComposeFileLoader.cs
+++ b/src/ComposeSharp.Loader/ComposeFileLoader.cs
@@ -471,6 +471,16 @@ private static void ValidateService(
// Stage 1: presence rules (image/build) and top-level field shapes.
ValidateOptionalScalar(service, "image", $"{path}.image", context);
ValidateBuild(service, path, context);
+ ValidateOptionalScalar(service, "restart", $"{path}.restart", context);
+ var restart = GetString(service, "restart");
+ if (restart?.Contains(':') == true)
+ {
+ var parts = restart.Split(':');
+ if (parts.Length != 2 || !parts[0].Equals("on-failure", StringComparison.OrdinalIgnoreCase) ||
+ !int.TryParse(parts[1], NumberStyles.None, CultureInfo.InvariantCulture, out _))
+ throw Validation(context, $"{path}.restart",
+ "restart retry count must use 'on-failure:N' with N between 0 and 2147483647.");
+ }
var hasImage = TryGetValue(service, "image", out var image) && image is not null;
var hasBuild = TryGetValue(service, "build", out var build) && build is not null;
@@ -1138,7 +1148,7 @@ private ServiceDefinition ParseService(
OomKillDisable: GetBool(map, "oom_kill_disable") ?? false ? 1L : null,
OomScoreAdj: GetString(map, "oom_score_adj"),
GroupAdd: GetStringList(map, "group_add"),
- // `restart: on-failure:3` style keeps the retry count separately; the engine does not send it yet.
+ // Keep the suffix for callers inspecting the loaded model; the engine applies Restart verbatim.
RestartMaxRetries: GetString(map, "restart")?.Contains(":") == true
? GetString(map, "restart")!.Split(':').Last()
: null,
diff --git a/src/ComposeSharp.Loader/Models/ComposeService.cs b/src/ComposeSharp.Loader/Models/ComposeService.cs
index 8d5f3a2..c0c19c5 100644
--- a/src/ComposeSharp.Loader/Models/ComposeService.cs
+++ b/src/ComposeSharp.Loader/Models/ComposeService.cs
@@ -19,7 +19,7 @@ namespace ComposeSharp.Loader.Models;
/// - Develop: watch configuration is not interpreted.
/// - EnvFile: original entries kept for inspection; their values are already folded into Environment.
/// - StopSignal/StopGracePeriod/Sysctls/Logging/Platform/PullPolicy/Dns/DnsSearch/Links/CpuQuota/Annotations/OomScoreAdj: parsed only, not sent to Docker.
-/// - RestartMaxRetries: retry count stripped from an on-failure:N restart value; parsed but not sent.
+/// - RestartMaxRetries: retry count stripped from an on-failure:N restart value; the engine applies the count from Restart. Zero or omission means unlimited retries; counts must be between 0 and 2147483647.
/// - OomKillDisable: retained as 1 only when the YAML value is true; an explicit false is indistinguishable from omission.
///
///
diff --git a/tests/ComposeSharp.IntegrationTests/ComposeServiceIntegrationTests.cs b/tests/ComposeSharp.IntegrationTests/ComposeServiceIntegrationTests.cs
index 3b9a506..5131964 100644
--- a/tests/ComposeSharp.IntegrationTests/ComposeServiceIntegrationTests.cs
+++ b/tests/ComposeSharp.IntegrationTests/ComposeServiceIntegrationTests.cs
@@ -31,6 +31,48 @@ public class ComposeServiceIntegrationTests
private static readonly SemaphoreSlim ImageGate = new(1, 1);
private static bool _busyBoxReady;
+ [Theory]
+ [InlineData("on-failure", 0)]
+ [InlineData("on-failure:5", 5)]
+ public async Task CreateAsync_AppliesRestartRetryCount(string restart, int retries)
+ {
+ if (!DockerAvailable) return;
+ await EnsureBusyBoxAsync();
+ var projectName = $"restart-retries-{Guid.NewGuid():N}";
+ var directory = Path.Combine(Path.GetTempPath(), projectName);
+ Directory.CreateDirectory(directory);
+ File.WriteAllText(Path.Combine(directory, "compose.yaml"),
+ $"services:\n app:\n image: busybox:1.36\n restart: '{restart}'\n");
+ var context = new ComposeProjectContext
+ {
+ ProjectName = projectName,
+ WorkingDirectory = directory,
+ ComposeFileName = "compose.yaml"
+ };
+ var service = new ComposeService();
+ try
+ {
+ await service.CreateAsync(context);
+ using var client = new DockerClientFactory().CreateClient();
+ var containers = await client.Containers.ListContainersAsync(new ContainersListParameters
+ {
+ All = true,
+ Filters = new Dictionary>
+ {
+ ["label"] = new Dictionary { [$"com.docker.compose.project={projectName}"] = true }
+ }
+ });
+ var container = await client.Containers.InspectContainerAsync(Assert.Single(containers).ID);
+ Assert.Equal(RestartPolicyKind.OnFailure, container.HostConfig.RestartPolicy.Name);
+ Assert.Equal(retries, container.HostConfig.RestartPolicy.MaximumRetryCount);
+ }
+ finally
+ {
+ try { await service.DownAsync(context); }
+ finally { Directory.Delete(directory, recursive: true); }
+ }
+ }
+
// Probes `docker info` once per test class; a missing CLI or unreachable daemon disables the Docker cases.
private static bool CheckDockerAvailable()
{
diff --git a/tests/ComposeSharp.Tests/ComposeLoaderValidationTests.cs b/tests/ComposeSharp.Tests/ComposeLoaderValidationTests.cs
index bbc0bde..4c0627b 100644
--- a/tests/ComposeSharp.Tests/ComposeLoaderValidationTests.cs
+++ b/tests/ComposeSharp.Tests/ComposeLoaderValidationTests.cs
@@ -245,6 +245,35 @@ public void LoadMerged_InvalidAppendedListItem_ReportsOverlaySource()
exception, files.OverlayPath!, "api", "services.api.ports[1]", "short port mapping");
}
+ [Theory]
+ [InlineData("on-failure:")]
+ [InlineData("on-failure:-1")]
+ [InlineData("on-failure:+1")]
+ [InlineData("on-failure: 5")]
+ [InlineData("on-failure:1.5")]
+ [InlineData("on-failure:abc")]
+ [InlineData("on-failure:2147483648")]
+ [InlineData("on-failure:5:6")]
+ [InlineData("always:5")]
+ public void Load_InvalidRestartSuffix_ReportsFullContext(string restart)
+ {
+ using var files = new ComposeFiles($"services:\n api:\n image: app\n restart: '{restart}'\n");
+ var exception = Assert.Throws(() => files.Load());
+ AssertServiceDiagnostic(exception, files.PrimaryPath, "api", "services.api.restart", "on-failure:N");
+ }
+
+ [Theory]
+ [InlineData("on-failure")]
+ [InlineData("on-failure:0")]
+ [InlineData("on-failure:5")]
+ [InlineData("on-failure:2147483647")]
+ public void Load_ValidRestartSuffix_PreservesValue(string restart)
+ {
+ using var files = new ComposeFiles($"services:\n api:\n image: app\n restart: '{restart}'\n");
+ var project = new ComposeFileLoader().Load(files.DirectoryPath, "compose.yaml");
+ Assert.Equal(restart, Assert.Single(project.Services).Restart);
+ }
+
// Asserts project-level diagnostics carry the source file and root property path but no service name.
private static void AssertProjectDiagnostic(
ComposeValidationException exception,
diff --git a/tests/ComposeSharp.Tests/ContainerLifecycleTests.cs b/tests/ComposeSharp.Tests/ContainerLifecycleTests.cs
index c7c66a3..cf7c62f 100644
--- a/tests/ComposeSharp.Tests/ContainerLifecycleTests.cs
+++ b/tests/ComposeSharp.Tests/ContainerLifecycleTests.cs
@@ -10,6 +10,37 @@ namespace ComposeSharp.Tests;
public class ContainerLifecycleTests
{
+ [Theory]
+ [InlineData("on-failure", RestartPolicyKind.OnFailure, 0)]
+ [InlineData("on-failure:5", RestartPolicyKind.OnFailure, 5)]
+ [InlineData("on-failure:0", RestartPolicyKind.OnFailure, 0)]
+ [InlineData("on-failure:2147483647", RestartPolicyKind.OnFailure, int.MaxValue)]
+ [InlineData("always", RestartPolicyKind.Always, 0)]
+ [InlineData("unless-stopped", RestartPolicyKind.UnlessStopped, 0)]
+ [InlineData("no", RestartPolicyKind.No, 0)]
+ public void BuildRestartPolicy_PreservesRetryCount(string restart, RestartPolicyKind kind, int retries)
+ {
+ var policy = ContainerLifecycle.BuildRestartPolicy(restart);
+ Assert.NotNull(policy);
+ Assert.Equal(kind, policy.Name);
+ Assert.Equal(retries, policy.MaximumRetryCount);
+ }
+
+ [Theory]
+ [InlineData("on-failure:")]
+ [InlineData("on-failure:-1")]
+ [InlineData("on-failure:+1")]
+ [InlineData("on-failure: 5")]
+ [InlineData("on-failure:1.5")]
+ [InlineData("on-failure:abc")]
+ [InlineData("on-failure:2147483648")]
+ [InlineData("on-failure:5:6")]
+ [InlineData("always:5")]
+ public void BuildRestartPolicy_RejectsInvalidSuffix(string restart)
+ {
+ Assert.Throws(() => ContainerLifecycle.BuildRestartPolicy(restart));
+ }
+
[Fact]
public void FindByContainerNumber_UsesLabelInsteadOfListPosition()
{