Skip to content

Model Runner (local LLMs) + review remediation#346

Open
mariotoffia wants to merge 161 commits into
masterfrom
featrure/model-support
Open

Model Runner (local LLMs) + review remediation#346
mariotoffia wants to merge 161 commits into
masterfrom
featrure/model-support

Conversation

@mariotoffia

Copy link
Copy Markdown
Owner

First-class Docker Model Runner support (local LLMs) — and any OpenAI-compatible runner — behind the existing Builder → WithinDriver → UseModelRunner() pattern, plus the full review-remediation pass.

See CHANGELOG.md ([3.2.0] - Unreleased) and docs/model-runner.md for the complete feature, docs/model-runner-plugins.md for non-Docker runner plugins, and docs/model-runner-compose.md for Compose models:.

Highlights

  • IModelRunner façade over three hexagonal ports (management/runtime via docker model CLI; inference via OpenAI-compatible HTTP on :12434 with SSE streaming), ergonomic ChatAsync/ChatStreamAsync/EmbedAsync, ModelReference/LlamaCppRuntimeFlags value objects, managed IModelService, WithModel(...) container wiring, Compose models: overlay, and GenericOpenAiModelRunner for any OpenAI endpoint.

Review remediation (latest)

Correctness

  • BUG-1: non-streaming inference preserves EndpointUnreachable (MIN_003) on transport failure (was downgraded to RequestFailed).
  • BUG-2: EmbeddingsRequest deep-copied before send.
  • ROB-1: bounded (≤64 KiB) inference error-body read.
  • ROB-2: ModelReference registry host lowercased at parse so equal refs serialize identically.
  • ROB-3: Windows mTLS client cert re-imported with a persisted key (SChannel rejects ephemeral keys); non-Windows unchanged.

Extensibility

  • E2 (source-breaking, preview): rename DockerApiModelInferenceDriverOpenAiModelInferenceDriver, relocated to FluentDocker.Drivers.Models (generic OpenAI-over-HTTP, not Docker-specific).
  • E1: inference backend is driver-sourced via new optional IModelBackendInfo (Docker reports llama.cpp; custom packs report own/none; GenericOpenAiModelRunner reports none) instead of a hardcoded literal.
  • E3: kernel-backed runner throws clean NotSupportedException for partial packs instead of InterfaceNotSupportedException.

Tests — driver transport-failure mapping, SSE split-frame reassembly, completion mid-stream fault, concurrent ChatAsync, inference-only pack capabilities + NotSupported, mTLS handshake, CLI-injection quoting.

Docs — split model-runner.md into Compose + plugin pages, plugin-authoring guide, README "What's New" with a multi-turn chat demo.

Breaking changes

  • DockerApiModelInferenceDriverOpenAiModelInferenceDriver (namespace …Docker.Api.Components…Drivers.Models). Source-breaking only; done while the subsystem is preview/unreleased.

Verification

  • Build net8.0 + net10.0 — 0 errors.
  • Unit (net10) — 3815 passed, 0 failed, 3 skipped (net8 compiles; executed in CI).
  • Lint (dotnet format whitespace+style) — clean.
  • Coverage — line 80.66% / branch 73.58% (floors 70/65).
  • Integration — DMR 9/9 and model integration 9/9 against Docker 29.5.3 / DMR v1.2.1.

mariotoffia and others added 30 commits June 23, 2026 09:54
- Introduced real DMR captures for model management and inference, including JSON outputs for model listing, inspection, and error handling.
- Added detailed documentation for Docker Model Runner (DMR), covering management, inference, capabilities, and integration with Docker Compose.
- Included examples for quick start, managing models, and wiring models into containers.
… support with local LLMs, including management, chat, and embeddings functionalities.
feat: Introduce new error code for command execution failures

refactor: Remove ModelBackend struct as it is no longer needed

fix: Update ModelPruneResult to include raw output for unparsed results

fix: Adjust ModelRunnerEndpoint to preserve path-bearing URLs

refactor: Change Backend property in ModelConfigureOptions to string for flexibility

refactor: Simplify ModelPackageRequest by removing unnecessary Labels property

refactor: Clean up ModelRunOptions by removing Backend and IgnoreRuntimeMemoryCheck properties

test: Add security tests for ComposeModelBuilder to prevent YAML injection

test: Ensure compatibility of WithExtraHost as a default interface method

test: Verify cancellation propagation during Docker API model inference

test: Validate Docker CLI streaming command execution and exit handling

test: Implement tests for ModelApiPaths to ensure safe URL path generation

test: Add tests for ModelTlsValidation to enforce strict hostname verification

feat: Implement ModelApiPaths for safe URL path construction in Docker API

feat: Create ModelTlsValidation for custom root CA validation in TLS connections
…stances; introduce shallow copy constructors for ChatCompletionRequest and CompletionRequest. Enhance DockerApiConnection and ModelApiConnection to manage X509 certificates properly, ensuring they are disposed correctly. Update Dispose methods across services to avoid sync-over-async deadlocks by dispatching to the thread pool. Improve documentation for Docker Model Runner, marking it as a preview feature and clarifying usage patterns. Adjust README and getting-started documentation to reflect changes in async method usage and model runner integration.
- Updated README.md to clarify Docker Model Runner availability and versioning.
- Modified getting-started.md to include context size requirement for DMR v1.2.1.
- Updated model-runner.md to reflect async usage and correct example links.
- Added unit tests for Docker CLI argument quoting to ensure proper argument handling.
- Expanded coverage tests for ModelRunnerService, including store and inference methods.
- Introduced MockDriverPack enhancements for better test coverage of model management.
- Added validation for environment variable names used in model-runner builders.
- Implemented `ModelApiTlsHandshakeTests` to validate TLS handshake behavior with self-signed certificates and custom CA trust.
- Created `InferenceModelIdTests` to ensure correct handling of inference model IDs without injecting default tags.
- Introduced `ModelEnvVarsCollection` to manage environment variable tests sequentially, preventing race conditions.
- Added command execution logic in `DockerCliDriverBase` for improved process management and output handling.
- Developed `InferenceDtoCopy` utility for deep copying inference request DTOs via JSON serialization.
- Established `InferenceModelId` struct to represent raw model IDs accurately without automatic tag injection.
- Added a script for enforcing coverage thresholds post-test execution to maintain code quality.
… sub-plans)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…n on push

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…meMismatch (A3)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…not corrupted (B7)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…h (B8)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…eld) (B10)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…body instead of throwing (C11)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… (C12)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…mbiguous rows (C14)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…s (D19)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ode (D20)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… format

Pre-existing tests in DockerCliComposeDriverTests and DockerCliContainerDriverTests
expected the old (incorrect) double-backslash behavior for interior path backslashes.
The B7 CommandLineToArgvW fix correctly preserves single interior backslashes; update
assertions to match the correct output. Also apply `dotnet format whitespace` fixes
to four source files flagged by make lint.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Finding 1 (C12): Wire StreamReadIdleTimeout into the PRODUCTION SSE read
  loop in DockerApiModelInferenceDriver.ReadBoundedLineAsync — previously the
  timeout was stored in ModelApiConnection but never consulted during streaming.
  Now ReadBoundedLineAsync accepts TimeSpan? idleTimeout and wraps each
  per-character read with a linked CTS when set. Behavior is opt-in: null =
  wait indefinitely (caller cancellation only), matching prior behavior.

- Finding 4 (C12): Change idle-timeout exception from StreamParseError to
  EndpointUnreachable — a stalled remote is an unreachable endpoint, not a
  parse error.

- Finding 2 (B8): Add NonStreaming_OversizedOutput_KillsChildProcessTree_
  ReturnsFailed to DockerCliProgressStreamingTests: creates a sentinel file
  before overflowing stdout; asserts Success=false, ExitCode=-1, and that
  the sentinel was never cleaned up (proving Kill(entireProcessTree:true)
  fired before the cleanup step ran).

- Finding 3 (B7): Add 4th InlineData to QuotesWindowsPathsWithoutCorruption:
  input "a ends\" (space triggers quoting, trailing backslash) must produce
  "a ends\\" — trailing backslash doubled before the closing quote.

- Remove dead ReadWithIdleTimeoutAsync from ModelApiConnection (superseded by
  the driver-level wiring); add StreamReadIdleTimeout property to
  IModelApiConnection + ModelApiConnection + MockModelApiConnection; add
  StallingStream and SetupStreamStalling to MockModelApiConnection for the
  new idle-timeout tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…in FixedStreamConnection

Interface gained StreamReadIdleTimeout (C12); the benchmark stub must implement it
or FluentDocker.Benchmarks fails to compile.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…IComposeModelBuilder (A2,D17)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ve Docker coupling from Services (D15,D16)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
mariotoffia and others added 30 commits July 11, 2026 07:09
…t throw

TrySysCtl's doc claimed it could throw InterfaceNotSupportedException from a
"fallback resolver" that no longer exists; TrySysCtl only ever returns false
for unsupported interfaces (pinned by
Chunk2KernelProdReadinessTests.TrySysCtl_WhenPackSysCtlThrows_WrapsFaultAsDriverException).
Removed the stale tag. Also documented that GetCapabilitiesAsync/IsHealthyAsync
throw InvalidOperationException when no default driver is configured and no
driverId is passed (confirmed via ResolveDriverIdOrDefault). Doc-only.
…tion

A pack or plain driver whose TryResolve reports success but hands back an
instance that isn't assignable to the requested interface was silently
swallowed: the pack path logged at Debug only, the plain-driver path logged
nothing at all, and both surfaced as a cause-less InterfaceNotSupportedException
claiming the driver "does not implement" the interface even though it's in
GetSupportedInterfaces(). That hides a mis-mapped registration
(Drivers[typeof(IContainerDriver)] = imageDriver) from on-call.

Both paths now log at Warning (parity with LogPackFallbackFailure), and
TryResolveCore reports the mismatched actual type back to SysCtl, which
throws InterfaceNotSupportedException via a new constructor overload whose
message names both the actual resolved type and the requested interface.
TrySysCtl's false-for-unsupported contract is unchanged; only SysCtl's thrown
message differs. Adds a new InterfaceNotSupportedException(driverId,
interfaceName, actualType) overload.

FluentDockerKernel.cs was at the 500-line limit, so the resolution-only
helpers (TryResolveCore, LogTypeMismatch, LogPackFallbackFailure,
IsResolutionContractException, CreateResolutionFailureException) move to a
new FluentDockerKernel.Resolution.cs partial, mirroring the existing
DriverRegistry / DriverRegistry.Helpers.cs split.

TDD coverage added for both the pack path (reusing the existing
WrongTypeDriverPack fixture) and the plain-driver path (new
WrongTypeResolverDriver fixture), asserting both the exception message and
the Warning-level log line via a small in-file recording ILoggerFactory.
…ption

The K-M4 change rewrote LogTypeMismatch to dereference resolved.GetType()
without the null guard the pre-existing code had. A pack/driver TryResolve
returning true with a null implementation (IsInstanceOfType(null) is false)
then hit LogTypeMismatch(..., null) -> NullReferenceException, which
TryResolveCore's catch-all promoted to DriverException -- breaking TrySysCtl's
never-throws-for-unsupported contract and worsening diagnostics vs before.

Guard resolved != null at both the pack and plain-driver call sites so a null
"success" falls through to the generic InterfaceNotSupportedException (soft:
TrySysCtl -> false), never NRE/DriverException. Adds TDD coverage for both
paths (pack: new NullResolvingDriverPack; driver: WrongTypeResolverDriver
parametrized with the resolved instance).

Also (MINOR consistency): give the kernel-level FluentDockerKernel.IsDisposeComplete
the same "completed pass != nothing abandoned; also check AbandonedDriverCount"
caveat the K-M2 pass added to IDriverRegistry.IsDisposeComplete one hop away.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… growth

ModelOperationGate held one SemaphoreSlim per distinct model key forever
(GetOrAdd, never removed) — a long-lived process churning through many
distinct model references (e.g. per-tenant fine-tunes) leaked memory slowly
and unboundedly. Replace the static ConcurrentDictionary with a
lock-guarded, ref-counted Dictionary<string, Gate>: the ref is reserved
under the lock before WaitAsync, so a gate is evicted (and its semaphore
disposed) only when RefCount hits zero, which is only reachable once no
caller holds or is queued on it. This sidesteps the naive "CurrentCount==1
=> remove" fix, which races a releasing caller against a new waiter and can
hand two callers the same logical gate via two different SemaphoreSlim
instances.

Public API (KeyFor, both AcquireAsync overloads) is unchanged. Adds one
read-only diagnostic, TrackedGateCount, so the bound is observable/testable
without reflection.

TDD: 4 new tests written first against a TrackedGateCount stub on the old
implementation (confirmed genuinely RED - real assertion failures, not a
compile error), then GREEN after the fix. Assertions are baseline-relative
rather than absolute-zero since this static gate is legitimately exercised
by other test classes running concurrently under xUnit's default
parallelism.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Streaming façade methods (GenericOpenAiModelRunner + ModelRunnerService, chat +
completion) called ThrowIfDisposed() once at call time then handed back the driver's
lazy IAsyncEnumerable. Disposing the runner (or its owned connection) mid-iteration
surfaced a raw ObjectDisposedException from inside HttpClient, bypassing the typed
ModelRunnerException taxonomy.

- Add terminal ErrorCodes.ModelInference.Disposed = "MIN_008" (NOT transient — a
  disposed runner must not trigger a retry loop; EndpointUnreachable is transient so
  was not reused).
- Add one shared ModelRunnerInferenceHelpers.GuardDisposalAsync<T> iterator: checks
  disposed before each advance and translates an ObjectDisposedException from
  MoveNextAsync into ModelRunnerException(Disposed, inner: ode); yield outside the
  try/catch; disposes the source enumerator in finally (ConfigureAwait(false)).
- Wire all 4 façade stream call sites through it; eager call-time ThrowIfDisposed()
  preserved (direct misuse still throws ODE synchronously).
- TDD: ModelRunnerStreamDisposalTests (gated fake driver) covers mid-stream disposal,
  ODE translation w/ inner, eager-check-unchanged, happy path; ModelErrorCodesTests
  asserts MIN_008 and that it is not transient.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…AddCommand backslash normalization (B-M2)

Rooted ADD sources skipped staging whenever anything already sat at the destination, and
root-level relative COPY/ADD sources were never registered in the collision guard - together
this let a rooted ADD silently ship a same-named COPY's bytes (or stale bytes on a reused
working folder) under a green build. Root-level relative sources now claim their name via a
shared ClaimRootedName helper (also de-duplicating the two pre-existing inline collision
guards), and the exists-skip is replaced with an unconditional copy so re-staging always
refreshes. AddCommand.ToString() now normalizes backslashes via the same shared
DockerfileJson.NormalizePath CopyCommand already used, so Windows-authored ADD paths render
with forward slashes instead of producing a tar-entry mismatch on the Linux daemon.
ComposeBuilder.ResetForRetry wiped BorrowedProject/_composeCleanupFailed
with no persisted "I created this" marker, so a retried BuildAsync()
that found its OWN half-created project (up failed, cleanup down also
failed) misclassified it as an external borrow. Dispose then skipped
`compose down` for it, leaking the stack on flaky-CI retry loops.

Add _priorAttemptCreatedProject, deliberately excluded from
ResetForRetry, and re-own by project name when the probe finds the
project on a later attempt and this builder set that marker. Mirrors
the Network/Volume re-own-by-id pattern; compose projects have no
stable id, so re-own is guarded by the persisted marker instead.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… claim-after-exists

IMPORTANT: rooted COPY/ADD staging hit File.Copy into a path already occupied by a directory
(created by an earlier nested relative source of the same basename), throwing a raw
UnauthorizedAccessException/IOException with no FluentDocker context. New shared
GuardNoDirectoryAt helper now throws a typed, actionable NotSupportedException before File.Copy
in both rooted branches, matching the method's other guarded failures.

MINOR (regression from the B-H1 commit): ClaimRootedName ran before the File.Exists check at all
four sites, so a missing relative source skipped under lenient string-gen still reserved its
basename - a following rooted source with that basename then threw a spurious collision. Moved
each claim to after the existence check so only actually-staged sources register a name; the
directory-source and not-found guards keep their prior order.

Two new unit tests in BuilderProductionReadinessBugTests (DockerfileBuilderTests was at the
500-line ceiling): directory-collision -> typed error, and missing-lenient-relative +
same-basename rooted -> stages without spurious throw. RED verified against the prior commit.
…M3, B-M4)

- WithContextSize(tokens) on ModelRunnerBuilder, ModelServiceBuilder, and
  ModelRunOptionsBuilder now rejects tokens <= 0 with ArgumentOutOfRangeException,
  matching the guard ComposeModelBuilder already had, instead of silently storing
  garbage that only surfaces as a raw CLI error deep inside `docker model configure`.
- Builder.BuildAsync now collects BuildScopes into an explicit List instead of a
  Dictionary keyed by (kernel, driverId), so BuildResults' reverse-disposal order
  (dependents before dependencies) is guaranteed by insertion order rather than
  relying on Dictionary enumeration order, an unspecified CLR detail.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…slabel (C-M2), RequestTimeout doc (C-M3)

C-M1: uniform ArgumentNullException.ThrowIfNull(model/source/target/request) across every
CLI model-runner op that dereferences a required reference — replaces raw NREs (Load/Unload/
Configure/Tag/Push) and NRE-swallowed-into-misleading-Fail (Pull/Inspect/Remove/Package) with
one consistent, documented failure mode.

C-M2: SupportsConfigureBackendAsync's backend-probe internal timeout (or seam fault) no longer
surfaces as OperationCanceledException to a caller who never cancelled their own token — it now
throws a typed, non-transient DriverException(ConfigureFailed) that ConfigureAsync maps to
CommandResponse.Fail, so a retry loop can no longer mistake a wedged Docker model plugin for a
transient cancellation. Genuine caller cancellation still throws OCE. Also fixed a second
pre-existing test that encoded the same bug via a different (seam-fault) trigger.

C-M3: DriverContext.RequestTimeout doc now states it bounds both API HTTP requests and CLI
buffered command execution, not just the API driver (doc-only, no behavior change).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
S-H1 (HIGH): WaitForPortAsync/WaitForHttpAsync/WaitForLogMessageAsync now
inspect container state each poll and throw FluentDockerException (exit code +
log tail) on a terminal exited/dead state, instead of burning the full timeout
and returning a bare false. Extracted the start gate's diagnostic helpers
(HasReachedTerminalState/ReadLogTailAsync/AppendLogTail) into a shared
internal WaitDiagnostics helper so the gate and the wait extensions share one
implementation (no copy-paste); added a service-level log-tail overload and a
ThrowIfTerminalAsync that positively confirms death (inspect failures are
swallowed so timeout/retry behaviour is unchanged when state is unknown). Added
LogWaitFailure + lastException tracking to WaitForPortAsync for parity with the
HTTP variant.

S-M4 (MEDIUM): inverted IsRetriableWaitException from a blacklist to a
whitelist of genuinely transient failures (SocketException, HttpRequestException,
transient DriverException, per-attempt TaskCanceledException). Non-transient
exceptions (e.g. InvalidOperationException) now surface instead of being
silently retried to a timeout; dropped the NRE special-case.

Moved GetDockerHost host extensions to ServiceExtensions.Host.cs to keep
ServiceExtensions.cs under the 500-line cap.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
StartAsync/StopAsync fired lifecycle hooks and published StateChange right
after the optimistic UpdateState, before reconciling against `compose ps` —
so Running/Stopped hooks could run against a dead or still-running stack
since `docker compose up/start/stop` reports success even when a service
crashes on boot. Mirror the already-correct RestartAsync (SVC-MAJ-3):
reconcile first, then only fire hooks if the reconciled state still matches.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…sync tar (S-M1/M2/M3)

S-M1: ServiceEndpointResolver returned a literal loopback binding IP verbatim even for
a remote daemon (tcp/ssh, non-loopback host), making WaitForPortAsync probe the client's
own loopback instead of the daemon's -> false-positive readiness. Added IsRemoteDaemon()
and resolve loopback bindings to the daemon host for remote daemons, mirroring the
existing wildcard-binding branch; local daemons (unix/npipe, tcp://localhost, tcp
loopback host) keep returning the loopback binding unchanged.

S-M2: named-volume dispose cleanup caught OperationCanceledException alongside every
other exception, logged a warning, and kept calling the driver for the remaining
volumes with an already-canceled token. Narrowed the catch to exclude OCE so
cancellation stops the loop instead of issuing dead calls.

S-M3: dispose-time export "explode" used synchronous TarFile.ExtractToDirectory,
blocking a threadpool thread and ignoring cancellation once started. Switched to
TarFile.ExtractToDirectoryAsync with the dispose-scoped token and made the source
stream an await-using IAsyncDisposable.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Review follow-ups on the wait fail-fast change:

- IMPORTANT-2: WaitForLogMessageAsync now checks log content BEFORE the
  terminal-state fail-fast, so a short-lived container that logs the awaited
  message and then exits still returns true (was regressing to a fail-fast
  throw). WaitForPortAsync/WaitForHttpAsync keep the top-of-loop fail-fast (a
  gone container cannot serve a port/HTTP).
- IMPORTANT-1: documented the new terminal-state throw in docs/containers.md
  and the XML <remarks> of all three wait helpers (they now throw
  FluentDockerException with exit code + log tail on exited/dead, in addition
  to cancellation and non-transient driver errors).
- MINOR: AppendLogTail is now idempotent (skips re-append when the message
  already carries the "Container log tail:" marker) — one guard in the shared
  helper fixes the double-tail from the builder catch re-wrapping a
  ThrowIfTerminalAsync / start-gate exception that already has a tail.
- Tests: added WaitForLogMessageAsync_MessagePresentButContainerTerminal_ReturnsTrue
  (RED→GREEN for the reorder) and
  WaitForPortAsync_RestartingContainer_DoesNotFailFast_TimesOutToFalse
  (restarting is not terminal).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…reset + parser XML docs (P-M1/M2/M3/M4)

P-M1: IsMachineManagedContext now gates on a remote DriverContext.Host first, so a
connection-refused error against a remote rootful daemon (ssh://) classifies as
Api.ConnectionFailed instead of the misleading, transient-retried Machine.NotRunning.

P-M2: AutoStartMachineCoreAsync no longer guesses an arbitrary machine when none is
flagged default and more than one exists; it throws PodmanMachineNotRunningException
naming AutoStartMachineConfig.MachineName instead of starting a possibly-wrong VM and
hanging for the full readiness timeout.

P-M3: buffered StatsAsync now passes --no-reset, symmetric with the streaming stats
path, so goterm's raw \033[2J reset sequence never reaches ParseStatsOutput regardless
of Flush()'s non-TTY detection.

P-M4: XML-documented all 21 public parser methods (10 delegating methods in
PodmanCliContainerDriver.Parsing.cs, 11 in PodmanContainerParser.cs) with accurate
null/throw semantics read from each method body.

PodmanContainerParser.cs and PodmanCliDriverPack.cs split via partial class
(PodmanContainerParser.Network.cs, PodmanCliDriverPack.AutoStart.cs) to stay under the
500-line file cap after the doc/fix additions.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ulti-dot filenames (Co-H1/Co-M1)

ResourceExtract(type, target, files) queried non-recursively, so ExtractFile's lossy
namespace/filename split dropped any multi-dot resource (Dockerfile.template,
settings.production, archive.tar.gz, app.properties) before Include's suffix-match ever
ran (Co-H1, PROVEN LIVE: empty dir, no exception). Even when matched via a recursive
query, the writer scattered it into a mangled subfolder + fragment, e.g.
dir/Dockerfile/template instead of dir/Dockerfile.template (Co-M1).

Fix: query recursively in the files-branch, and rewrite Include's suffix-matched
ResourceInfo onto the REQUESTED name (reconstructing Namespace/RelativeRootNamespace
from the fully-qualified manifest name), so the requested-files path is fully
deterministic regardless of ExtractFile's guess. Also add a best-effort refinement in
QueryCore for the no-files recursive path (Query()/ToFile(), which has no requested name
to disambiguate): a dotless ExtractFile guess beyond the query root now re-anchors to the
full remainder, landing common multi-dot shapes as one file at the target root instead of
a directory+fragment. Corrected the MDL-MAJ-2 comment that overclaimed the non-recursive
path already worked.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…xed docker volume specs (Co-M2)

TemplateString's opt-in handleWindowsPathIfNeeded constructor flag drove ToTargetOs,
which converted every '/' to '\' (outside quoted http(s) URLs) when both
FdOs.IsWindows() and the flag were true. A mixed docker volume spec like
c:/data:/data (Windows host : Unix container) would become c:\data:\data,
corrupting the container-side path. The flag had zero callers inside the library
(grep-verified across FluentDocker/, FluentDocker.Tests/, FluentDocker.Benchmarks/,
and Examples/) - dead flexibility exposed as a public footgun. v3.2.0-preview.2 is
not yet on NuGet, so removing the parameter is a clean pre-release change.

Deleted the flag, the constructor param, ToTargetOs, and the now-orphaned
UrlDetector/MyRegex() regex machinery that existed solely to support it. Render
now runs directly on the input string on every call, on every OS. Removed the one
test that exercised the dead flag (its assertion was the corruption itself) and
added two regression guards: a literal c:/data:/data round-trip, and a
${TMP}/data:/data host-mount spec whose container side keeps forward slashes.

No runtime RED was possible on this non-Windows machine (the flag defaulted to
false and every real call site used the 1-arg constructor already), so the proof
is structural: the whole solution + ~5.4k tests still build and pass with the
2-arg constructor gone, confirming no caller relied on it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…c aggregate + TTY log-sniff fallback (A-H1/A-M1/A-M2/A-M3)

A-H1 (HIGH): PullAsync("nginx:1.25", tag: null) sent fromImage=nginx%3A1.25&tag=latest -
the daemon's reference.WithTag discards the embedded tag and silently pulls
nginx:latest instead of nginx:1.25. Added TrySplitEmbeddedTag, mirroring the CLI
driver's ShouldAppendTag detection rule exactly, to split an embedded ":tag" into
fromImage/tag; a conflicting explicit tag now rejects with InvalidArgument instead of
being silently discarded.

A-M1 (MEDIUM): DockerApiServiceDriver.RemoveAsync aborted a multi-id remove on the
first failed DELETE, leaving every id after it (including live services) untouched.
Now attempts every id and aggregates failures, mirroring DockerApiSystemDriver.
PruneAsync's collect-then-fail pattern and the CLI driver's docker service rm
semantics. Split the now-524-line file into DockerApiServiceDriver.Parsing.cs
(spec builders + JSON parsing) to stay under the 500-line cap.

A-M2 (MEDIUM): below API 1.42, GetLogsAsync byte-sniffed the first frame to decide
multiplexed-vs-raw; a TTY container's binary output can coincidentally start with
stdcopy-header-shaped bytes and get misparsed as multiplexed, silently stripping fake
"headers" throughout the log. ReadDockerLogTailAsync now takes a containerId and, on
content-type gate failure, falls back to a TTY inspect (DetectTtyAsync, promoted from
DockerApiStreamDriver to the shared DockerApiDriverBase.Logs.cs) before falling back
further to the byte-sniff only when TTY can't be determined either.

A-M3 (doc): documented StreamIdleTimeout's full blast radius (applies to every
streamed response incl. /events/stats/attach, not just logs) and the per-timeout
pooled-buffer leak trade-off on both DockerApiConnectionConfig.StreamIdleTimeout and
IDockerApiDriverBuilder.WithStreamIdleTimeout. No behavior change.

make lint clean; make test: 5437/5437 passed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ore non-recursive exactness)

Review found the QueryCore dotless-guess re-anchoring ran unconditionally, before the
_recursive gate, so on the public recursive:false path a genuinely-nested dotless file
(real folder Res/Sub/ with a file literally named "README" -> "Res.Sub.README") was
wrongly re-anchored to the root and included, instead of correctly excluded — a new
wrong-location silent write, the same class Co-H1 closes. Added `_recursive &&` to the
re-anchoring condition: preserves the tested (always-recursive) Co-H1/Co-M1 behavior and
restores non-recursive root-level-only exactness. The Include path is unaffected (it
reconstructs the true manifest name independently of re-anchoring).

Tests: new embedded fixture Res/Sub/README + regression test asserting a non-recursive
query EXCLUDES it (RED pre-gate: wrongly re-anchored to root as "Sub.README"; GREEN after).
Also pinned the documented no-files nested case (Res.Sub.Dockerfile.template collapses to a
flat "Sub.Dockerfile.template" at the root).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…M1) + 602-line trims

Doc-H1: swept all 8 docs/README references (plus the MsTestClassContainerFixtureBase.cs
XML cross-ref) from the deprecated MsTestContainerFixtureBase to the new
MsTestPerTestContainerFixtureBase, leaving exactly one deprecation footnote in
docs/testing/mstest.md. Following the old docs previously yielded CS0618 on first build.

Doc-M1: amended the [3.2.0-preview.2] CHANGELOG entry in place for post-dated API changes
(MsTest rename/Deprecated section, ISysCtl.GetCapabilitiesAsync/IsHealthyAsync/
DefaultDriverId, IDriverRegistry.IsDisposeComplete/AbandonedDriverCount) and the full
production-readiness remediation since 0902baf, grouped under Breaking/Added/Fixed/
Deprecated with scannable single-topic bullets — including the breaking TemplateString
2-arg constructor removal and the new ErrorCodes.ModelInference.Disposed (MIN_008) /
ModelOperationGate.TrackedGateCount surface.

Structural: trimmed docs/utilities.md, docs/testing/xunit.md, and
docs/migrate-v2-to-v3/test-migration.md from 602 to 598-599 lines by reflowing
hard-wrapped prose paragraphs (pure whitespace change, no content removed).

make lint clean; make test: 5438/5438 passed (foreground, blocking run).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ADMEs (Doc-H1)

Two package READMEs still taught the deprecated MsTestContainerFixtureBase as the
primary name in their cross-package lifetime tables (the exact pattern Doc-H1 targets).
Swept both the table cell and the prose sentence to MsTestPerTestContainerFixtureBase.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…tials

Document all 181 undocumented public members in FluentDocker/Model/Drivers/
(all ErrorCodes.cs error-code constants; every other file in the directory
was already fully documented). ErrorCodes.cs exceeded 500 lines after docs,
so it is split into 18 partial files, one per existing nested static class
(General/Driver/Container/Image/Network/Volume/Compose/Pod/Kubernetes/
Machine/Manifest/Api/Config/Auth/Stack/Service/Model/ModelInference), with
the main file retaining only the class doc and IsTransientCode. Mechanical
split verified byte-identical (constant name, group, and string value) via
a diff against the original file; no logic or values changed. CS1591 stays
suppressed in the csproj (untouched).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Document all 148 undocumented public members in FluentDocker/Services/Impl/
(HostService + .Operations, ImageService, NetworkService, ComposeService +
.Lifecycle/.Unpause, VolumeService, ContainerService + .Operations/.Lifecycle/
.Export, PodService, EngineScope). Members implementing a documented interface
method use <inheritdoc />, matching the file's own existing convention; the
handful of Pause/Stop/Remove/Hook overrides that unconditionally throw
FluentDockerNotSupportedException got a real one-line summary plus an
<exception> tag instead, since inheriting the base "removes the service"
wording would misdescribe them. ContainerService.cs exceeded 500 lines after
docs, so the inspect-cache subsystem (InspectAsync + its cache fields/record/
helper) was split into a new ContainerService.Inspect.cs partial, mirroring
the file's existing .Export.cs/.Lifecycle.cs/.Operations.cs convention.
No behavior change; CS1591 stays suppressed in the csproj (untouched).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Document all 123 undocumented public/protected members under FluentDocker/Drivers/
(Docker CLI driver base + components, Podman CLI driver base + components, the
ISystemDriver result types, IPodmanManifestDriver's OCI-shaped result types, and
the few remaining gaps in the otherwise-documented Docker API/Models areas).
Most flagged members were base-class helpers (error/failure-code classification,
argument quoting, process execution overloads), DTO properties mirroring Docker/
Podman CLI JSON output, or driver constructors rather than port-interface
implementations, so real behavioral docs were written throughout; <inheritdoc />
was used only for the 2 PopulateMeta() overrides whose base contract is unchanged.

DockerCliComposeDriver.Info.cs, ModelJsonParser.cs, and PodmanCliDriverBase.cs
exceeded 500 lines after docs, so each is split via an additional partial file
following the existing convention (Create.cs, Tables.cs, Execution.cs — the
latter mirrors the Docker-side driver base's existing Execution.cs). No behavior
change; CS1591 stays suppressed in the csproj (untouched).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…s DTOs

Task 16 batch 16d: document all 108 previously-undocumented public members
across Model/Containers, Model/Events, Model/Networks, Model/Images, and
Model/Stacks (CS1591 stays suppressed at the csproj level; enforcement is a
separate final step). Docs name the Docker/Podman CLI or JSON concept each
member maps to; no behavior change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Task 16e: documents 67 CS1591-flagged public members across Model/Compose,
Model/Common, Model/Builders, Model/Models, and top-level Model/*.cs. Docs
name the compose YAML key or Docker concept each member maps to; also
relocates a doc comment on ModelRunOptions.ContextSize that had drifted onto
its private backing field. No behavior change; CS1591 suppression untouched.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…pass

Documents the last 77 undocumented public members of Task 16's full XML-doc
pass, across FluentDocker/{Resources,Testing,Common,Builders,Extensions}/:
resource query/reader/writer types, OrphanCleanup/SessionLabel test-cleanup
API, lenient/tolerant JSON converters, lifecycle-hook/network-alias builder
models, and extension-class summaries. A fresh whole-project CS1591 probe
now shows zero warnings anywhere in FluentDocker/, confirming this is the
last outstanding batch.

Testing/Core/OrphanCleanup.cs (499 lines pre-docs) is split into
OrphanCleanup.cs (public surface), OrphanCleanup.Resources.cs (per-kind
sweep logic), OrphanCleanup.Filters.cs (shared predicates), and a new
SessionLabel.cs (its own top-level type, extracted rather than left
crammed into the same file) — all four ≤ 500 lines, verified byte-for-byte
equivalent to the original via a code-line diff.

Also fixes a pre-existing broken XML doc cref in Builders/Builder.Models.cs
(missing `using FluentDocker.Kernel;`) found while probing the same
directory.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Task 16 final step: remove the blanket CS1591 suppression in
FluentDocker.csproj and promote it to a build error via a scoped
WarningsAsErrors (no repo-wide TreatWarningsAsErrors exists to piggyback
on). A clean rebuild surfaced zero straggler undocumented members —
docs batches A-F already fully covered the public API. Added the
T7-deferred <exception cref="ArgumentNullException"> tags to
IModelManagementDriver / IModelRuntimeDriver, verified 1:1 against the
sole CLI adapter's actual ArgumentNullException.ThrowIfNull calls.
Smoke-tested the gate (delete a doc -> build fails with CS1591 as an
error -> restore -> build succeeds) and added a CHANGELOG line.

make check: lint clean, 5438/5438 unit tests, 13+4 runner tests,
coverage 87.53%/79.1% (floor 78%/71%) - all green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- PodmanMachineNotRunningException: add a (message, isTransient) overload
  so the P-M2 ambiguous-machine auto-start guard (multiple machines, none
  default) can report itself as a permanent config error instead of
  inheriting the hardcoded IsTransient=true meant for stopped-machine
  retries. Only that one throw site now passes isTransient: false; all
  other throw sites are unchanged.
- LenientInt32Converter: reword the class summary, which claimed 0 was
  returned for "unparsable runtime drift" while Read() actually throws
  JsonException for any unrecognized token (0 is only for JSON null),
  matching the honest style of the sibling LenientBoolConverter.
- ErrorCodes.Machine.NotRunning: note the one non-transient exception
  (the P-M2 ambiguous-machine case) alongside the existing transient
  guidance.
- Pin the P-M2 test with Assert.False(ex.IsTransient).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ents

- Improve kernel registration-rollback dispose to be budget-bounded.
- Add IsTracked method to ModelOperationGate for per-key diagnostics.
- Update Docker CLI command timeout messages for clarity.
- Refactor tests to ensure proper handling of consecutive failed retries and named volume cleanup.
- Ensure ModelOperationGate correctly evicts idle gates and prevents memory leaks.
- Adjust assertions in tests for better accuracy and reliability.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants