From b718b68f1e30a440b8d3366e4dfd2aa5231ec2eb Mon Sep 17 00:00:00 2001 From: AviBackToBlack <54722547+AviBackToBlack@users.noreply.github.com> Date: Sun, 4 Oct 2026 10:33:19 +0100 Subject: [PATCH 1/5] Reconcile orphaned WSL runtime containers --- README.md | 19 +- docs/architecture.md | 15 +- docs/roadmap-decisions.md | 7 +- docs/roadmap-implementation-requirements.md | 12 +- docs/security-model.md | 13 +- docs/wsl-process-contract.md | 29 +- docs/wsl.md | 40 ++- internal/hostenv/hostenv.go | 5 +- internal/wsldocker/reconcile.go | 153 ++++++++++ internal/wsldocker/reconcile_test.go | 126 ++++++++ internal/wslinstall/install.go | 2 +- internal/wslreconcile/command.go | 91 ++++++ internal/wslreconcile/lease_linux.go | 229 ++++++++++++++ internal/wslreconcile/lease_linux_test.go | 106 +++++++ internal/wslreconcile/lease_other.go | 24 ++ internal/wslreconcile/production_linux.go | 49 +++ internal/wslreconcile/production_other.go | 7 + internal/wslreconcile/reconcile.go | 317 ++++++++++++++++++++ internal/wslreconcile/reconcile_test.go | 230 ++++++++++++++ internal/wslrun/plan.go | 3 +- internal/wslrun/run_linux.go | 7 +- internal/wslrun/runner.go | 30 +- internal/wslrun/runner_test.go | 56 +++- main.go | 8 +- 24 files changed, 1530 insertions(+), 48 deletions(-) create mode 100644 internal/wsldocker/reconcile.go create mode 100644 internal/wsldocker/reconcile_test.go create mode 100644 internal/wslreconcile/command.go create mode 100644 internal/wslreconcile/lease_linux.go create mode 100644 internal/wslreconcile/lease_linux_test.go create mode 100644 internal/wslreconcile/lease_other.go create mode 100644 internal/wslreconcile/production_linux.go create mode 100644 internal/wslreconcile/production_other.go create mode 100644 internal/wslreconcile/reconcile.go create mode 100644 internal/wslreconcile/reconcile_test.go diff --git a/README.md b/README.md index 65fa244..9bbe20c 100644 --- a/README.md +++ b/README.md @@ -71,7 +71,7 @@ real Linux CLI/runtime in an ephemeral container |---|---| | Windows 10/11 x64 + Docker Desktop (Linux containers) + PowerShell | **Supported** — this is the validated configuration | | cmd.exe invocation of shims | Works for the common cases; less battle-tested than PowerShell | -| WSL2 | **v2 runtime wired; activation gated.** The native Linux runtime uses the fixed private WSL layout and Docker Desktop's WSL integration directly, but production dispatch remains fail-closed until retained-container orphan reconciliation and real WSL2 qualification land. See [docs/wsl.md](docs/wsl.md) | +| WSL2 | **v2 runtime wired; activation gated.** The native Linux runtime uses the fixed private WSL layout and Docker Desktop's WSL integration directly, including proof-bound retained-container orphan recovery, but production dispatch remains fail-closed until native state commands, integration coverage and real WSL2 qualification land. See [docs/wsl.md](docs/wsl.md) | | Windows 11 ARM64 | **CI/release-artifact/update-path qualified only, not supported yet.** Native tests/build/dispatch run on GitHub-hosted ARM64 hardware, the release workflow produces a reproducible ARM64 archive, and self-update selects and verifies that archive by `GOARCH`; real Docker Desktop ARM64 E2E qualification remains | | Linux / macOS hosts | **Not supported.** The program is Go and cross-compiles, but shim installation, path mapping and doctor checks are Windows-specific | | Windows containers | Not supported; images are Linux images | @@ -949,8 +949,16 @@ creation/upgrades and requires an already provisioned authenticated registry. The bootstrap executable must itself be a bounded, current-user-owned regular non-symlink file with safe executable permissions. This command still does not contact Docker itself. Install reports the wired-but-gated runtime state; -managed tool dispatch remains fail-closed until orphan reconciliation and real -WSL2 qualification land. See [docs/wsl.md](docs/wsl.md). +managed tool dispatch remains fail-closed until native state commands, +integration coverage and real WSL2 qualification land. See +[docs/wsl.md](docs/wsl.md). + +`cb wsl cleanup --check` reports exact namespace-owned retained runtime +containers as active or orphaned without mutation. `--apply` stops, waits and +removes only re-proven orphans; ordinary tool startup performs the same pass +automatically. A process-held private lease protects active runs, and the +namespace coordinator remains held across container creation and lease +publication so cleanup cannot guess across that race. ### Self-update release selection @@ -1113,8 +1121,9 @@ benchmark methodology and the disposable-container tradeoff are in - Windows x64 + Docker Desktop (Linux containers) is the currently qualified release target. The native WSL2 runtime, fixed-layout installation, project mapping, managed volumes, Engine lifecycle, stdio/TTY, resize, signal and exit - propagation are wired, but activation still requires retained-container - orphan reconciliation plus real WSL2 + Docker Desktop qualification. Windows + propagation plus retained-container orphan reconciliation are wired, but + activation still requires native state commands, integration coverage and + real WSL2 + Docker Desktop qualification. Windows ARM64 has native non-Docker CI coverage and published release artifacts, but no Docker support claim. - First invocation of a tool after `cb lock` may still need images present diff --git a/docs/architecture.md b/docs/architecture.md index e05de8a..679edf5 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -492,7 +492,7 @@ driver/scope. The package also preflights an entire stateful profile's project/shared binding set, re-proves the exact project root before deriving project identities, and ensures each distinct identity only after the complete plan validates. Tool-time composition is wired but the host boundary keeps it -activation-gated until orphan reconciliation lands; native state commands remain. +activation-gated while native state commands and qualification remain. `internal/wslrun` is the native WSL vertical orchestrator. It requires the fixed layout, private registry, managed binary and exact invoked shim; resolves @@ -504,10 +504,15 @@ exit status, so daemon auto-remove cannot race fast tools. Non-TTY streams use strict Docker framing; TTY sessions use raw terminal mode, resize events and an explicit Linux signal-forwarding set. Any runtime failure cancels live I/O, proof-bound kills the container, waits for stop and performs non-force cleanup. -The production host boundary does not yet dispatch into this orchestrator: -SIGKILL of the host shim can bypass every in-process defer and strand a retained -container, so activation waits for proof-bound orphan reconciliation rather -than making an unsafe partial support claim. +Before creation it holds a namespace coordinator and reconciles retained runs. +Each created run publishes a private process-held lease before the coordinator +is released. Reconciliation preserves locked active leases and mutates only an +unlocked or lease-less candidate whose complete labels, retention and stream +configuration were freshly re-proven. A running orphan is killed and waited; +all orphan removal uses the same proof-bound non-force lifecycle. Explicit +`cb wsl cleanup --check|--apply` exposes that recovery path. The production host +boundary still does not dispatch into this orchestrator until native state +commands, integration coverage and real WSL qualification complete. After the host runtime boundary is enforced, `cb self-update` is dispatched before machine policy and registry loading. Release selection therefore remains diff --git a/docs/roadmap-decisions.md b/docs/roadmap-decisions.md index bc38948..33f9e6d 100644 --- a/docs/roadmap-decisions.md +++ b/docs/roadmap-decisions.md @@ -16,7 +16,7 @@ items from being repeatedly rediscovered as if they were immediately actionable. | RM-24 Python / uv | **Keep both** | Decision complete. Built-in `python`/`pip` keep the dedicated Python provider; `uv`/`uvx` remain separate opt-in stateful profiles. | | RM-26 Python global CLI exposure | **pipx yes; plain pip expose no** | Completed in PR #74. The separate stateful pipx profile and managed store shipped; project/compat `/venv/bin` remains intentionally unexposed. | | RM-34 Cargo expose enhancement | **Intentionally deferred** | Existing expose-all and explicit binary selection are sufficient. Reopen only for a concrete unmet use case. | -| WSL2 | **Native WSL frontend** | Fixed-layout install plus managed-tool runtime/Docker composition are wired behind the host gate. Retained-container orphan reconciliation, native state commands, integration corpus and real WSL qualification remain before activation. No Windows↔WSL path/state guessing. | +| WSL2 | **Native WSL frontend** | Fixed-layout install, managed-tool runtime/Docker composition and proof-bound retained-container orphan reconciliation are wired behind the host gate. Native state commands, integration corpus and real WSL qualification remain before activation. No Windows↔WSL path/state guessing. | | Enterprise policy | **Machine-owned constraint layer** | Foundation shipped in PR #75. Authenticated registry and image-trust follow-ups must extend this boundary and cannot be weakened by lower layers. | | Image trust | **Policy-driven Sigstore/cosign at lock time** | Ready after signed-registry policy. Digest locking remains default where policy permits. Required trust never silently falls back to digest-only. | | Per-project overlays | **Explicit digest-bound, add-only trust model** | Implementation-ready on the merged policy foundation. Initial overlays exclude host mounts, env prefixes and shared cross-project volumes. | @@ -345,7 +345,7 @@ is not completion. - explicit read-only/apply Linux ownership, permission and symlink layout preparation plus the fixed-path native install/config lifecycle are implemented; ordinary managed tool dispatch is composed but remains - activation-gated pending orphan reconciliation; + activation-gated pending state-command integration and qualification; - Docker Desktop WSL integration proof, proof-bound bounded control requests, the separately constrained attach transport, strict raw-stream decoder, exact container inspection, wait, TTY-resize, signal, start, creation and @@ -357,6 +357,9 @@ is not completion. - profile-aware nearest/outermost/trusted project-root selection, canonical project and descendant storage classification (including symlink and nested-mount rejection), and proof-consuming argument mapping are wired; + - process-held per-run leases, create-to-lease namespace serialization, + proof-bound automatic orphan recovery and explicit read-only/apply cleanup + are implemented for retained runtime containers; - project identity and cross-boundary rejection integration tests; - real WSL Docker E2E. diff --git a/docs/roadmap-implementation-requirements.md b/docs/roadmap-implementation-requirements.md index adf956b..1b6be42 100644 --- a/docs/roadmap-implementation-requirements.md +++ b/docs/roadmap-implementation-requirements.md @@ -79,7 +79,7 @@ The minimum delivery gate for a code change is: | Enterprise policy | **Foundation and signed registry shipped / image trust remains** | PRs #75 and #84 shipped the machine-owned constraint layer and authenticated registry; image trust remains | | Image trust | **Online/offline production and runtime authorization implemented / private-registry work remains** | Add an explicit private-registry credential bridge | | Plugin/provider architecture | **Intentionally deferred** | Reopen only after at least two real integrations cannot fit the declarative model | -| WSL2 | **Native tool runtime wired / activation remaining** | The fixed install/config/shim lifecycle, project proof/mapping, namespaced tool-time volumes, direct Docker Desktop Engine lifecycle, stdin/output framing, raw TTY, resize, signal forwarding, retained-container cleanup and exit propagation are composed behind the fail-closed host gate. Retained-container orphan reconciliation, native state-management commands, integration corpus and real WSL2 + Docker Desktop qualification remain before activation. | +| WSL2 | **Native tool runtime wired / activation remaining** | The fixed install/config/shim lifecycle, project proof/mapping, namespaced tool-time volumes, direct Docker Desktop Engine lifecycle, stdin/output framing, raw TTY, resize, signal forwarding, retained-container cleanup, orphan reconciliation and exit propagation are composed behind the fail-closed host gate. Native state-management commands, integration corpus and real WSL2 + Docker Desktop qualification remain before activation. | | Per-project overlays | **Completed in PR #80** | Add-only digest-bound trust model shipped on the merged enterprise-policy foundation | | Release SBOM | **Conditionally deferred** | Trigger on shipped third-party/runtime dependencies or concrete compliance/consumer demand | | Snyk | **Conditionally deferred** | Trigger only for a real coverage gap plus owner/account/token and triage/outage policy | @@ -612,9 +612,10 @@ Docker Desktop WSL integration proof, create/attach/start/wait/resize/signal/ cleanup, strict non-TTY output decoding, raw TTY mode, terminal resize events, host-signal forwarding and exact exit-code propagation. Containers are retained until wait records the exit status and are then removed through the proof-bound -cleanup path, avoiding an auto-remove race for fast tools. Because uncatchable -host-shim death can bypass that in-process cleanup, production activation waits -for proof-bound orphan reconciliation. Native state-command integration, +cleanup path, avoiding an auto-remove race for fast tools. Process-held run +leases and a namespace coordinator close the create-before-lease race; automatic +startup and explicit `cb wsl cleanup --check|--apply` re-prove and recover only +exact unlocked or lease-less retained runs. Native state-command integration, project/cross-boundary integration coverage and real WSL qualification remain. Implementation must define native config/shim location, Docker endpoint, @@ -726,8 +727,7 @@ in PR #91. 2. Signed-registry enterprise policy. 3. Image trust at lock time, after signed-registry policy merges. 4. Remaining RM-31 real published-release/self-test E2E qualification. -5. Native WSL retained-container orphan reconciliation, remaining state - commands, integration corpus and real E2E. +5. Native WSL remaining state commands, integration corpus and real E2E. 6. RM-30 Authenticode only after certificate/protected-signing prerequisites exist. 7. RM-29 real Windows-on-Arm + Docker Desktop qualification last; do not delay higher-value work for it. diff --git a/docs/security-model.md b/docs/security-model.md index 3f324e9..43b959f 100644 --- a/docs/security-model.md +++ b/docs/security-model.md @@ -103,7 +103,7 @@ readable, and dangerous to let others edit. authenticates signed registries when required, and reconciles only the fixed managed binary and provenance-checked symlinks. The ordinary managed-tool runtime is composed and tested but remains behind this host gate until - retained-container orphan reconciliation and real qualification land; + native state commands, integration coverage and real qualification land; unsupported native management commands remain rejected. - **Native WSL installation does not adopt ambient files.** The bootstrap executable is the exact OS-reported running image and must be a bounded, @@ -174,8 +174,15 @@ readable, and dangerous to let others edit. container, waits under a fresh bound and then invokes the same non-force proof-bound removal. Cleanup errors are never hidden by the original failure. An uncatchable host-shim SIGKILL can bypass every in-process cleanup path, so - production activation remains gated until a separate proof-bound orphan - reconciliation mechanism handles both running and stopped retained objects. + each run owns a private process-held lease and container creation is serialized + with lease publication by a namespace coordinator. Automatic startup and + explicit `cb wsl cleanup --apply` discover only exact namespace labels, then + re-prove the full immutable container contract before mutation. Locked leases + preserve active runs. Missing or unlockable leases identify recoverable + orphans while the coordinator is held; running orphans are killed and waited, + stopped orphans are removed directly, and all deletion remains proof-bound + and non-force. Unsafe lease files or any ambiguous candidate stop the complete + preflight before its first mutation. - **Machine policy cannot be redirected or weakened.** A present enterprise policy is loaded only from the fixed OS path, requires administrator/root ownership and restrictive permissions, and authorizes the already-resolved diff --git a/docs/wsl-process-contract.md b/docs/wsl-process-contract.md index 056e432..2d1f0d8 100644 --- a/docs/wsl-process-contract.md +++ b/docs/wsl-process-contract.md @@ -2,8 +2,9 @@ This document defines the process semantics implemented by ContainerBin's native-Linux frontend inside WSL2. The runtime is composed and covered in this -tree, but production dispatch remains activation-gated until retained-container -orphan reconciliation and real WSL2 + Docker Desktop qualification land. +tree, but production dispatch remains activation-gated until the required +native state commands, integration corpus and real WSL2 + Docker Desktop +qualification land. The corresponding Windows behavior is documented separately in [the Windows shell/process contract](shell-contract.md). @@ -58,6 +59,25 @@ Engine wait, captures the `0..255` status, drains output, and removes the stoppe container through the proof-bound non-force cleanup path. Every control or stream connection repeats the Docker Desktop WSL socket and peer proof. +Before creating a runtime container, ContainerBin holds a namespace coordinator +lock, discovers exact namespace-labeled retained runs, and re-inspects every +candidate before mutation. Each live run owns a private `0600` lease file keyed +by its cryptographic run ID and keeps an exclusive lock on that file for the +process lifetime. The coordinator remains locked across container creation and +lease publication, so reconciliation cannot observe a newly created container +without its liveness decision. Locked leases are active and are never touched. +Missing or unlockable leases identify an orphan only while the coordinator is +held. Running orphans are sent SIGKILL, waited, and then removed; stopped +orphans are removed directly. Every removal reuses the exact proof-bound, +non-force container lifecycle. + +`cb wsl cleanup --check` reports active and orphaned retained runs without +mutation. Explicit `--apply` performs the same proof-bound reconciliation used +automatically before ordinary execution. Malformed labels, changed container +configuration, unsafe lease files or incomplete proofs stop the whole preflight +before its first mutation. A lease path is removed only after exact container +absence is established; otherwise it remains as recovery evidence. + ## Streams and TTY ContainerBin always attaches stdin, stdout and stderr. Non-TTY stdin is copied @@ -113,8 +133,9 @@ joined to the original diagnostic. The top level maps infrastructure failures to ContainerBin's documented exit code 120. Catchable host signals remain intercepted until that cleanup finishes, so their default disposition cannot terminate the shim inside the bounded cleanup window and strand a retained -container. SIGKILL remains uncatchable and is covered by the activation gate's -orphan-reconciliation requirement. +container. SIGKILL remains uncatchable; the process-held lease is automatically +unlocked by the kernel and the next automatic or explicit reconciliation pass +recovers the retained container. A failed start response is treated as transport-ambiguous: the Engine may have accepted the request before the connection failed. Cleanup therefore attempts diff --git a/docs/wsl.md b/docs/wsl.md index ab6d6c9..1454f9d 100644 --- a/docs/wsl.md +++ b/docs/wsl.md @@ -8,8 +8,8 @@ WSL frontend, and standalone Linux remains a separate, demand-gated product. The implementation establishes the runtime boundary, fixed native-WSL layout, explicit install/config lifecycle and the complete managed-tool composition through Docker Desktop's Engine socket. Production dispatch remains fail-closed -until retained-container orphan reconciliation, the remaining native management -state lifecycle and real WSL2 + Docker Desktop qualification land. +until the remaining native management state lifecycle, integration corpus and +real WSL2 + Docker Desktop qualification land. ## Runtime classification @@ -29,11 +29,11 @@ state lifecycle and real WSL2 + Docker Desktop qualification land. became consistent. Other Linux kernels are standalone Linux and rejected. - `cb version`, `cb help` and `cb config` remain bootstrap-safe for diagnosis; they perform no Docker or registry mutation and return before host enforcement. -- `cb wsl prepare --check|--apply` and `cb wsl install --check|--apply` are the - native-WSL management surface. Managed tool shims are installed, but their - runtime dispatch remains gated; other `cb` management commands remain - explicitly unavailable rather than falling through to Windows-oriented - Docker CLI, path or state behavior. +- `cb wsl prepare --check|--apply`, `cb wsl install --check|--apply` and + `cb wsl cleanup --check|--apply` are the native-WSL management surface. + Managed tool shims are installed, but their runtime dispatch remains gated; + other `cb` management commands remain explicitly unavailable rather than + falling through to Windows-oriented Docker CLI, path or state behavior. Environment variables alone never promote an ordinary Linux kernel to WSL2. Custom kernels that remove the Microsoft WSL2 identity markers fail closed; @@ -115,7 +115,7 @@ created only when missing and fully revalidated. Foreign files, owners, targets or unsafe modes stop the transaction instead of being repaired or replaced. The install command itself performs no Docker request. After a successful apply and revalidation, its managed tool shims remain activation-gated by the -host boundary until orphan reconciliation and qualification land. +host boundary until state-command integration and qualification land. An interruption before the final binary rename can leave a current-user-owned `.cb-install-.tmp` regular file in the private binary directory. ContainerBin does not sweep filename lookalikes without stronger provenance; @@ -301,6 +301,22 @@ Infrastructure or stream failure cancels the live wait, sends SIGKILL through the same proof-bound transport, waits for stop and then cleans up. Real WSL2 + Docker Desktop qualification remains mandatory before release support. +Every ordinary run first acquires the private state-directory namespace lock, +applies orphan reconciliation, creates the retained container while still holding that +coordinator, publishes a private process-held lease keyed by the generated run +ID, and only then releases the coordinator. This closes the create-before-lease +race. Reconciliation treats a locked lease as active; an absent or unlockable +lease is orphaned. It re-proves every discovered container's full ID, labels, +retention mode and stream configuration before the first mutation. Running +orphans are SIGKILLed and waited; stopped orphans go directly through the same +proof-bound non-force removal. Lease paths are removed only after exact +container absence is established. + +`cb wsl cleanup --check` exposes the classification without changing Docker or +lease state. `cb wsl cleanup --apply` performs explicit recovery. Both require +the complete fixed layout and fail closed on unsafe or replaced `0600` lease +files, ambiguous Docker candidates or any ownership/configuration mismatch. + ## Native WSL volume identity and control lifecycle The implemented volume contract names every object @@ -337,14 +353,12 @@ per-project venv, namespace-shared compatibility venv and pip cache are wired. The ordinary managed tool path is implemented behind the host gate. Activation and release qualification still require all of the following: -1. add proof-bound retained-container orphan reconciliation so an uncatchable - host-shim death cannot strand a running or stopped tool container; -2. complete the native state-management subset required for safe supported +1. complete the native state-management subset required for safe supported cleanup and diagnostics; every consumer must construct and match the complete distribution/machine/user identity; -3. Windows-filesystem and WSL-filesystem project tests plus mixed-invocation +2. Windows-filesystem and WSL-filesystem project tests plus mixed-invocation rejection; and -4. real WSL2 + Docker Desktop end-to-end qualification before any support claim. +3. real WSL2 + Docker Desktop end-to-end qualification before any support claim. The WSL runtime deliberately rejects `host_mounts`: that registry field uses a Windows drive-path grammar and silently reinterpreting it as Linux would violate diff --git a/internal/hostenv/hostenv.go b/internal/hostenv/hostenv.go index 7567c00..06895ba 100644 --- a/internal/hostenv/hostenv.go +++ b/internal/hostenv/hostenv.go @@ -51,7 +51,8 @@ func Current() (Runtime, error) { // RequireFrontend enforces the supported host boundary. Native Windows is // enabled. Native WSL2 is classified precisely but remains activation-gated -// until retained-container orphan reconciliation and real qualification land. +// until state-command integration, the integration corpus and real +// qualification land. func RequireFrontend() error { return requireFrontend(Current()) } @@ -76,7 +77,7 @@ func requireFrontend(info Runtime, probeErr error) error { if err := validateDistroIdentity(info.Distro); err != nil { return fmt.Errorf("native WSL2 distribution identity cannot be proven: %w", err) } - return fmt.Errorf("native WSL2 distribution %q was detected; the tool runtime is wired but activation is gated until orphan-container reconciliation and real Docker Desktop qualification land", info.Distro) + return fmt.Errorf("native WSL2 distribution %q was detected; the tool runtime is wired but activation is gated until native state commands, integration coverage and real Docker Desktop qualification land", info.Distro) case WSL1Native: return errors.New("WSL1 is unsupported; the native frontend requires WSL2 and Docker Desktop WSL integration") case WSLUnrecognized: diff --git a/internal/wsldocker/reconcile.go b/internal/wsldocker/reconcile.go new file mode 100644 index 0000000..f28b08b --- /dev/null +++ b/internal/wsldocker/reconcile.go @@ -0,0 +1,153 @@ +package wsldocker + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "net/url" + "sort" +) + +const maxContainerListOutput = 4 << 20 + +// ContainerCandidate is untrusted namespace-filtered discovery output. It must +// be passed to ProveRetainedContainer before any lifecycle mutation. +type ContainerCandidate struct { + id string + runID string + namespace string + tool string +} + +func (c ContainerCandidate) ID() string { return c.id } +func (c ContainerCandidate) RunID() string { return c.runID } +func (c ContainerCandidate) Namespace() string { return c.namespace } +func (c ContainerCandidate) Tool() string { return c.tool } + +type controlExecuteFunc func(context.Context, Request) (Response, error) +type containerInspectFunc func(context.Context, string) (ContainerSnapshot, error) + +// DiscoverRetainedContainers returns discovery-only candidates selected by the +// exact managed-run namespace labels. The Docker list response is never treated +// as ownership proof. +func DiscoverRetainedContainers(ctx context.Context, namespace string) ([]ContainerCandidate, error) { + return discoverRetainedContainers(ctx, namespace, Execute) +} + +// ProveRetainedContainer re-inspects one discovery candidate and returns an +// immutable owned handle only when its labels, retained lifecycle and stdio +// contract exactly match a ContainerBin native-WSL run. A candidate that +// disappeared after discovery is reported as exists=false. +func ProveRetainedContainer(ctx context.Context, candidate ContainerCandidate, namespace string) (Container, ContainerSnapshot, bool, error) { + return proveRetainedContainer(ctx, candidate, namespace, InspectContainer) +} + +func discoverRetainedContainers(ctx context.Context, namespace string, execute controlExecuteFunc) ([]ContainerCandidate, error) { + if ctx == nil { + return nil, errors.New("native WSL retained-container discovery requires a context") + } + if !validWSLNamespace(namespace) { + return nil, fmt.Errorf("native WSL retained-container discovery requires a valid namespace, got %q", namespace) + } + if execute == nil { + return nil, errors.New("native WSL retained-container discovery executor is unavailable") + } + filters, err := json.Marshal(map[string][]string{ + "label": { + "cb.managed=true", + "cb.kind=run", + containerNamespaceLabel + "=" + namespace, + }, + }) + if err != nil { + return nil, fmt.Errorf("encode native WSL retained-container discovery filters: %w", err) + } + response, err := execute(ctx, Request{ + Method: http.MethodGet, + Path: "/containers/json", + Query: url.Values{ + "all": {"true"}, + "filters": {string(filters)}, + }, + SuccessStatuses: []int{http.StatusOK}, + }) + if err != nil { + return nil, fmt.Errorf("discover native WSL retained containers: %w", err) + } + if len(response.Body) > maxContainerListOutput { + return nil, fmt.Errorf("Docker container list response exceeds %d bytes", maxContainerListOutput) + } + var listed []struct { + ID string `json:"Id"` + Labels map[string]string `json:"Labels"` + } + if err := json.Unmarshal(response.Body, &listed); err != nil { + return nil, fmt.Errorf("decode native WSL retained-container list: %w", err) + } + candidates := make([]ContainerCandidate, 0, len(listed)) + seen := make(map[string]bool, len(listed)) + for index, listedContainer := range listed { + candidate, err := decodeContainerCandidate(listedContainer.ID, listedContainer.Labels, namespace) + if err != nil { + return nil, fmt.Errorf("validate native WSL retained-container candidate %d: %w", index, err) + } + if seen[candidate.id] { + return nil, fmt.Errorf("Docker container list returned duplicate ID %s", candidate.id) + } + seen[candidate.id] = true + candidates = append(candidates, candidate) + } + sort.Slice(candidates, func(i, j int) bool { return candidates[i].id < candidates[j].id }) + return candidates, nil +} + +func decodeContainerCandidate(id string, labels map[string]string, namespace string) (ContainerCandidate, error) { + if err := validateContainerID(id); err != nil { + return ContainerCandidate{}, fmt.Errorf("invalid container ID: %w", err) + } + if labels["cb.managed"] != "true" || labels["cb.kind"] != "run" || labels[containerNamespaceLabel] != namespace { + return ContainerCandidate{}, errors.New("candidate does not match the exact managed run namespace labels") + } + runID := labels[containerRunIDLabel] + tool := labels[containerToolLabel] + if !validRunID(runID) || !validRuntimeName(tool) { + return ContainerCandidate{}, errors.New("candidate run or tool identity is invalid") + } + return ContainerCandidate{id: id, runID: runID, namespace: namespace, tool: tool}, nil +} + +func proveRetainedContainer(ctx context.Context, candidate ContainerCandidate, namespace string, inspect containerInspectFunc) (Container, ContainerSnapshot, bool, error) { + if ctx == nil { + return Container{}, ContainerSnapshot{}, false, errors.New("native WSL retained-container proof requires a context") + } + if inspect == nil { + return Container{}, ContainerSnapshot{}, false, errors.New("native WSL retained-container inspector is unavailable") + } + if !validWSLNamespace(namespace) || candidate.namespace != namespace || !validRunID(candidate.runID) || !validRuntimeName(candidate.tool) { + return Container{}, ContainerSnapshot{}, false, errors.New("native WSL retained-container candidate identity is invalid") + } + if err := validateContainerID(candidate.id); err != nil { + return Container{}, ContainerSnapshot{}, false, fmt.Errorf("native WSL retained-container candidate: %w", err) + } + snapshot, err := inspect(ctx, candidate.id) + if err != nil { + var apiError *APIError + if errors.As(err, &apiError) && apiError.StatusCode == http.StatusNotFound { + return Container{}, ContainerSnapshot{}, false, nil + } + return Container{}, ContainerSnapshot{}, false, fmt.Errorf("inspect native WSL retained-container candidate: %w", err) + } + container := Container{ + id: candidate.id, runID: candidate.runID, namespace: namespace, + tool: candidate.tool, retainUntilCleanup: true, + } + if err := requireOwnedContainer(container, snapshot); err != nil { + return Container{}, ContainerSnapshot{}, false, fmt.Errorf("refuse native WSL retained-container adoption: %w", err) + } + if snapshot.AutoRemove() || !snapshot.AttachStdin() || !snapshot.AttachStdout() || !snapshot.AttachStderr() || !snapshot.OpenStdin() || !snapshot.StdinOnce() { + return Container{}, ContainerSnapshot{}, false, errors.New("refuse native WSL retained-container adoption whose retention or stdio contract changed") + } + return container, snapshot, true, nil +} diff --git a/internal/wsldocker/reconcile_test.go b/internal/wsldocker/reconcile_test.go new file mode 100644 index 0000000..00710fb --- /dev/null +++ b/internal/wsldocker/reconcile_test.go @@ -0,0 +1,126 @@ +package wsldocker + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "reflect" + "testing" +) + +func TestDiscoverRetainedContainersUsesExactNamespaceFilters(t *testing.T) { + firstID := "a" + testContainerID[1:] + secondID := "b" + testContainerID[1:] + listed := []map[string]any{ + {"Id": secondID, "Labels": containerLabels(Container{namespace: testWSLNamespace, runID: testRunID, tool: "python313"})}, + {"Id": firstID, "Labels": containerLabels(Container{namespace: testWSLNamespace, runID: "abcdefabcdefabcdefabcdefabcdefab", tool: "node24"})}, + } + raw, err := json.Marshal(listed) + if err != nil { + t.Fatal(err) + } + candidates, err := discoverRetainedContainers(context.Background(), testWSLNamespace, func(_ context.Context, request Request) (Response, error) { + if request.Method != http.MethodGet || request.Path != "/containers/json" || request.Query.Get("all") != "true" { + t.Fatalf("discovery request = %#v", request) + } + var filters map[string][]string + if err := json.Unmarshal([]byte(request.Query.Get("filters")), &filters); err != nil { + t.Fatalf("decode filters: %v", err) + } + want := []string{"cb.managed=true", "cb.kind=run", containerNamespaceLabel + "=" + testWSLNamespace} + if !reflect.DeepEqual(filters["label"], want) { + t.Fatalf("label filters = %#v, want %#v", filters["label"], want) + } + return Response{StatusCode: http.StatusOK, Body: raw}, nil + }) + if err != nil { + t.Fatal(err) + } + if len(candidates) != 2 || candidates[0].ID() != firstID || candidates[1].ID() != secondID { + t.Fatalf("sorted candidates = %#v", candidates) + } +} + +func TestDiscoverRetainedContainersRejectsUntrustedShapes(t *testing.T) { + validLabels := containerLabels(Container{namespace: testWSLNamespace, runID: testRunID, tool: "node24"}) + tests := []struct { + name string + id string + labels map[string]string + }{ + {name: "short ID", id: "short", labels: validLabels}, + {name: "foreign namespace", id: testContainerID, labels: mutateContainerLabels(validLabels, func(labels map[string]string) { + labels[containerNamespaceLabel] = "wsl2-abcdefabcdefabcdefabcdefabcdefab" + })}, + {name: "missing managed", id: testContainerID, labels: mutateContainerLabels(validLabels, func(labels map[string]string) { delete(labels, "cb.managed") })}, + {name: "bad run ID", id: testContainerID, labels: mutateContainerLabels(validLabels, func(labels map[string]string) { labels[containerRunIDLabel] = "bad" })}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + raw, _ := json.Marshal([]map[string]any{{"Id": test.id, "Labels": test.labels}}) + _, err := discoverRetainedContainers(context.Background(), testWSLNamespace, func(context.Context, Request) (Response, error) { + return Response{StatusCode: http.StatusOK, Body: raw}, nil + }) + if err == nil { + t.Fatal("unsafe discovery candidate was accepted") + } + }) + } +} + +func TestProveRetainedContainerRequiresExactRetainedRuntime(t *testing.T) { + candidate := ContainerCandidate{id: testContainerID, runID: testRunID, namespace: testWSLNamespace, tool: "node24"} + wantContainer := Container{id: testContainerID, runID: testRunID, namespace: testWSLNamespace, tool: "node24", retainUntilCleanup: true} + snapshot := ContainerSnapshot{ + id: testContainerID, labels: containerLabels(wantContainer), running: true, + attachStdin: true, attachStdout: true, attachStderr: true, openStdin: true, stdinOnce: true, + } + container, gotSnapshot, exists, err := proveRetainedContainer(context.Background(), candidate, testWSLNamespace, func(context.Context, string) (ContainerSnapshot, error) { + return snapshot, nil + }) + if err != nil || !exists || container != wantContainer || !gotSnapshot.Running() { + t.Fatalf("proof = (%#v, %#v, %t, %v)", container, gotSnapshot, exists, err) + } + + changed := snapshot + changed.autoRemove = true + if _, _, _, err := proveRetainedContainer(context.Background(), candidate, testWSLNamespace, func(context.Context, string) (ContainerSnapshot, error) { + return changed, nil + }); err == nil { + t.Fatal("auto-remove candidate was adopted") + } + changed = snapshot + changed.labels = mutateContainerLabels(snapshot.labels, func(labels map[string]string) { labels[containerToolLabel] = "foreign" }) + if _, _, _, err := proveRetainedContainer(context.Background(), candidate, testWSLNamespace, func(context.Context, string) (ContainerSnapshot, error) { + return changed, nil + }); err == nil { + t.Fatal("mislabeled candidate was adopted") + } +} + +func TestProveRetainedContainerAcceptsDiscoveryRemovalRace(t *testing.T) { + candidate := ContainerCandidate{id: testContainerID, runID: testRunID, namespace: testWSLNamespace, tool: "node24"} + _, _, exists, err := proveRetainedContainer(context.Background(), candidate, testWSLNamespace, func(context.Context, string) (ContainerSnapshot, error) { + return ContainerSnapshot{}, &APIError{Method: http.MethodGet, Path: "/containers/" + testContainerID + "/json", StatusCode: http.StatusNotFound} + }) + if err != nil || exists { + t.Fatalf("disappeared candidate = exists %t, error %v", exists, err) + } +} + +func mutateContainerLabels(labels map[string]string, mutate func(map[string]string)) map[string]string { + cloned := cloneContainerLabels(labels) + mutate(cloned) + return cloned +} + +func TestDiscoverRetainedContainersPropagatesExecutorFailure(t *testing.T) { + want := errors.New("engine unavailable") + _, err := discoverRetainedContainers(context.Background(), testWSLNamespace, func(context.Context, Request) (Response, error) { + return Response{}, want + }) + if !errors.Is(err, want) { + t.Fatalf("discovery error = %v, want executor failure", err) + } +} diff --git a/internal/wslinstall/install.go b/internal/wslinstall/install.go index a4542b9..36fb0da 100644 --- a/internal/wslinstall/install.go +++ b/internal/wslinstall/install.go @@ -303,7 +303,7 @@ func printPlan(out io.Writer, plan Plan, applied bool) error { } if plan.ready() { fmt.Fprintln(&report, "status: INSTALLATION READY") - fmt.Fprintln(&report, "frontend: INSTALLED; RUNTIME WIRED; ACTIVATION GATED (orphan reconciliation and real WSL qualification remain)") + fmt.Fprintln(&report, "frontend: INSTALLED; RUNTIME WIRED; ACTIVATION GATED (state commands, integration coverage and real WSL qualification remain)") } else { fmt.Fprintln(&report, "status: APPLY REQUIRED") fmt.Fprintln(&report, "apply: cb wsl install --apply") diff --git a/internal/wslreconcile/command.go b/internal/wslreconcile/command.go new file mode 100644 index 0000000..b80f8b2 --- /dev/null +++ b/internal/wslreconcile/command.go @@ -0,0 +1,91 @@ +package wslreconcile + +import ( + "context" + "errors" + "fmt" + "io" + "strings" + + "github.com/AviBackToBlack/container-bin/internal/hostenv" + "github.com/AviBackToBlack/container-bin/internal/wslfs" +) + +type command struct { + currentLayout func() (hostenv.WSLLayout, error) + checkLayout func(hostenv.WSLLayout) (wslfs.Plan, error) + reconcile func(context.Context, hostenv.WSLLayout, bool) (Report, error) +} + +// Run checks or applies proof-bound retained-container reconciliation for the +// current native-WSL namespace. +func Run(ctx context.Context, args []string, out io.Writer) error { + return (command{ + currentLayout: wslfs.CurrentLayout, + checkLayout: wslfs.Check, + reconcile: func(ctx context.Context, layout hostenv.WSLLayout, apply bool) (Report, error) { + return reconcile(ctx, layout, apply, productionDependencies()) + }, + }).run(ctx, args, out) +} + +func (c command) run(ctx context.Context, args []string, out io.Writer) error { + if len(args) != 1 || (args[0] != "--check" && args[0] != "--apply") { + return errors.New("usage: cb wsl cleanup (--check | --apply)") + } + if ctx == nil || out == nil { + return errors.New("native WSL cleanup requires a context and output writer") + } + if c.currentLayout == nil || c.checkLayout == nil || c.reconcile == nil { + return errors.New("native WSL cleanup command is incomplete") + } + layout, err := c.currentLayout() + if err != nil { + return err + } + plan, err := c.checkLayout(layout) + if err != nil { + return err + } + if plan.Layout != layout || len(plan.MissingDirectories) != 0 { + return errors.New("native WSL cleanup requires a complete, exact managed layout; run cb wsl prepare --apply") + } + report, err := c.reconcile(ctx, layout, args[0] == "--apply") + if err != nil { + return err + } + if report.Namespace != layout.StateNamespace || report.Applied != (args[0] == "--apply") { + return errors.New("native WSL cleanup returned an inconsistent report") + } + return printReport(out, report) +} + +func printReport(out io.Writer, report Report) error { + var text strings.Builder + if report.Applied { + fmt.Fprintln(&text, "native WSL retained-container cleanup (apply)") + } else { + fmt.Fprintln(&text, "native WSL retained-container cleanup (read-only check)") + } + fmt.Fprintf(&text, "namespace: %s\n", report.Namespace) + for _, entry := range report.Entries { + status := "orphan-stopped" + if entry.Active { + status = "active" + } else if entry.Running { + status = "orphan-running" + } + if entry.Removed { + status = "reconciled" + } + fmt.Fprintf(&text, "%s: container=%s run=%s tool=%s\n", status, entry.ContainerID, entry.RunID, entry.Tool) + } + fmt.Fprintf(&text, "totals: active=%d orphaned=%d reconciled=%d\n", report.ActiveCount(), report.OrphanCount(), report.RemovedCount()) + if !report.Applied && report.OrphanCount() != 0 { + fmt.Fprintln(&text, "apply: cb wsl cleanup --apply") + } + if _, err := io.WriteString(out, text.String()); err != nil { + return fmt.Errorf("write native WSL cleanup report: %w", err) + } + return nil +} diff --git a/internal/wslreconcile/lease_linux.go b/internal/wslreconcile/lease_linux.go new file mode 100644 index 0000000..6af5c88 --- /dev/null +++ b/internal/wslreconcile/lease_linux.go @@ -0,0 +1,229 @@ +//go:build linux + +package wslreconcile + +import ( + "context" + "errors" + "fmt" + "os" + "strings" + "syscall" + "time" + + "github.com/AviBackToBlack/container-bin/internal/hostenv" +) + +const ( + lockRetryDelay = 25 * time.Millisecond +) + +type fileCoordinator struct { + file *os.File +} + +type fileLease struct { + file *os.File + dir *os.File + name string + dev uint64 + ino uint64 + removed bool +} + +func acquireFileCoordinator(ctx context.Context, layout hostenv.WSLLayout) (coordinator, error) { + dir, _, err := openStateDirectory(layout) + if err != nil { + return nil, err + } + // flock works on the already-open distribution-local directory. Using the + // directory itself keeps --check genuinely read-only while still serializing + // discovery against create-to-lease publication. + if err := lockContext(ctx, dir); err != nil { + return nil, errors.Join(err, dir.Close()) + } + return &fileCoordinator{file: dir}, nil +} + +func createFileLease(layout hostenv.WSLLayout, runID string) (lease, error) { + name, err := leaseName(runID) + if err != nil { + return nil, err + } + dir, state, err := openStateDirectory(layout) + if err != nil { + return nil, err + } + file, stat, err := openManagedLockFile(dir, state, name, syscall.O_RDWR|syscall.O_CREAT|syscall.O_EXCL, 0o600) + if err != nil { + dir.Close() + return nil, err + } + if err := syscall.Flock(int(file.Fd()), syscall.LOCK_EX|syscall.LOCK_NB); err != nil { + cleanupErr := syscall.Unlinkat(int(dir.Fd()), name) + return nil, errors.Join(fmt.Errorf("lock newly created native WSL runtime lease: %w", err), cleanupErr, file.Close(), dir.Close()) + } + return &fileLease{file: file, dir: dir, name: name, dev: uint64(stat.Dev), ino: stat.Ino}, nil +} + +func probeFileLease(layout hostenv.WSLLayout, runID string) (leaseStatus, lease, error) { + name, err := leaseName(runID) + if err != nil { + return leaseMissing, nil, err + } + dir, state, err := openStateDirectory(layout) + if err != nil { + return leaseMissing, nil, err + } + file, stat, err := openManagedLockFile(dir, state, name, syscall.O_RDWR, 0) + if errors.Is(err, syscall.ENOENT) { + dir.Close() + return leaseMissing, nil, nil + } + if err != nil { + dir.Close() + return leaseMissing, nil, err + } + if err := syscall.Flock(int(file.Fd()), syscall.LOCK_EX|syscall.LOCK_NB); err != nil { + closeErr := errors.Join(file.Close(), dir.Close()) + if errors.Is(err, syscall.EWOULDBLOCK) || errors.Is(err, syscall.EAGAIN) { + return leaseActive, nil, closeErr + } + return leaseMissing, nil, errors.Join(fmt.Errorf("probe native WSL runtime lease lock: %w", err), closeErr) + } + return leaseOrphaned, &fileLease{file: file, dir: dir, name: name, dev: uint64(stat.Dev), ino: stat.Ino}, nil +} + +func openStateDirectory(layout hostenv.WSLLayout) (*os.File, *syscall.Stat_t, error) { + if layout.StateDir == "" || !strings.HasPrefix(layout.StateDir, "/") || layout.UID != uint32(os.Geteuid()) { + return nil, nil, errors.New("native WSL runtime state identity is invalid for the current user") + } + fd, err := syscall.Open(layout.StateDir, syscall.O_RDONLY|syscall.O_DIRECTORY|syscall.O_NOFOLLOW|syscall.O_CLOEXEC, 0) + if err != nil { + return nil, nil, fmt.Errorf("open native WSL runtime state directory %s: %w", layout.StateDir, err) + } + dir := os.NewFile(uintptr(fd), layout.StateDir) + stat := new(syscall.Stat_t) + if err := syscall.Fstat(fd, stat); err != nil { + return nil, nil, errors.Join(fmt.Errorf("inspect native WSL runtime state directory: %w", err), dir.Close()) + } + if stat.Mode&syscall.S_IFMT != syscall.S_IFDIR || stat.Uid != layout.UID || stat.Mode&0o777 != 0o700 { + return nil, nil, errors.Join(errors.New("native WSL runtime state directory must be an owner-only 0700 directory"), dir.Close()) + } + return dir, stat, nil +} + +func openManagedLockFile(dir *os.File, state *syscall.Stat_t, name string, flags int, mode uint32) (*os.File, *syscall.Stat_t, error) { + fd, err := syscall.Openat(int(dir.Fd()), name, flags|syscall.O_NOFOLLOW|syscall.O_CLOEXEC, mode) + if err != nil { + return nil, nil, fmt.Errorf("open native WSL runtime lock %s: %w", name, err) + } + file := os.NewFile(uintptr(fd), name) + cleanupCreated := func() error { + if flags&syscall.O_EXCL == 0 { + return nil + } + err := syscall.Unlinkat(int(dir.Fd()), name) + if errors.Is(err, syscall.ENOENT) { + return nil + } + return err + } + stat := new(syscall.Stat_t) + if err := syscall.Fstat(fd, stat); err != nil { + return nil, nil, errors.Join(fmt.Errorf("inspect native WSL runtime lock %s: %w", name, err), cleanupCreated(), file.Close()) + } + if stat.Mode&syscall.S_IFMT != syscall.S_IFREG || stat.Uid != state.Uid || stat.Mode&0o777 != 0o600 || uint64(stat.Dev) != uint64(state.Dev) || stat.Nlink != 1 { + return nil, nil, errors.Join(fmt.Errorf("native WSL runtime lock %s must be a same-device owner-only 0600 regular file with one link", name), cleanupCreated(), file.Close()) + } + return file, stat, nil +} + +func lockContext(ctx context.Context, file *os.File) error { + for { + err := syscall.Flock(int(file.Fd()), syscall.LOCK_EX|syscall.LOCK_NB) + if err == nil { + return nil + } + if !errors.Is(err, syscall.EWOULDBLOCK) && !errors.Is(err, syscall.EAGAIN) { + return fmt.Errorf("lock native WSL runtime coordinator: %w", err) + } + timer := time.NewTimer(lockRetryDelay) + select { + case <-ctx.Done(): + if !timer.Stop() { + <-timer.C + } + return fmt.Errorf("wait for native WSL runtime coordinator: %w", ctx.Err()) + case <-timer.C: + } + } +} + +func leaseName(runID string) (string, error) { + if len(runID) != 32 { + return "", errors.New("native WSL runtime lease requires a 32-character run identity") + } + for _, char := range runID { + if (char < '0' || char > '9') && (char < 'a' || char > 'f') { + return "", errors.New("native WSL runtime lease requires a lowercase hexadecimal run identity") + } + } + return "run-" + runID + ".lease", nil +} + +func (c *fileCoordinator) Close() error { + if c == nil || c.file == nil { + return nil + } + err := errors.Join( + syscall.Flock(int(c.file.Fd()), syscall.LOCK_UN), + c.file.Close(), + ) + c.file = nil + return err +} + +func (l *fileLease) Remove() error { + if l == nil || l.removed { + return nil + } + fd, err := syscall.Openat(int(l.dir.Fd()), l.name, syscall.O_RDONLY|syscall.O_NOFOLLOW|syscall.O_CLOEXEC, 0) + if errors.Is(err, syscall.ENOENT) { + l.removed = true + return nil + } + if err != nil { + return fmt.Errorf("reopen native WSL runtime lease %s before removal: %w", l.name, err) + } + current := os.NewFile(uintptr(fd), l.name) + stat := new(syscall.Stat_t) + if err := syscall.Fstat(fd, stat); err != nil { + return errors.Join(fmt.Errorf("inspect native WSL runtime lease %s before removal: %w", l.name, err), current.Close()) + } + if uint64(stat.Dev) != l.dev || stat.Ino != l.ino { + return errors.Join(fmt.Errorf("refuse to remove replaced native WSL runtime lease %s", l.name), current.Close()) + } + if err := current.Close(); err != nil { + return err + } + if err := syscall.Unlinkat(int(l.dir.Fd()), l.name); err != nil && !errors.Is(err, syscall.ENOENT) { + return fmt.Errorf("remove native WSL runtime lease %s: %w", l.name, err) + } + l.removed = true + return nil +} + +func (l *fileLease) Close() error { + if l == nil || l.file == nil { + return nil + } + err := errors.Join( + syscall.Flock(int(l.file.Fd()), syscall.LOCK_UN), + l.file.Close(), + l.dir.Close(), + ) + l.file = nil + l.dir = nil + return err +} diff --git a/internal/wslreconcile/lease_linux_test.go b/internal/wslreconcile/lease_linux_test.go new file mode 100644 index 0000000..2ef70d2 --- /dev/null +++ b/internal/wslreconcile/lease_linux_test.go @@ -0,0 +1,106 @@ +//go:build linux + +package wslreconcile + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/AviBackToBlack/container-bin/internal/hostenv" +) + +func leaseTestLayout(t *testing.T) hostenv.WSLLayout { + t.Helper() + dir := t.TempDir() + if err := os.Chmod(dir, 0o700); err != nil { + t.Fatal(err) + } + return hostenv.WSLLayout{UID: uint32(os.Geteuid()), StateDir: dir} +} + +func TestFileLeaseLifecycle(t *testing.T) { + layout := leaseTestLayout(t) + runID := strings.Repeat("a", 32) + held, err := createFileLease(layout, runID) + if err != nil { + t.Fatal(err) + } + status, observed, err := probeFileLease(layout, runID) + if err != nil || status != leaseActive || observed != nil { + t.Fatalf("held lease probe = (%v, %v, %v), want active", status, observed, err) + } + if err := held.Close(); err != nil { + t.Fatal(err) + } + status, observed, err = probeFileLease(layout, runID) + if err != nil || status != leaseOrphaned || observed == nil { + t.Fatalf("released lease probe = (%v, %v, %v), want orphaned", status, observed, err) + } + if err := observed.Remove(); err != nil { + t.Fatal(err) + } + if err := observed.Close(); err != nil { + t.Fatal(err) + } + status, observed, err = probeFileLease(layout, runID) + if err != nil || status != leaseMissing || observed != nil { + t.Fatalf("removed lease probe = (%v, %v, %v), want missing", status, observed, err) + } +} + +func TestFileLeaseRejectsUnsafePaths(t *testing.T) { + layout := leaseTestLayout(t) + runID := strings.Repeat("b", 32) + name, err := leaseName(runID) + if err != nil { + t.Fatal(err) + } + target := filepath.Join(layout.StateDir, "target") + if err := os.WriteFile(target, nil, 0o600); err != nil { + t.Fatal(err) + } + if err := os.Symlink(target, filepath.Join(layout.StateDir, name)); err != nil { + t.Fatal(err) + } + if _, _, err := probeFileLease(layout, runID); err == nil { + t.Fatal("symlink lease was accepted") + } + if err := os.Remove(filepath.Join(layout.StateDir, name)); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(layout.StateDir, name), nil, 0o644); err != nil { + t.Fatal(err) + } + if _, _, err := probeFileLease(layout, runID); err == nil { + t.Fatal("world-readable lease was accepted") + } + if _, err := createFileLease(layout, "not-a-run-id"); err == nil { + t.Fatal("invalid run identity was accepted") + } +} + +func TestFileCoordinatorHonorsContext(t *testing.T) { + layout := leaseTestLayout(t) + first, err := acquireFileCoordinator(context.Background(), layout) + if err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithCancel(context.Background()) + cancel() + if _, err := acquireFileCoordinator(ctx, layout); err == nil { + t.Fatal("second coordinator unexpectedly acquired held lock") + } + if err := first.Close(); err != nil { + t.Fatal(err) + } + entries, err := os.ReadDir(layout.StateDir) + if err != nil { + t.Fatal(err) + } + if len(entries) != 0 { + t.Fatalf("read-only coordinator created state entries: %v", entries) + } +} diff --git a/internal/wslreconcile/lease_other.go b/internal/wslreconcile/lease_other.go new file mode 100644 index 0000000..3c18686 --- /dev/null +++ b/internal/wslreconcile/lease_other.go @@ -0,0 +1,24 @@ +//go:build !linux + +package wslreconcile + +import ( + "context" + "errors" + + "github.com/AviBackToBlack/container-bin/internal/hostenv" +) + +var errLinuxLeaseRequired = errors.New("native WSL runtime leases require Linux") + +func acquireFileCoordinator(context.Context, hostenv.WSLLayout) (coordinator, error) { + return nil, errLinuxLeaseRequired +} + +func createFileLease(hostenv.WSLLayout, string) (lease, error) { + return nil, errLinuxLeaseRequired +} + +func probeFileLease(hostenv.WSLLayout, string) (leaseStatus, lease, error) { + return leaseMissing, nil, errLinuxLeaseRequired +} diff --git a/internal/wslreconcile/production_linux.go b/internal/wslreconcile/production_linux.go new file mode 100644 index 0000000..7f14463 --- /dev/null +++ b/internal/wslreconcile/production_linux.go @@ -0,0 +1,49 @@ +//go:build linux + +package wslreconcile + +import ( + "context" + + "github.com/AviBackToBlack/container-bin/internal/wsldocker" +) + +func productionDependencies() dependencies { + return dependencies{ + acquireCoordinator: acquireFileCoordinator, + createLease: createFileLease, + probeLease: probeFileLease, + discover: func(ctx context.Context, namespace string) ([]candidate, error) { + discovered, err := wsldocker.DiscoverRetainedContainers(ctx, namespace) + if err != nil { + return nil, err + } + candidates := make([]candidate, 0, len(discovered)) + for _, item := range discovered { + candidates = append(candidates, candidate{ + id: item.ID(), runID: item.RunID(), tool: item.Tool(), docker: item, + }) + } + return candidates, nil + }, + prove: func(ctx context.Context, item candidate, namespace string) (retainedContainer, bool, error) { + container, snapshot, exists, err := wsldocker.ProveRetainedContainer(ctx, item.docker, namespace) + if err != nil || !exists { + return retainedContainer{}, exists, err + } + return retainedContainer{ + id: container.ID(), runID: container.RunID(), tool: container.Tool(), + running: snapshot.Running(), docker: container, + }, true, nil + }, + signal: func(ctx context.Context, container retainedContainer, signal int) error { + return wsldocker.SignalContainer(ctx, container.id, signal) + }, + wait: func(ctx context.Context, container retainedContainer) (int, error) { + return wsldocker.WaitContainer(ctx, container.id) + }, + remove: func(ctx context.Context, container retainedContainer) error { + return wsldocker.RemoveContainer(ctx, container.docker) + }, + } +} diff --git a/internal/wslreconcile/production_other.go b/internal/wslreconcile/production_other.go new file mode 100644 index 0000000..f3846ff --- /dev/null +++ b/internal/wslreconcile/production_other.go @@ -0,0 +1,7 @@ +//go:build !linux + +package wslreconcile + +// Non-Linux builds retain the command surface for cross-compilation, while +// incomplete dependencies make any accidental execution fail closed. +func productionDependencies() dependencies { return dependencies{} } diff --git a/internal/wslreconcile/reconcile.go b/internal/wslreconcile/reconcile.go new file mode 100644 index 0000000..bb86eb1 --- /dev/null +++ b/internal/wslreconcile/reconcile.go @@ -0,0 +1,317 @@ +// Package wslreconcile safely reconciles retained native-WSL runtime +// containers whose owning ContainerBin process no longer exists. +package wslreconcile + +import ( + "context" + "errors" + "fmt" + "net/http" + "sort" + "time" + + "github.com/AviBackToBlack/container-bin/internal/hostenv" + "github.com/AviBackToBlack/container-bin/internal/wsldocker" +) + +const reconcileTimeout = 30 * time.Second + +type coordinator interface { + Close() error +} + +type lease interface { + Remove() error + Close() error +} + +type leaseStatus uint8 + +const ( + leaseMissing leaseStatus = iota + leaseActive + leaseOrphaned +) + +type candidate struct { + id string + runID string + tool string + docker wsldocker.ContainerCandidate +} + +type retainedContainer struct { + id string + runID string + tool string + running bool + docker wsldocker.Container +} + +type dependencies struct { + acquireCoordinator func(context.Context, hostenv.WSLLayout) (coordinator, error) + createLease func(hostenv.WSLLayout, string) (lease, error) + probeLease func(hostenv.WSLLayout, string) (leaseStatus, lease, error) + discover func(context.Context, string) ([]candidate, error) + prove func(context.Context, candidate, string) (retainedContainer, bool, error) + signal func(context.Context, retainedContainer, int) error + wait func(context.Context, retainedContainer) (int, error) + remove func(context.Context, retainedContainer) error +} + +// Entry is one exact retained runtime classified by reconciliation. +type Entry struct { + ContainerID string + RunID string + Tool string + Running bool + Active bool + Removed bool +} + +// Report is the deterministic result of one namespace reconciliation pass. +type Report struct { + Namespace string + Applied bool + Entries []Entry +} + +// ActiveCount returns the number of retained containers with a locked lease. +func (r Report) ActiveCount() int { + count := 0 + for _, entry := range r.Entries { + if entry.Active { + count++ + } + } + return count +} + +// OrphanCount returns the number of retained containers without an active lease. +func (r Report) OrphanCount() int { + return len(r.Entries) - r.ActiveCount() +} + +// RemovedCount returns the number of orphaned containers removed by this pass. +func (r Report) RemovedCount() int { + count := 0 + for _, entry := range r.Entries { + if entry.Removed { + count++ + } + } + return count +} + +// RunGuard holds the namespace coordinator until a newly created container +// has a process-held lease. Its Close method deliberately leaves the lease +// path behind when exact container removal was not proven. +type RunGuard struct { + layout hostenv.WSLLayout + deps dependencies + coordinator coordinator + lease lease + closed bool +} + +// BeginRun reconciles prior orphans while holding the namespace coordinator, +// then keeps that coordinator until Adopt publishes the new run lease. +func BeginRun(ctx context.Context, layout hostenv.WSLLayout) (*RunGuard, error) { + return beginRun(ctx, layout, productionDependencies()) +} + +func beginRun(ctx context.Context, layout hostenv.WSLLayout, deps dependencies) (*RunGuard, error) { + if ctx == nil { + return nil, errors.New("native WSL runtime reconciliation requires a context") + } + if err := validateDependencies(deps); err != nil { + return nil, err + } + coordinator, err := deps.acquireCoordinator(ctx, layout) + if err != nil { + return nil, fmt.Errorf("acquire native WSL runtime coordinator: %w", err) + } + guard := &RunGuard{layout: layout, deps: deps, coordinator: coordinator} + if _, err := reconcileLocked(ctx, layout, true, deps); err != nil { + return nil, errors.Join(err, guard.Close(false)) + } + return guard, nil +} + +// Adopt creates and locks the lease for the exact Docker run identity before +// releasing the namespace coordinator. +func (g *RunGuard) Adopt(runID string) error { + if g == nil || g.closed { + return errors.New("native WSL runtime guard is not active") + } + if g.lease != nil { + return errors.New("native WSL runtime guard already adopted a run") + } + created, err := g.deps.createLease(g.layout, runID) + if err != nil { + return fmt.Errorf("create native WSL runtime lease: %w", err) + } + g.lease = created + if err := g.coordinator.Close(); err != nil { + g.coordinator = nil + cleanupErr := errors.Join(created.Remove(), created.Close()) + g.lease = nil + return errors.Join(fmt.Errorf("release native WSL runtime coordinator: %w", err), cleanupErr) + } + g.coordinator = nil + return nil +} + +// Close releases this process's lease. The lease pathname is removed only +// after the caller proves the retained container is gone. +func (g *RunGuard) Close(containerGone bool) error { + if g == nil || g.closed { + return nil + } + g.closed = true + var errs []error + if g.lease != nil { + if containerGone { + errs = append(errs, g.lease.Remove()) + } + errs = append(errs, g.lease.Close()) + g.lease = nil + } + if g.coordinator != nil { + errs = append(errs, g.coordinator.Close()) + g.coordinator = nil + } + return errors.Join(errs...) +} + +func reconcile(ctx context.Context, layout hostenv.WSLLayout, apply bool, deps dependencies) (Report, error) { + if ctx == nil { + return Report{}, errors.New("native WSL runtime reconciliation requires a context") + } + if err := validateDependencies(deps); err != nil { + return Report{}, err + } + coordinator, err := deps.acquireCoordinator(ctx, layout) + if err != nil { + return Report{}, fmt.Errorf("acquire native WSL runtime coordinator: %w", err) + } + report, reconcileErr := reconcileLocked(ctx, layout, apply, deps) + return report, errors.Join(reconcileErr, coordinator.Close()) +} + +type classified struct { + container retainedContainer + active bool + lease lease +} + +func reconcileLocked(ctx context.Context, layout hostenv.WSLLayout, apply bool, deps dependencies) (report Report, err error) { + report = Report{Namespace: layout.StateNamespace, Applied: apply} + candidates, err := deps.discover(ctx, layout.StateNamespace) + if err != nil { + return report, fmt.Errorf("discover retained native WSL containers: %w", err) + } + classifiedContainers := make([]classified, 0, len(candidates)) + defer func() { + for _, item := range classifiedContainers { + if item.lease != nil { + err = errors.Join(err, item.lease.Close()) + } + } + }() + + // Complete every ownership and lease proof before the first mutation. + for _, discovered := range candidates { + container, exists, proveErr := deps.prove(ctx, discovered, layout.StateNamespace) + if proveErr != nil { + return report, fmt.Errorf("prove retained native WSL container %s: %w", discovered.id, proveErr) + } + if !exists { + continue + } + status, heldLease, probeErr := deps.probeLease(layout, container.runID) + if probeErr != nil { + return report, fmt.Errorf("probe native WSL runtime lease %s: %w", container.runID, probeErr) + } + if status != leaseMissing && status != leaseActive && status != leaseOrphaned { + return report, errors.New("native WSL runtime lease probe returned an invalid status") + } + if status == leaseActive && heldLease != nil { + return report, errors.New("active native WSL runtime lease unexpectedly returned a handle") + } + if status == leaseOrphaned && heldLease == nil { + return report, errors.New("orphaned native WSL runtime lease did not return its lock") + } + classifiedContainers = append(classifiedContainers, classified{ + container: container, + active: status == leaseActive, + lease: heldLease, + }) + } + + sort.Slice(classifiedContainers, func(i, j int) bool { + return classifiedContainers[i].container.id < classifiedContainers[j].container.id + }) + for i := range classifiedContainers { + item := &classifiedContainers[i] + entry := Entry{ + ContainerID: item.container.id, + RunID: item.container.runID, + Tool: item.container.tool, + Running: item.container.running, + Active: item.active, + } + if apply && !item.active { + gone, cleanupErr := reconcileOrphan(ctx, item.container, deps) + if cleanupErr != nil { + return report, cleanupErr + } + if !gone { + return report, fmt.Errorf("native WSL orphan container %s cleanup did not prove absence", item.container.id) + } + if item.lease != nil { + if removeErr := item.lease.Remove(); removeErr != nil { + return report, fmt.Errorf("remove reconciled native WSL runtime lease %s: %w", item.container.runID, removeErr) + } + } + entry.Removed = true + } + report.Entries = append(report.Entries, entry) + } + return report, nil +} + +func reconcileOrphan(ctx context.Context, container retainedContainer, deps dependencies) (bool, error) { + operationContext, cancel := context.WithTimeout(ctx, reconcileTimeout) + defer cancel() + if container.running { + if err := deps.signal(operationContext, container, 9); err != nil { + if isAPIStatus(err, http.StatusNotFound) { + return true, nil + } + if !isAPIStatus(err, http.StatusConflict) { + return false, fmt.Errorf("stop orphaned native WSL container %s: %w", container.id, err) + } + } + if _, err := deps.wait(operationContext, container); err != nil && !isAPIStatus(err, http.StatusNotFound) { + return false, fmt.Errorf("wait for orphaned native WSL container %s: %w", container.id, err) + } + } + if err := deps.remove(operationContext, container); err != nil { + return false, fmt.Errorf("remove orphaned native WSL container %s: %w", container.id, err) + } + return true, nil +} + +func validateDependencies(deps dependencies) error { + if deps.acquireCoordinator == nil || deps.createLease == nil || deps.probeLease == nil || + deps.discover == nil || deps.prove == nil || deps.signal == nil || deps.wait == nil || deps.remove == nil { + return errors.New("native WSL runtime reconciliation dependencies are incomplete") + } + return nil +} + +func isAPIStatus(err error, status int) bool { + var apiError *wsldocker.APIError + return errors.As(err, &apiError) && apiError.StatusCode == status +} diff --git a/internal/wslreconcile/reconcile_test.go b/internal/wslreconcile/reconcile_test.go new file mode 100644 index 0000000..cb9ee9a --- /dev/null +++ b/internal/wslreconcile/reconcile_test.go @@ -0,0 +1,230 @@ +package wslreconcile + +import ( + "bytes" + "context" + "errors" + "net/http" + "strings" + "testing" + + "github.com/AviBackToBlack/container-bin/internal/hostenv" + "github.com/AviBackToBlack/container-bin/internal/wsldocker" + "github.com/AviBackToBlack/container-bin/internal/wslfs" +) + +type fakeCoordinator struct { + closed *int +} + +func (f *fakeCoordinator) Close() error { + *f.closed++ + return nil +} + +type fakeLease struct { + removed *int + closed *int +} + +func (f *fakeLease) Remove() error { + *f.removed++ + return nil +} + +func (f *fakeLease) Close() error { + *f.closed++ + return nil +} + +func testLayout() hostenv.WSLLayout { + return hostenv.WSLLayout{StateNamespace: "wsl2-0123456789abcdef0123456789abcdef", StateDir: "/home/test/.local/state/container-bin"} +} + +func testCandidate(index string, running bool) (candidate, retainedContainer) { + id := strings.Repeat(index, 64) + runID := strings.Repeat(index, 32) + return candidate{id: id, runID: runID, tool: "go"}, retainedContainer{id: id, runID: runID, tool: "go", running: running} +} + +func testDependencies(items []candidate, containers map[string]retainedContainer, statuses map[string]leaseStatus, event *[]string) dependencies { + return dependencies{ + acquireCoordinator: func(context.Context, hostenv.WSLLayout) (coordinator, error) { + *event = append(*event, "coordinator") + return &fakeCoordinator{closed: new(int)}, nil + }, + createLease: func(_ hostenv.WSLLayout, runID string) (lease, error) { + *event = append(*event, "lease:"+runID) + return &fakeLease{removed: new(int), closed: new(int)}, nil + }, + probeLease: func(_ hostenv.WSLLayout, runID string) (leaseStatus, lease, error) { + status := statuses[runID] + *event = append(*event, "probe:"+runID) + if status == leaseOrphaned { + return status, &fakeLease{removed: new(int), closed: new(int)}, nil + } + return status, nil, nil + }, + discover: func(context.Context, string) ([]candidate, error) { return items, nil }, + prove: func(_ context.Context, item candidate, _ string) (retainedContainer, bool, error) { + container, ok := containers[item.id] + return container, ok, nil + }, + signal: func(_ context.Context, item retainedContainer, signal int) error { + *event = append(*event, "signal:"+item.runID) + if signal != 9 { + return errors.New("unexpected signal") + } + return nil + }, + wait: func(_ context.Context, item retainedContainer) (int, error) { + *event = append(*event, "wait:"+item.runID) + return 137, nil + }, + remove: func(_ context.Context, item retainedContainer) error { + *event = append(*event, "remove:"+item.runID) + return nil + }, + } +} + +func TestReconcilePreservesActiveAndRemovesOrphans(t *testing.T) { + activeCandidate, active := testCandidate("a", true) + runningCandidate, running := testCandidate("b", true) + stoppedCandidate, stopped := testCandidate("c", false) + items := []candidate{stoppedCandidate, activeCandidate, runningCandidate} + containers := map[string]retainedContainer{active.id: active, running.id: running, stopped.id: stopped} + statuses := map[string]leaseStatus{active.runID: leaseActive, running.runID: leaseMissing, stopped.runID: leaseOrphaned} + var events []string + + report, err := reconcile(context.Background(), testLayout(), true, testDependencies(items, containers, statuses, &events)) + if err != nil { + t.Fatal(err) + } + if report.ActiveCount() != 1 || report.OrphanCount() != 2 || report.RemovedCount() != 2 { + t.Fatalf("unexpected report: %+v", report) + } + joined := strings.Join(events, ",") + if strings.Contains(joined, "signal:"+active.runID) || strings.Contains(joined, "remove:"+active.runID) { + t.Fatalf("active runtime was mutated: %s", joined) + } + for _, want := range []string{"signal:" + running.runID, "wait:" + running.runID, "remove:" + running.runID, "remove:" + stopped.runID} { + if !strings.Contains(joined, want) { + t.Errorf("missing event %q in %s", want, joined) + } + } +} + +func TestReconcileCheckIsReadOnly(t *testing.T) { + item, container := testCandidate("d", true) + var events []string + report, err := reconcile(context.Background(), testLayout(), false, testDependencies( + []candidate{item}, map[string]retainedContainer{item.id: container}, map[string]leaseStatus{item.runID: leaseMissing}, &events, + )) + if err != nil { + t.Fatal(err) + } + if report.RemovedCount() != 0 || strings.Contains(strings.Join(events, ","), "signal:") || strings.Contains(strings.Join(events, ","), "remove:") { + t.Fatalf("check mode mutated state: report=%+v events=%v", report, events) + } +} + +func TestReconcileCompletesAllProofsBeforeMutation(t *testing.T) { + firstCandidate, first := testCandidate("e", false) + secondCandidate, _ := testCandidate("f", false) + var events []string + deps := testDependencies([]candidate{firstCandidate, secondCandidate}, map[string]retainedContainer{first.id: first}, map[string]leaseStatus{first.runID: leaseMissing}, &events) + deps.prove = func(_ context.Context, item candidate, _ string) (retainedContainer, bool, error) { + if item.id == secondCandidate.id { + return retainedContainer{}, false, errors.New("ambiguous ownership") + } + return first, true, nil + } + if _, err := reconcile(context.Background(), testLayout(), true, deps); err == nil || !strings.Contains(err.Error(), "ambiguous ownership") { + t.Fatalf("expected proof error, got %v", err) + } + if strings.Contains(strings.Join(events, ","), "remove:") { + t.Fatalf("mutation occurred before complete preflight: %v", events) + } +} + +func TestReconcileToleratesRunningCompletionRaces(t *testing.T) { + item, container := testCandidate("1", true) + for _, status := range []int{http.StatusNotFound, http.StatusConflict} { + t.Run(http.StatusText(status), func(t *testing.T) { + var events []string + deps := testDependencies([]candidate{item}, map[string]retainedContainer{item.id: container}, map[string]leaseStatus{item.runID: leaseMissing}, &events) + deps.signal = func(context.Context, retainedContainer, int) error { + return &wsldocker.APIError{StatusCode: status} + } + report, err := reconcile(context.Background(), testLayout(), true, deps) + if err != nil { + t.Fatal(err) + } + if report.RemovedCount() != 1 { + t.Fatalf("orphan was not reconciled: %+v", report) + } + }) + } +} + +func TestBeginRunHoldsCoordinatorUntilLeasePublication(t *testing.T) { + var events []string + closed := 0 + removed := 0 + leaseClosed := 0 + deps := testDependencies(nil, nil, nil, &events) + deps.acquireCoordinator = func(context.Context, hostenv.WSLLayout) (coordinator, error) { + events = append(events, "coordinator") + return &fakeCoordinator{closed: &closed}, nil + } + deps.createLease = func(_ hostenv.WSLLayout, runID string) (lease, error) { + if closed != 0 { + t.Fatal("coordinator released before lease publication") + } + events = append(events, "lease:"+runID) + return &fakeLease{removed: &removed, closed: &leaseClosed}, nil + } + guard, err := beginRun(context.Background(), testLayout(), deps) + if err != nil { + t.Fatal(err) + } + if closed != 0 { + t.Fatal("coordinator was not retained after reconciliation") + } + runID := strings.Repeat("2", 32) + if err := guard.Adopt(runID); err != nil { + t.Fatal(err) + } + if closed != 1 { + t.Fatalf("coordinator close count = %d, want 1", closed) + } + if err := guard.Close(false); err != nil { + t.Fatal(err) + } + if removed != 0 || leaseClosed != 1 { + t.Fatalf("failed container cleanup should leave lease path: removed=%d closed=%d", removed, leaseClosed) + } +} + +func TestCommandRequiresExactLayoutAndPrintsReport(t *testing.T) { + layout := testLayout() + cmd := command{ + currentLayout: func() (hostenv.WSLLayout, error) { return layout, nil }, + checkLayout: func(hostenv.WSLLayout) (wslfs.Plan, error) { return wslfs.Plan{Layout: layout}, nil }, + reconcile: func(_ context.Context, _ hostenv.WSLLayout, apply bool) (Report, error) { + return Report{Namespace: layout.StateNamespace, Applied: apply, Entries: []Entry{{ + ContainerID: strings.Repeat("3", 64), RunID: strings.Repeat("3", 32), Tool: "go", Running: true, + }}}, nil + }, + } + var out bytes.Buffer + if err := cmd.run(context.Background(), []string{"--check"}, &out); err != nil { + t.Fatal(err) + } + for _, want := range []string{"read-only check", "orphan-running:", "active=0 orphaned=1 reconciled=0", "cb wsl cleanup --apply"} { + if !strings.Contains(out.String(), want) { + t.Errorf("output missing %q:\n%s", want, out.String()) + } + } +} diff --git a/internal/wslrun/plan.go b/internal/wslrun/plan.go index 65b2a4b..bc73729 100644 --- a/internal/wslrun/plan.go +++ b/internal/wslrun/plan.go @@ -29,6 +29,7 @@ const ( ) type toolPlan struct { + layout hostenv.WSLLayout spec wsldocker.ContainerCreateSpec volumes []wslvolume.Volume } @@ -61,7 +62,7 @@ func buildToolPlan(tool registry.Tool, userArgs []string, machinePolicy policy.P return toolPlan{}, err } - plan := toolPlan{spec: wsldocker.ContainerCreateSpec{ + plan := toolPlan{layout: layout, spec: wsldocker.ContainerCreateSpec{ Tool: tool.Name, Namespace: scope.Namespace(), Image: image, TTY: tty, Environment: environment, RetainUntilCleanup: true, }} diff --git a/internal/wslrun/run_linux.go b/internal/wslrun/run_linux.go index b15cbd8..944e338 100644 --- a/internal/wslrun/run_linux.go +++ b/internal/wslrun/run_linux.go @@ -8,10 +8,12 @@ import ( "os" "path/filepath" + "github.com/AviBackToBlack/container-bin/internal/hostenv" "github.com/AviBackToBlack/container-bin/internal/policy" "github.com/AviBackToBlack/container-bin/internal/registry" "github.com/AviBackToBlack/container-bin/internal/wsldocker" "github.com/AviBackToBlack/container-bin/internal/wslfs" + "github.com/AviBackToBlack/container-bin/internal/wslreconcile" "github.com/AviBackToBlack/container-bin/internal/wslshim" "github.com/AviBackToBlack/container-bin/internal/wslvolume" ) @@ -40,9 +42,12 @@ func productionFrontendDependencies() frontendDependencies { plan: productionPlanDependencies(), run: runDependencies{ ensureVolume: wslvolume.Ensure, + beginRun: func(ctx context.Context, layout hostenv.WSLLayout) (runGuard, error) { + return wslreconcile.BeginRun(ctx, layout) + }, create: func(ctx context.Context, spec wsldocker.ContainerCreateSpec) (containerHandle, error) { container, err := wsldocker.CreateContainer(ctx, spec) - return containerHandle{id: container.ID(), native: container}, err + return containerHandle{id: container.ID(), runID: container.RunID(), native: container}, err }, attach: func(ctx context.Context, request wsldocker.AttachRequest) (attachStream, error) { return wsldocker.OpenAttach(ctx, request) diff --git a/internal/wslrun/runner.go b/internal/wslrun/runner.go index 2ab1baf..ceb6a69 100644 --- a/internal/wslrun/runner.go +++ b/internal/wslrun/runner.go @@ -8,6 +8,7 @@ import ( "net/http" "time" + "github.com/AviBackToBlack/container-bin/internal/hostenv" "github.com/AviBackToBlack/container-bin/internal/wsldocker" "github.com/AviBackToBlack/container-bin/internal/wslvolume" ) @@ -25,9 +26,15 @@ type attachStream interface { type containerHandle struct { id string + runID string native any } +type runGuard interface { + Adopt(string) error + Close(bool) error +} + type hostEvent struct { signal int resize bool @@ -49,6 +56,7 @@ type waitResult struct { type runDependencies struct { ensureVolume func(context.Context, wslvolume.Volume) error + beginRun func(context.Context, hostenv.WSLLayout) (runGuard, error) create func(context.Context, wsldocker.ContainerCreateSpec) (containerHandle, error) attach func(context.Context, wsldocker.AttachRequest) (attachStream, error) start func(context.Context, string) error @@ -67,7 +75,7 @@ func executeTool(ctx context.Context, plan toolPlan, deps runDependencies) (code if ctx == nil { return 0, errors.New("native WSL tool execution requires a context") } - if deps.ensureVolume == nil || deps.create == nil || deps.attach == nil || deps.start == nil || deps.wait == nil || + if deps.ensureVolume == nil || deps.beginRun == nil || deps.create == nil || deps.attach == nil || deps.start == nil || deps.wait == nil || deps.resize == nil || deps.signal == nil || deps.remove == nil || deps.prepareTerminal == nil || deps.startEvents == nil || deps.stdin == nil || deps.stdout == nil || deps.stderr == nil { return 0, errors.New("native WSL tool execution dependencies are incomplete") @@ -79,12 +87,22 @@ func executeTool(ctx context.Context, plan toolPlan, deps runDependencies) (code return 0, fmt.Errorf("ensure native WSL volume %s: %w", volume.Name(), err) } } + guard, err := deps.beginRun(runCtx, plan.layout) + if err != nil { + return 0, fmt.Errorf("prepare native WSL runtime lease: %w", err) + } + containerGone := false + defer func() { + if err := guard.Close(containerGone); err != nil { + retErr = errors.Join(retErr, fmt.Errorf("close native WSL runtime lease: %w", err)) + } + }() container, err := deps.create(runCtx, plan.spec) if err != nil { return 0, fmt.Errorf("create native WSL tool container: %w", err) } - if container.id == "" { - return 0, errors.New("native WSL container creation returned an empty identity") + if container.id == "" || container.runID == "" { + return 0, errors.New("native WSL container creation returned an incomplete identity") } var ( @@ -126,12 +144,15 @@ func executeTool(ctx context.Context, plan toolPlan, deps runDependencies) (code retErr = errors.Join(retErr, fmt.Errorf("clean up ambiguously running native WSL tool container: %w", err)) } else { removed = true + containerGone = true running = false } } if !running && !removed { if err := deps.remove(cleanupCtx, container); err != nil { retErr = errors.Join(retErr, fmt.Errorf("clean up native WSL tool container: %w", err)) + } else { + containerGone = true } } retErr = errors.Join(retErr, lifecycleErr) @@ -142,6 +163,9 @@ func executeTool(ctx context.Context, plan toolPlan, deps runDependencies) (code stopEvents() } }() + if err := guard.Adopt(container.runID); err != nil { + return 0, fmt.Errorf("publish native WSL runtime lease: %w", err) + } events, stop, err := deps.startEvents(plan.spec.TTY) if err != nil { diff --git a/internal/wslrun/runner_test.go b/internal/wslrun/runner_test.go index acb3e9e..cbdb483 100644 --- a/internal/wslrun/runner_test.go +++ b/internal/wslrun/runner_test.go @@ -32,6 +32,14 @@ type fakeAttach struct { closeWriteErr error } +type fakeRunGuard struct { + adopt func(string) error + close func(bool) error +} + +func (g fakeRunGuard) Adopt(runID string) error { return g.adopt(runID) } +func (g fakeRunGuard) Close(gone bool) error { return g.close(gone) } + func (s *fakeAttach) Read(p []byte) (int, error) { return s.reader.Read(p) } func (s *fakeAttach) Write(p []byte) (int, error) { s.mu.Lock() @@ -86,6 +94,46 @@ func TestExecuteToolStreamsMultiplexedIOAndPropagatesExitCode(t *testing.T) { } } +func TestExecuteToolPublishesLeaseBeforeRuntimeAndRemovesItAfterContainer(t *testing.T) { + stream := &fakeAttach{reader: bytes.NewReader([]byte("done")), writeDone: make(chan struct{})} + var stdout, stderr bytes.Buffer + var calls []string + deps := successfulRunDependencies(t, stream, &stdout, &stderr, &calls) + deps.beginRun = func(_ context.Context, layout hostenv.WSLLayout) (runGuard, error) { + calls = append(calls, "begin") + if layout.StateNamespace != testNamespace { + t.Fatalf("runtime lease layout = %#v", layout) + } + return fakeRunGuard{ + adopt: func(runID string) error { + calls = append(calls, "adopt:"+runID) + return nil + }, + close: func(gone bool) error { + calls = append(calls, "close-lease") + if !gone { + t.Fatal("lease path removed without proven container removal") + } + return nil + }, + }, nil + } + plan := toolPlan{ + layout: hostenv.WSLLayout{StateNamespace: testNamespace}, + spec: wsldocker.ContainerCreateSpec{Tool: "demo", Namespace: testNamespace, Image: "demo:1", WorkingDirectory: "/root"}, + } + if _, err := executeTool(context.Background(), plan, deps); err != nil { + t.Fatal(err) + } + createAt := callIndex(calls, "create") + adoptAt := callIndex(calls, "adopt:"+strings.Repeat("b", 32)) + removeAt := callIndex(calls, "remove") + closeAt := callIndex(calls, "close-lease") + if beginAt := callIndex(calls, "begin"); beginAt < 0 || createAt <= beginAt || adoptAt <= createAt || removeAt <= adoptAt || closeAt <= removeAt { + t.Fatalf("unsafe runtime lease ordering: %#v", calls) + } +} + func TestExecuteToolAppliesTTYSizeAndForwardsHostEvents(t *testing.T) { stream := &fakeAttach{reader: bytes.NewReader([]byte("tty output")), writeDone: make(chan struct{})} var stdout, stderr bytes.Buffer @@ -365,9 +413,15 @@ func successfulRunDependencies(t *testing.T, stream *fakeAttach, stdout, stderr *calls = append(*calls, "ensure:"+volume.Name()) return nil }, + beginRun: func(context.Context, hostenv.WSLLayout) (runGuard, error) { + return fakeRunGuard{ + adopt: func(string) error { return nil }, + close: func(bool) error { return nil }, + }, nil + }, create: func(context.Context, wsldocker.ContainerCreateSpec) (containerHandle, error) { *calls = append(*calls, "create") - return containerHandle{id: strings.Repeat("a", 64), native: "owned"}, nil + return containerHandle{id: strings.Repeat("a", 64), runID: strings.Repeat("b", 32), native: "owned"}, nil }, attach: func(context.Context, wsldocker.AttachRequest) (attachStream, error) { *calls = append(*calls, "attach") diff --git a/main.go b/main.go index 11d05ec..2b6bce5 100644 --- a/main.go +++ b/main.go @@ -22,6 +22,7 @@ import ( "github.com/AviBackToBlack/container-bin/internal/state" "github.com/AviBackToBlack/container-bin/internal/wslfs" "github.com/AviBackToBlack/container-bin/internal/wslinstall" + "github.com/AviBackToBlack/container-bin/internal/wslreconcile" "github.com/AviBackToBlack/container-bin/internal/wslrun" ) @@ -61,6 +62,9 @@ var runSelfUpdateHelper = selfupdate.RunHelper // The dispatcher remains ahead of the Windows frontend gate; wslinstall loads // only the fixed native policy/registry paths for commands that require them. var runWSL = func(args []string, out io.Writer) error { + if len(args) != 0 && args[0] == "cleanup" { + return wslreconcile.Run(context.Background(), args[1:], out) + } return wslinstall.Run(args, out, version, withMutationLock) } @@ -86,7 +90,7 @@ func main() { } if hostRuntime.Kind == hostenv.WSL2Native { if isManagementInvocation(invoked) { - fatalf("native WSL management command %q is unavailable; use `cb wsl install --check|--apply`, `cb version`, `cb help`, or a managed tool shim", strings.Join(os.Args[1:], " ")) + fatalf("native WSL management command %q is unavailable; use `cb wsl install --check|--apply`, `cb wsl cleanup --check|--apply`, `cb version`, `cb help`, or a managed tool shim", strings.Join(os.Args[1:], " ")) return } code, err := runWSLTool(context.Background(), invoked, os.Args[1:]) @@ -457,6 +461,8 @@ Commands: report a plan, or verify and transactionally apply it cb wsl prepare (--check | --apply) validate or create the fixed native-WSL filesystem layout + cb wsl cleanup (--check | --apply) + inspect or reconcile retained native-WSL runtime containers cb wsl install (--check | --apply) inspect or reconcile the fixed native-WSL installation cb list list configured tool profiles From 05bb3bc03ca3d2010880f7f7de6ac9759fee0529 Mon Sep 17 00:00:00 2001 From: AviBackToBlack <54722547+AviBackToBlack@users.noreply.github.com> Date: Sun, 4 Oct 2026 10:39:59 +0100 Subject: [PATCH 2/5] Address WSL reconciliation review findings --- internal/wsldocker/reconcile.go | 2 +- internal/wsldocker/reconcile_linux.go | 24 ++++++++++ internal/wsldocker/reconcile_linux_test.go | 23 +++++++++ internal/wsldocker/reconcile_other.go | 9 ++++ internal/wslreconcile/lease_linux.go | 3 ++ internal/wslreconcile/reconcile.go | 24 ++++++++-- internal/wslreconcile/reconcile_test.go | 56 ++++++++++++++++++++++ main.go | 3 +- 8 files changed, 138 insertions(+), 6 deletions(-) create mode 100644 internal/wsldocker/reconcile_linux.go create mode 100644 internal/wsldocker/reconcile_linux_test.go create mode 100644 internal/wsldocker/reconcile_other.go diff --git a/internal/wsldocker/reconcile.go b/internal/wsldocker/reconcile.go index f28b08b..647045b 100644 --- a/internal/wsldocker/reconcile.go +++ b/internal/wsldocker/reconcile.go @@ -33,7 +33,7 @@ type containerInspectFunc func(context.Context, string) (ContainerSnapshot, erro // exact managed-run namespace labels. The Docker list response is never treated // as ownership proof. func DiscoverRetainedContainers(ctx context.Context, namespace string) ([]ContainerCandidate, error) { - return discoverRetainedContainers(ctx, namespace, Execute) + return discoverRetainedContainers(ctx, namespace, executeRetainedContainerDiscovery) } // ProveRetainedContainer re-inspects one discovery candidate and returns an diff --git a/internal/wsldocker/reconcile_linux.go b/internal/wsldocker/reconcile_linux.go new file mode 100644 index 0000000..3a19ecf --- /dev/null +++ b/internal/wsldocker/reconcile_linux.go @@ -0,0 +1,24 @@ +//go:build linux + +package wsldocker + +import ( + "context" + "time" +) + +type boundedControlPerformFunc func(context.Context, string, Request, time.Duration, int64, uint32) (operationResult, error) + +func executeRetainedContainerDiscovery(ctx context.Context, request Request) (Response, error) { + return execute(ctx, request, retainedContainerDiscoveryDependencies(performDockerRequest)) +} + +func retainedContainerDiscoveryDependencies(perform boundedControlPerformFunc) operationDependencies { + return operationDependencies{ + check: Check, + statSocket: statDockerSocket, + perform: func(ctx context.Context, socketPath string, request Request) (operationResult, error) { + return perform(ctx, socketPath, request, operationTimeout, maxContainerListOutput, 0) + }, + } +} diff --git a/internal/wsldocker/reconcile_linux_test.go b/internal/wsldocker/reconcile_linux_test.go new file mode 100644 index 0000000..09b591d --- /dev/null +++ b/internal/wsldocker/reconcile_linux_test.go @@ -0,0 +1,23 @@ +//go:build linux + +package wsldocker + +import ( + "context" + "testing" + "time" +) + +func TestRetainedContainerDiscoveryUsesItsDeclaredOutputBound(t *testing.T) { + var gotLimit int64 + deps := retainedContainerDiscoveryDependencies(func(_ context.Context, _ string, _ Request, _ time.Duration, maxOutput int64, _ uint32) (operationResult, error) { + gotLimit = maxOutput + return operationResult{}, nil + }) + if _, err := deps.perform(context.Background(), DockerSocketPath, Request{}); err != nil { + t.Fatal(err) + } + if gotLimit != maxContainerListOutput || gotLimit <= maxOperationOutput { + t.Fatalf("retained-container discovery limit = %d, want %d and greater than shared %d", gotLimit, maxContainerListOutput, maxOperationOutput) + } +} diff --git a/internal/wsldocker/reconcile_other.go b/internal/wsldocker/reconcile_other.go new file mode 100644 index 0000000..4fd09fc --- /dev/null +++ b/internal/wsldocker/reconcile_other.go @@ -0,0 +1,9 @@ +//go:build !linux + +package wsldocker + +import "context" + +func executeRetainedContainerDiscovery(ctx context.Context, request Request) (Response, error) { + return Execute(ctx, request) +} diff --git a/internal/wslreconcile/lease_linux.go b/internal/wslreconcile/lease_linux.go index 6af5c88..1785c64 100644 --- a/internal/wslreconcile/lease_linux.go +++ b/internal/wslreconcile/lease_linux.go @@ -185,6 +185,9 @@ func (c *fileCoordinator) Close() error { } func (l *fileLease) Remove() error { + // The caller must hold the namespace coordinator until both Remove and + // Close complete, preventing a cooperating reconciler from observing a + // replacement pathname while this inode remains locked. if l == nil || l.removed { return nil } diff --git a/internal/wslreconcile/reconcile.go b/internal/wslreconcile/reconcile.go index bb86eb1..f4b001c 100644 --- a/internal/wslreconcile/reconcile.go +++ b/internal/wslreconcile/reconcile.go @@ -154,7 +154,10 @@ func (g *RunGuard) Adopt(runID string) error { g.lease = created if err := g.coordinator.Close(); err != nil { g.coordinator = nil - cleanupErr := errors.Join(created.Remove(), created.Close()) + // Leave the path as recovery evidence. Removing it after a failed + // coordinator release could expose a replacement pathname to another + // reconciler before this lock is closed. + cleanupErr := created.Close() g.lease = nil return errors.Join(fmt.Errorf("release native WSL runtime coordinator: %w", err), cleanupErr) } @@ -172,10 +175,23 @@ func (g *RunGuard) Close(containerGone bool) error { var errs []error if g.lease != nil { if containerGone { - errs = append(errs, g.lease.Remove()) + ctx, cancel := context.WithTimeout(context.Background(), reconcileTimeout) + coordinator, err := g.deps.acquireCoordinator(ctx, g.layout) + cancel() + if err != nil { + errs = append(errs, fmt.Errorf("reacquire native WSL runtime coordinator before lease removal: %w", err)) + } else { + removeErr := g.lease.Remove() + leaseCloseErr := g.lease.Close() + coordinatorCloseErr := coordinator.Close() + errs = append(errs, removeErr, leaseCloseErr, coordinatorCloseErr) + g.lease = nil + } + } + if g.lease != nil { + errs = append(errs, g.lease.Close()) + g.lease = nil } - errs = append(errs, g.lease.Close()) - g.lease = nil } if g.coordinator != nil { errs = append(errs, g.coordinator.Close()) diff --git a/internal/wslreconcile/reconcile_test.go b/internal/wslreconcile/reconcile_test.go index cb9ee9a..b325926 100644 --- a/internal/wslreconcile/reconcile_test.go +++ b/internal/wslreconcile/reconcile_test.go @@ -15,10 +15,30 @@ import ( type fakeCoordinator struct { closed *int + err error } func (f *fakeCoordinator) Close() error { *f.closed++ + return f.err +} + +type orderedCoordinator struct{ events *[]string } + +func (c orderedCoordinator) Close() error { + *c.events = append(*c.events, "coordinator-close") + return nil +} + +type orderedLease struct{ events *[]string } + +func (l orderedLease) Remove() error { + *l.events = append(*l.events, "lease-remove") + return nil +} + +func (l orderedLease) Close() error { + *l.events = append(*l.events, "lease-close") return nil } @@ -207,6 +227,42 @@ func TestBeginRunHoldsCoordinatorUntilLeasePublication(t *testing.T) { } } +func TestRunGuardSerializesLeaseRemovalAndUnlock(t *testing.T) { + var events []string + guard := &RunGuard{ + layout: testLayout(), + deps: dependencies{acquireCoordinator: func(context.Context, hostenv.WSLLayout) (coordinator, error) { + events = append(events, "coordinator-acquire") + return orderedCoordinator{events: &events}, nil + }}, + lease: orderedLease{events: &events}, + } + if err := guard.Close(true); err != nil { + t.Fatal(err) + } + want := []string{"coordinator-acquire", "lease-remove", "lease-close", "coordinator-close"} + if strings.Join(events, ",") != strings.Join(want, ",") { + t.Fatalf("lease removal ordering = %v, want %v", events, want) + } +} + +func TestAdoptLeavesLeaseEvidenceWhenCoordinatorReleaseFails(t *testing.T) { + removed, closed, coordinatorClosed := 0, 0, 0 + guard := &RunGuard{ + layout: testLayout(), + coordinator: &fakeCoordinator{closed: &coordinatorClosed, err: errors.New("unlock failed")}, + deps: dependencies{createLease: func(hostenv.WSLLayout, string) (lease, error) { + return &fakeLease{removed: &removed, closed: &closed}, nil + }}, + } + if err := guard.Adopt(strings.Repeat("4", 32)); err == nil || !strings.Contains(err.Error(), "unlock failed") { + t.Fatalf("Adopt() error = %v", err) + } + if removed != 0 || closed != 1 || coordinatorClosed != 1 { + t.Fatalf("failed coordinator release cleanup removed=%d closed=%d coordinator=%d", removed, closed, coordinatorClosed) + } +} + func TestCommandRequiresExactLayoutAndPrintsReport(t *testing.T) { layout := testLayout() cmd := command{ diff --git a/main.go b/main.go index 2b6bce5..d083bd9 100644 --- a/main.go +++ b/main.go @@ -486,7 +486,8 @@ Commands: Native WSL2: Bootstrap and cb wsl ... are enabled. The managed-tool runtime is wired but - activation awaits orphan reconciliation and real Docker Desktop qualification. + activation awaits native state commands, integration coverage and real + Docker Desktop qualification. Registry: %s From c3d523c89fe304a35dc9fa5db6acd9a8498d3f15 Mon Sep 17 00:00:00 2001 From: AviBackToBlack <54722547+AviBackToBlack@users.noreply.github.com> Date: Sun, 4 Oct 2026 10:45:06 +0100 Subject: [PATCH 3/5] Reject ambiguous WSL runtime leases --- internal/wsldocker/reconcile.go | 11 ++++++--- internal/wsldocker/reconcile_test.go | 18 ++++++++++++++ internal/wslreconcile/reconcile.go | 10 +++----- internal/wslreconcile/reconcile_test.go | 33 ++++++++++++++++++++++++- 4 files changed, 62 insertions(+), 10 deletions(-) diff --git a/internal/wsldocker/reconcile.go b/internal/wsldocker/reconcile.go index 647045b..649c28d 100644 --- a/internal/wsldocker/reconcile.go +++ b/internal/wsldocker/reconcile.go @@ -87,16 +87,21 @@ func discoverRetainedContainers(ctx context.Context, namespace string, execute c return nil, fmt.Errorf("decode native WSL retained-container list: %w", err) } candidates := make([]ContainerCandidate, 0, len(listed)) - seen := make(map[string]bool, len(listed)) + seenIDs := make(map[string]bool, len(listed)) + seenRunIDs := make(map[string]bool, len(listed)) for index, listedContainer := range listed { candidate, err := decodeContainerCandidate(listedContainer.ID, listedContainer.Labels, namespace) if err != nil { return nil, fmt.Errorf("validate native WSL retained-container candidate %d: %w", index, err) } - if seen[candidate.id] { + if seenIDs[candidate.id] { return nil, fmt.Errorf("Docker container list returned duplicate ID %s", candidate.id) } - seen[candidate.id] = true + if seenRunIDs[candidate.runID] { + return nil, fmt.Errorf("Docker container list returned duplicate run ID %s", candidate.runID) + } + seenIDs[candidate.id] = true + seenRunIDs[candidate.runID] = true candidates = append(candidates, candidate) } sort.Slice(candidates, func(i, j int) bool { return candidates[i].id < candidates[j].id }) diff --git a/internal/wsldocker/reconcile_test.go b/internal/wsldocker/reconcile_test.go index 00710fb..a1372a4 100644 --- a/internal/wsldocker/reconcile_test.go +++ b/internal/wsldocker/reconcile_test.go @@ -6,6 +6,7 @@ import ( "errors" "net/http" "reflect" + "strings" "testing" ) @@ -69,6 +70,23 @@ func TestDiscoverRetainedContainersRejectsUntrustedShapes(t *testing.T) { } } +func TestDiscoverRetainedContainersRejectsDuplicateRunIdentity(t *testing.T) { + labels := containerLabels(Container{namespace: testWSLNamespace, runID: testRunID, tool: "node24"}) + raw, err := json.Marshal([]map[string]any{ + {"Id": "a" + testContainerID[1:], "Labels": labels}, + {"Id": "b" + testContainerID[1:], "Labels": labels}, + }) + if err != nil { + t.Fatal(err) + } + _, err = discoverRetainedContainers(context.Background(), testWSLNamespace, func(context.Context, Request) (Response, error) { + return Response{StatusCode: http.StatusOK, Body: raw}, nil + }) + if err == nil || !strings.Contains(err.Error(), "duplicate run ID") { + t.Fatalf("duplicate run identity error = %v", err) + } +} + func TestProveRetainedContainerRequiresExactRetainedRuntime(t *testing.T) { candidate := ContainerCandidate{id: testContainerID, runID: testRunID, namespace: testWSLNamespace, tool: "node24"} wantContainer := Container{id: testContainerID, runID: testRunID, namespace: testWSLNamespace, tool: "node24", retainUntilCleanup: true} diff --git a/internal/wslreconcile/reconcile.go b/internal/wslreconcile/reconcile.go index f4b001c..60b321a 100644 --- a/internal/wslreconcile/reconcile.go +++ b/internal/wslreconcile/reconcile.go @@ -154,12 +154,10 @@ func (g *RunGuard) Adopt(runID string) error { g.lease = created if err := g.coordinator.Close(); err != nil { g.coordinator = nil - // Leave the path as recovery evidence. Removing it after a failed - // coordinator release could expose a replacement pathname to another - // reconciler before this lock is closed. - cleanupErr := created.Close() - g.lease = nil - return errors.Join(fmt.Errorf("release native WSL runtime coordinator: %w", err), cleanupErr) + // Keep the locked lease attached to the guard. Deferred container + // cleanup can then remove it only after proving absence; otherwise Close + // unlocks it but deliberately leaves the pathname as recovery evidence. + return fmt.Errorf("release native WSL runtime coordinator: %w", err) } g.coordinator = nil return nil diff --git a/internal/wslreconcile/reconcile_test.go b/internal/wslreconcile/reconcile_test.go index b325926..7adc3e3 100644 --- a/internal/wslreconcile/reconcile_test.go +++ b/internal/wslreconcile/reconcile_test.go @@ -246,7 +246,7 @@ func TestRunGuardSerializesLeaseRemovalAndUnlock(t *testing.T) { } } -func TestAdoptLeavesLeaseEvidenceWhenCoordinatorReleaseFails(t *testing.T) { +func TestAdoptRetainsLeaseThroughCleanupWhenCoordinatorReleaseFails(t *testing.T) { removed, closed, coordinatorClosed := 0, 0, 0 guard := &RunGuard{ layout: testLayout(), @@ -258,11 +258,42 @@ func TestAdoptLeavesLeaseEvidenceWhenCoordinatorReleaseFails(t *testing.T) { if err := guard.Adopt(strings.Repeat("4", 32)); err == nil || !strings.Contains(err.Error(), "unlock failed") { t.Fatalf("Adopt() error = %v", err) } + if removed != 0 || closed != 0 || coordinatorClosed != 1 { + t.Fatalf("Adopt released its lease early: removed=%d closed=%d coordinator=%d", removed, closed, coordinatorClosed) + } + if err := guard.Close(false); err != nil { + t.Fatal(err) + } if removed != 0 || closed != 1 || coordinatorClosed != 1 { t.Fatalf("failed coordinator release cleanup removed=%d closed=%d coordinator=%d", removed, closed, coordinatorClosed) } } +func TestAdoptFailureRemovesLeaseOnlyAfterProvenCleanup(t *testing.T) { + removed, closed, initialClosed, cleanupClosed := 0, 0, 0, 0 + guard := &RunGuard{ + layout: testLayout(), + coordinator: &fakeCoordinator{closed: &initialClosed, err: errors.New("unlock failed")}, + deps: dependencies{ + createLease: func(hostenv.WSLLayout, string) (lease, error) { + return &fakeLease{removed: &removed, closed: &closed}, nil + }, + acquireCoordinator: func(context.Context, hostenv.WSLLayout) (coordinator, error) { + return &fakeCoordinator{closed: &cleanupClosed}, nil + }, + }, + } + if err := guard.Adopt(strings.Repeat("5", 32)); err == nil { + t.Fatal("Adopt unexpectedly succeeded") + } + if err := guard.Close(true); err != nil { + t.Fatal(err) + } + if removed != 1 || closed != 1 || initialClosed != 1 || cleanupClosed != 1 { + t.Fatalf("proven cleanup removed=%d closed=%d initial=%d cleanup=%d", removed, closed, initialClosed, cleanupClosed) + } +} + func TestCommandRequiresExactLayoutAndPrintsReport(t *testing.T) { layout := testLayout() cmd := command{ From dbcf8a9fe26ea88bdccb2864c7609e229409be40 Mon Sep 17 00:00:00 2001 From: AviBackToBlack <54722547+AviBackToBlack@users.noreply.github.com> Date: Sun, 4 Oct 2026 14:21:06 +0100 Subject: [PATCH 4/5] Complete WSL cleanup review hardening --- internal/wslinstall/install.go | 2 +- internal/wslinstall/install_test.go | 7 +++++++ internal/wslreconcile/reconcile.go | 14 +++++++++----- internal/wslreconcile/reconcile_test.go | 16 ++++++++++++++++ 4 files changed, 33 insertions(+), 6 deletions(-) diff --git a/internal/wslinstall/install.go b/internal/wslinstall/install.go index 36fb0da..8884c93 100644 --- a/internal/wslinstall/install.go +++ b/internal/wslinstall/install.go @@ -95,7 +95,7 @@ func (c command) run(args []string, out io.Writer, version string) error { return c.prepareCommand(args, out) } if len(args) != 2 || args[0] != "install" || (args[1] != "--check" && args[1] != "--apply") { - return errors.New("usage: cb wsl prepare (--check | --apply) | cb wsl install (--check | --apply)") + return errors.New("usage: cb wsl prepare (--check | --apply) | cb wsl cleanup (--check | --apply) | cb wsl install (--check | --apply)") } if c.currentLayout == nil || c.checkLayout == nil || c.checkRegistryRecovery == nil || c.loadPolicy == nil || c.loadRegistryReadOnly == nil || c.executable == nil || c.lstat == nil || c.binaryState == nil || c.inspectNames == nil { return errors.New("native WSL install command is incomplete") diff --git a/internal/wslinstall/install_test.go b/internal/wslinstall/install_test.go index 9ca31ad..0154a6a 100644 --- a/internal/wslinstall/install_test.go +++ b/internal/wslinstall/install_test.go @@ -17,6 +17,13 @@ import ( "github.com/AviBackToBlack/container-bin/internal/wslshim" ) +func TestUsageIncludesCleanupCommand(t *testing.T) { + err := (command{}).run([]string{"unknown"}, io.Discard, "dev") + if err == nil || !strings.Contains(err.Error(), "cb wsl cleanup (--check | --apply)") { + t.Fatalf("usage error = %v", err) + } +} + func TestCheckIsReadOnlyAndReportsRequiredActions(t *testing.T) { layout := installTestLayout() reg := registry.Default() diff --git a/internal/wslreconcile/reconcile.go b/internal/wslreconcile/reconcile.go index 60b321a..a285b29 100644 --- a/internal/wslreconcile/reconcile.go +++ b/internal/wslreconcile/reconcile.go @@ -247,20 +247,24 @@ func reconcileLocked(ctx context.Context, layout hostenv.WSLLayout, apply bool, if probeErr != nil { return report, fmt.Errorf("probe native WSL runtime lease %s: %w", container.runID, probeErr) } + classifiedContainers = append(classifiedContainers, classified{ + container: container, + lease: heldLease, + }) + classified := &classifiedContainers[len(classifiedContainers)-1] if status != leaseMissing && status != leaseActive && status != leaseOrphaned { return report, errors.New("native WSL runtime lease probe returned an invalid status") } + if status == leaseMissing && heldLease != nil { + return report, errors.New("missing native WSL runtime lease unexpectedly returned a handle") + } if status == leaseActive && heldLease != nil { return report, errors.New("active native WSL runtime lease unexpectedly returned a handle") } if status == leaseOrphaned && heldLease == nil { return report, errors.New("orphaned native WSL runtime lease did not return its lock") } - classifiedContainers = append(classifiedContainers, classified{ - container: container, - active: status == leaseActive, - lease: heldLease, - }) + classified.active = status == leaseActive } sort.Slice(classifiedContainers, func(i, j int) bool { diff --git a/internal/wslreconcile/reconcile_test.go b/internal/wslreconcile/reconcile_test.go index 7adc3e3..a7dbb17 100644 --- a/internal/wslreconcile/reconcile_test.go +++ b/internal/wslreconcile/reconcile_test.go @@ -168,6 +168,22 @@ func TestReconcileCompletesAllProofsBeforeMutation(t *testing.T) { } } +func TestReconcileRejectsMissingLeaseWithHandle(t *testing.T) { + item, container := testCandidate("6", false) + removed, closed := 0, 0 + var events []string + deps := testDependencies([]candidate{item}, map[string]retainedContainer{item.id: container}, nil, &events) + deps.probeLease = func(hostenv.WSLLayout, string) (leaseStatus, lease, error) { + return leaseMissing, &fakeLease{removed: &removed, closed: &closed}, nil + } + if _, err := reconcile(context.Background(), testLayout(), true, deps); err == nil || !strings.Contains(err.Error(), "missing native WSL runtime lease unexpectedly returned a handle") { + t.Fatalf("unexpected probe-contract result: %v", err) + } + if removed != 0 || closed != 1 || strings.Contains(strings.Join(events, ","), "remove:") { + t.Fatalf("invalid probe result mutated state: removed=%d closed=%d events=%v", removed, closed, events) + } +} + func TestReconcileToleratesRunningCompletionRaces(t *testing.T) { item, container := testCandidate("1", true) for _, status := range []int{http.StatusNotFound, http.StatusConflict} { From 1b0f538297de27a9fd362008dc79ebda93ae64be Mon Sep 17 00:00:00 2001 From: AviBackToBlack <54722547+AviBackToBlack@users.noreply.github.com> Date: Sun, 4 Oct 2026 14:48:48 +0100 Subject: [PATCH 5/5] Reap detached WSL runtime leases --- README.md | 4 +- docs/architecture.md | 5 +- docs/security-model.md | 4 +- docs/wsl-process-contract.md | 5 +- docs/wsl.md | 4 +- internal/wslreconcile/command.go | 15 ++- internal/wslreconcile/lease_linux.go | 34 ++++-- internal/wslreconcile/lease_linux_test.go | 28 +++++ internal/wslreconcile/lease_other.go | 4 + internal/wslreconcile/production_linux.go | 1 + internal/wslreconcile/reconcile.go | 125 ++++++++++++++++++++- internal/wslreconcile/reconcile_test.go | 129 +++++++++++++++++++++- 12 files changed, 340 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index 9bbe20c..f5d3451 100644 --- a/README.md +++ b/README.md @@ -958,7 +958,9 @@ containers as active or orphaned without mutation. `--apply` stops, waits and removes only re-proven orphans; ordinary tool startup performs the same pass automatically. A process-held private lease protects active runs, and the namespace coordinator remains held across container creation and lease -publication so cleanup cannot guess across that race. +publication so cleanup cannot guess across that race. Unlocked lease evidence +whose exact namespace no longer contains a matching retained container is +reported and reaped by `--apply`; locked leases are always preserved. ### Self-update release selection diff --git a/docs/architecture.md b/docs/architecture.md index 679edf5..a3f349c 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -510,7 +510,10 @@ is released. Reconciliation preserves locked active leases and mutates only an unlocked or lease-less candidate whose complete labels, retention and stream configuration were freshly re-proven. A running orphan is killed and waited; all orphan removal uses the same proof-bound non-force lifecycle. Explicit -`cb wsl cleanup --check|--apply` exposes that recovery path. The production host +`cb wsl cleanup --check|--apply` exposes that recovery path. The same +coordinator-held pass enumerates managed lease names and reaps an unlocked +lease only when complete namespace discovery contains no matching run; locked +lease-only records remain untouched. The production host boundary still does not dispatch into this orchestrator until native state commands, integration coverage and real WSL qualification complete. diff --git a/docs/security-model.md b/docs/security-model.md index 43b959f..b4d4237 100644 --- a/docs/security-model.md +++ b/docs/security-model.md @@ -181,7 +181,9 @@ readable, and dangerous to let others edit. preserve active runs. Missing or unlockable leases identify recoverable orphans while the coordinator is held; running orphans are killed and waited, stopped orphans are removed directly, and all deletion remains proof-bound - and non-force. Unsafe lease files or any ambiguous candidate stop the complete + and non-force. Unlocked lease evidence is reaped only when complete namespace + discovery has no matching retained container; locked leases are preserved. + Unsafe lease files or any ambiguous candidate stop the complete preflight before its first mutation. - **Machine policy cannot be redirected or weakened.** A present enterprise policy is loaded only from the fixed OS path, requires administrator/root diff --git a/docs/wsl-process-contract.md b/docs/wsl-process-contract.md index 2d1f0d8..8b720ec 100644 --- a/docs/wsl-process-contract.md +++ b/docs/wsl-process-contract.md @@ -76,7 +76,10 @@ mutation. Explicit `--apply` performs the same proof-bound reconciliation used automatically before ordinary execution. Malformed labels, changed container configuration, unsafe lease files or incomplete proofs stop the whole preflight before its first mutation. A lease path is removed only after exact container -absence is established; otherwise it remains as recovery evidence. +absence is established; otherwise it remains as recovery evidence. A later +coordinator-held pass enumerates managed lease names, preserves every locked +lease, and reaps an unlocked lease only when complete namespace discovery has +no matching run identity. ## Streams and TTY diff --git a/docs/wsl.md b/docs/wsl.md index 1454f9d..7b8c6f3 100644 --- a/docs/wsl.md +++ b/docs/wsl.md @@ -310,7 +310,9 @@ lease is orphaned. It re-proves every discovered container's full ID, labels, retention mode and stream configuration before the first mutation. Running orphans are SIGKILLed and waited; stopped orphans go directly through the same proof-bound non-force removal. Lease paths are removed only after exact -container absence is established. +container absence is established. A later coordinator-held pass reports and +reaps unlocked lease evidence only when the complete namespace discovery has +no matching retained container; locked lease-only records are preserved. `cb wsl cleanup --check` exposes the classification without changing Docker or lease state. `cb wsl cleanup --apply` performs explicit recovery. Both require diff --git a/internal/wslreconcile/command.go b/internal/wslreconcile/command.go index b80f8b2..9b647e1 100644 --- a/internal/wslreconcile/command.go +++ b/internal/wslreconcile/command.go @@ -80,8 +80,19 @@ func printReport(out io.Writer, report Report) error { } fmt.Fprintf(&text, "%s: container=%s run=%s tool=%s\n", status, entry.ContainerID, entry.RunID, entry.Tool) } - fmt.Fprintf(&text, "totals: active=%d orphaned=%d reconciled=%d\n", report.ActiveCount(), report.OrphanCount(), report.RemovedCount()) - if !report.Applied && report.OrphanCount() != 0 { + for _, entry := range report.Leases { + status := "lease-residue" + if entry.Active { + status = "lease-active" + } else if entry.Removed { + status = "lease-reaped" + } + fmt.Fprintf(&text, "%s: run=%s\n", status, entry.RunID) + } + fmt.Fprintf(&text, "totals: active=%d orphaned=%d reconciled=%d lease_active=%d lease_residue=%d lease_reaped=%d\n", + report.ActiveCount(), report.OrphanCount(), report.RemovedCount(), + report.ActiveLeaseOnlyCount(), report.OrphanedLeaseCount(), report.ReapedLeaseCount()) + if !report.Applied && (report.OrphanCount() != 0 || report.OrphanedLeaseCount() != 0) { fmt.Fprintln(&text, "apply: cb wsl cleanup --apply") } if _, err := io.WriteString(out, text.String()); err != nil { diff --git a/internal/wslreconcile/lease_linux.go b/internal/wslreconcile/lease_linux.go index 1785c64..ff4559e 100644 --- a/internal/wslreconcile/lease_linux.go +++ b/internal/wslreconcile/lease_linux.go @@ -94,6 +94,31 @@ func probeFileLease(layout hostenv.WSLLayout, runID string) (leaseStatus, lease, return leaseOrphaned, &fileLease{file: file, dir: dir, name: name, dev: uint64(stat.Dev), ino: stat.Ino}, nil } +func discoverFileLeases(layout hostenv.WSLLayout) (runIDs []string, err error) { + dir, _, err := openStateDirectory(layout) + if err != nil { + return nil, err + } + defer func() { err = errors.Join(err, dir.Close()) }() + entries, err := dir.ReadDir(-1) + if err != nil { + return nil, fmt.Errorf("enumerate native WSL runtime state directory: %w", err) + } + for _, entry := range entries { + name := entry.Name() + if !strings.HasPrefix(name, "run-") || !strings.HasSuffix(name, ".lease") { + continue + } + runID := strings.TrimSuffix(strings.TrimPrefix(name, "run-"), ".lease") + expected, nameErr := leaseName(runID) + if nameErr != nil || expected != name { + return nil, fmt.Errorf("invalid native WSL runtime lease filename %q", name) + } + runIDs = append(runIDs, runID) + } + return runIDs, nil +} + func openStateDirectory(layout hostenv.WSLLayout) (*os.File, *syscall.Stat_t, error) { if layout.StateDir == "" || !strings.HasPrefix(layout.StateDir, "/") || layout.UID != uint32(os.Geteuid()) { return nil, nil, errors.New("native WSL runtime state identity is invalid for the current user") @@ -161,13 +186,8 @@ func lockContext(ctx context.Context, file *os.File) error { } func leaseName(runID string) (string, error) { - if len(runID) != 32 { - return "", errors.New("native WSL runtime lease requires a 32-character run identity") - } - for _, char := range runID { - if (char < '0' || char > '9') && (char < 'a' || char > 'f') { - return "", errors.New("native WSL runtime lease requires a lowercase hexadecimal run identity") - } + if err := validateRunID(runID); err != nil { + return "", err } return "run-" + runID + ".lease", nil } diff --git a/internal/wslreconcile/lease_linux_test.go b/internal/wslreconcile/lease_linux_test.go index 2ef70d2..56d2298 100644 --- a/internal/wslreconcile/lease_linux_test.go +++ b/internal/wslreconcile/lease_linux_test.go @@ -51,6 +51,34 @@ func TestFileLeaseLifecycle(t *testing.T) { } } +func TestDiscoverFileLeases(t *testing.T) { + layout := leaseTestLayout(t) + runID := strings.Repeat("c", 32) + held, err := createFileLease(layout, runID) + if err != nil { + t.Fatal(err) + } + if err := held.Close(); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(layout.StateDir, "unrelated-state"), nil, 0o600); err != nil { + t.Fatal(err) + } + runIDs, err := discoverFileLeases(layout) + if err != nil { + t.Fatal(err) + } + if len(runIDs) != 1 || runIDs[0] != runID { + t.Fatalf("discovered lease run IDs = %v, want %s", runIDs, runID) + } + if err := os.WriteFile(filepath.Join(layout.StateDir, "run-invalid.lease"), nil, 0o600); err != nil { + t.Fatal(err) + } + if _, err := discoverFileLeases(layout); err == nil || !strings.Contains(err.Error(), "invalid native WSL runtime lease filename") { + t.Fatalf("malformed managed lease filename was accepted: %v", err) + } +} + func TestFileLeaseRejectsUnsafePaths(t *testing.T) { layout := leaseTestLayout(t) runID := strings.Repeat("b", 32) diff --git a/internal/wslreconcile/lease_other.go b/internal/wslreconcile/lease_other.go index 3c18686..d7d298c 100644 --- a/internal/wslreconcile/lease_other.go +++ b/internal/wslreconcile/lease_other.go @@ -22,3 +22,7 @@ func createFileLease(hostenv.WSLLayout, string) (lease, error) { func probeFileLease(hostenv.WSLLayout, string) (leaseStatus, lease, error) { return leaseMissing, nil, errLinuxLeaseRequired } + +func discoverFileLeases(hostenv.WSLLayout) ([]string, error) { + return nil, errLinuxLeaseRequired +} diff --git a/internal/wslreconcile/production_linux.go b/internal/wslreconcile/production_linux.go index 7f14463..f9e6768 100644 --- a/internal/wslreconcile/production_linux.go +++ b/internal/wslreconcile/production_linux.go @@ -13,6 +13,7 @@ func productionDependencies() dependencies { acquireCoordinator: acquireFileCoordinator, createLease: createFileLease, probeLease: probeFileLease, + discoverLeases: discoverFileLeases, discover: func(ctx context.Context, namespace string) ([]candidate, error) { discovered, err := wsldocker.DiscoverRetainedContainers(ctx, namespace) if err != nil { diff --git a/internal/wslreconcile/reconcile.go b/internal/wslreconcile/reconcile.go index a285b29..4fd0c1b 100644 --- a/internal/wslreconcile/reconcile.go +++ b/internal/wslreconcile/reconcile.go @@ -52,6 +52,7 @@ type dependencies struct { acquireCoordinator func(context.Context, hostenv.WSLLayout) (coordinator, error) createLease func(hostenv.WSLLayout, string) (lease, error) probeLease func(hostenv.WSLLayout, string) (leaseStatus, lease, error) + discoverLeases func(hostenv.WSLLayout) ([]string, error) discover func(context.Context, string) ([]candidate, error) prove func(context.Context, candidate, string) (retainedContainer, bool, error) signal func(context.Context, retainedContainer, int) error @@ -69,11 +70,50 @@ type Entry struct { Removed bool } +// LeaseEntry is one managed run lease whose container is proven absent from +// the complete namespace discovery result. +type LeaseEntry struct { + RunID string + Active bool + Removed bool +} + // Report is the deterministic result of one namespace reconciliation pass. type Report struct { Namespace string Applied bool Entries []Entry + Leases []LeaseEntry +} + +// ActiveLeaseOnlyCount returns the number of locked leases with no retained +// container. They are preserved because another process still owns the lock. +func (r Report) ActiveLeaseOnlyCount() int { + count := 0 + for _, entry := range r.Leases { + if entry.Active { + count++ + } + } + return count +} + +// OrphanedLeaseCount returns the number of unlocked lease paths whose retained +// container is absent. +func (r Report) OrphanedLeaseCount() int { + return len(r.Leases) - r.ActiveLeaseOnlyCount() +} + +// ReapedLeaseCount returns the number of orphaned lease paths removed by this +// pass. +func (r Report) ReapedLeaseCount() int { + count := 0 + for _, entry := range r.Leases { + if entry.Removed { + count++ + } + } + return count } // ActiveCount returns the number of retained containers with a locked lease. @@ -219,22 +259,50 @@ type classified struct { lease lease } +type classifiedLease struct { + runID string + active bool + lease lease +} + func reconcileLocked(ctx context.Context, layout hostenv.WSLLayout, apply bool, deps dependencies) (report Report, err error) { report = Report{Namespace: layout.StateNamespace, Applied: apply} candidates, err := deps.discover(ctx, layout.StateNamespace) if err != nil { return report, fmt.Errorf("discover retained native WSL containers: %w", err) } + leaseRunIDs, err := deps.discoverLeases(layout) + if err != nil { + return report, fmt.Errorf("discover native WSL runtime leases: %w", err) + } + leaseRunIDSet := make(map[string]struct{}, len(leaseRunIDs)) + for _, runID := range leaseRunIDs { + if err := validateRunID(runID); err != nil { + return report, fmt.Errorf("discover native WSL runtime lease: %w", err) + } + if _, duplicate := leaseRunIDSet[runID]; duplicate { + return report, fmt.Errorf("discover native WSL runtime leases: duplicate run identity %s", runID) + } + leaseRunIDSet[runID] = struct{}{} + } + sort.Strings(leaseRunIDs) classifiedContainers := make([]classified, 0, len(candidates)) + classifiedLeases := make([]classifiedLease, 0, len(leaseRunIDs)) defer func() { for _, item := range classifiedContainers { if item.lease != nil { err = errors.Join(err, item.lease.Close()) } } + for _, item := range classifiedLeases { + if item.lease != nil { + err = errors.Join(err, item.lease.Close()) + } + } }() // Complete every ownership and lease proof before the first mutation. + containerRunIDs := make(map[string]struct{}, len(candidates)) for _, discovered := range candidates { container, exists, proveErr := deps.prove(ctx, discovered, layout.StateNamespace) if proveErr != nil { @@ -243,6 +311,10 @@ func reconcileLocked(ctx context.Context, layout hostenv.WSLLayout, apply bool, if !exists { continue } + if _, duplicate := containerRunIDs[container.runID]; duplicate { + return report, fmt.Errorf("prove retained native WSL containers: duplicate run identity %s", container.runID) + } + containerRunIDs[container.runID] = struct{}{} status, heldLease, probeErr := deps.probeLease(layout, container.runID) if probeErr != nil { return report, fmt.Errorf("probe native WSL runtime lease %s: %w", container.runID, probeErr) @@ -266,6 +338,34 @@ func reconcileLocked(ctx context.Context, layout hostenv.WSLLayout, apply bool, } classified.active = status == leaseActive } + for _, runID := range leaseRunIDs { + if _, hasContainer := containerRunIDs[runID]; hasContainer { + continue + } + status, heldLease, probeErr := deps.probeLease(layout, runID) + if probeErr != nil { + return report, fmt.Errorf("probe detached native WSL runtime lease %s: %w", runID, probeErr) + } + classifiedLeases = append(classifiedLeases, classifiedLease{runID: runID, lease: heldLease}) + classified := &classifiedLeases[len(classifiedLeases)-1] + if status != leaseMissing && status != leaseActive && status != leaseOrphaned { + return report, errors.New("detached native WSL runtime lease probe returned an invalid status") + } + if status == leaseMissing && heldLease != nil { + return report, errors.New("missing detached native WSL runtime lease unexpectedly returned a handle") + } + if status == leaseActive && heldLease != nil { + return report, errors.New("active detached native WSL runtime lease unexpectedly returned a handle") + } + if status == leaseOrphaned && heldLease == nil { + return report, errors.New("orphaned detached native WSL runtime lease did not return its lock") + } + if status == leaseMissing { + classifiedLeases = classifiedLeases[:len(classifiedLeases)-1] + continue + } + classified.active = status == leaseActive + } sort.Slice(classifiedContainers, func(i, j int) bool { return classifiedContainers[i].container.id < classifiedContainers[j].container.id @@ -296,6 +396,17 @@ func reconcileLocked(ctx context.Context, layout hostenv.WSLLayout, apply bool, } report.Entries = append(report.Entries, entry) } + for i := range classifiedLeases { + item := &classifiedLeases[i] + entry := LeaseEntry{RunID: item.runID, Active: item.active} + if apply && !item.active { + if removeErr := item.lease.Remove(); removeErr != nil { + return report, fmt.Errorf("remove detached native WSL runtime lease %s: %w", item.runID, removeErr) + } + entry.Removed = true + } + report.Leases = append(report.Leases, entry) + } return report, nil } @@ -323,12 +434,24 @@ func reconcileOrphan(ctx context.Context, container retainedContainer, deps depe func validateDependencies(deps dependencies) error { if deps.acquireCoordinator == nil || deps.createLease == nil || deps.probeLease == nil || - deps.discover == nil || deps.prove == nil || deps.signal == nil || deps.wait == nil || deps.remove == nil { + deps.discoverLeases == nil || deps.discover == nil || deps.prove == nil || deps.signal == nil || deps.wait == nil || deps.remove == nil { return errors.New("native WSL runtime reconciliation dependencies are incomplete") } return nil } +func validateRunID(runID string) error { + if len(runID) != 32 { + return errors.New("native WSL runtime lease requires a 32-character run identity") + } + for _, char := range runID { + if (char < '0' || char > '9') && (char < 'a' || char > 'f') { + return errors.New("native WSL runtime lease requires a lowercase hexadecimal run identity") + } + } + return nil +} + func isAPIStatus(err error, status int) bool { var apiError *wsldocker.APIError return errors.As(err, &apiError) && apiError.StatusCode == status diff --git a/internal/wslreconcile/reconcile_test.go b/internal/wslreconcile/reconcile_test.go index a7dbb17..000da26 100644 --- a/internal/wslreconcile/reconcile_test.go +++ b/internal/wslreconcile/reconcile_test.go @@ -85,7 +85,8 @@ func testDependencies(items []candidate, containers map[string]retainedContainer } return status, nil, nil }, - discover: func(context.Context, string) ([]candidate, error) { return items, nil }, + discoverLeases: func(hostenv.WSLLayout) ([]string, error) { return nil, nil }, + discover: func(context.Context, string) ([]candidate, error) { return items, nil }, prove: func(_ context.Context, item candidate, _ string) (retainedContainer, bool, error) { container, ok := containers[item.id] return container, ok, nil @@ -149,6 +150,128 @@ func TestReconcileCheckIsReadOnly(t *testing.T) { } } +func TestReconcileReportsAndReapsDetachedLeaseEvidence(t *testing.T) { + orphanedRunID := strings.Repeat("7", 32) + activeRunID := strings.Repeat("8", 32) + for _, apply := range []bool{false, true} { + t.Run(map[bool]string{false: "check", true: "apply"}[apply], func(t *testing.T) { + removed, closed := 0, 0 + var events []string + deps := testDependencies(nil, nil, nil, &events) + deps.discoverLeases = func(hostenv.WSLLayout) ([]string, error) { + return []string{activeRunID, orphanedRunID}, nil + } + deps.probeLease = func(_ hostenv.WSLLayout, runID string) (leaseStatus, lease, error) { + events = append(events, "probe:"+runID) + if runID == activeRunID { + return leaseActive, nil, nil + } + return leaseOrphaned, &fakeLease{removed: &removed, closed: &closed}, nil + } + report, err := reconcile(context.Background(), testLayout(), apply, deps) + if err != nil { + t.Fatal(err) + } + if report.ActiveLeaseOnlyCount() != 1 || report.OrphanedLeaseCount() != 1 { + t.Fatalf("unexpected detached lease report: %+v", report) + } + wantRemoved := 0 + if apply { + wantRemoved = 1 + } + if report.ReapedLeaseCount() != wantRemoved || removed != wantRemoved || closed != 1 { + t.Fatalf("apply=%t report=%+v removed=%d closed=%d", apply, report, removed, closed) + } + }) + } +} + +func TestReconcileProvesDetachedLeasesBeforeContainerMutation(t *testing.T) { + item, container := testCandidate("9", false) + var events []string + deps := testDependencies([]candidate{item}, map[string]retainedContainer{item.id: container}, map[string]leaseStatus{item.runID: leaseMissing}, &events) + deps.discoverLeases = func(hostenv.WSLLayout) ([]string, error) { + return []string{strings.Repeat("a", 32)}, nil + } + deps.probeLease = func(_ hostenv.WSLLayout, runID string) (leaseStatus, lease, error) { + events = append(events, "probe:"+runID) + if runID != item.runID { + return leaseMissing, nil, errors.New("ambiguous detached lease") + } + return leaseMissing, nil, nil + } + if _, err := reconcile(context.Background(), testLayout(), true, deps); err == nil || !strings.Contains(err.Error(), "ambiguous detached lease") { + t.Fatalf("expected detached lease proof error, got %v", err) + } + if strings.Contains(strings.Join(events, ","), "remove:") { + t.Fatalf("container mutation occurred before detached lease proof: %v", events) + } +} + +func TestReconcileRejectsInvalidDetachedLeaseProbeContracts(t *testing.T) { + runID := strings.Repeat("b", 32) + tests := []struct { + name string + status leaseStatus + withHandle bool + want string + }{ + {name: "invalid status", status: leaseStatus(99), withHandle: true, want: "invalid status"}, + {name: "missing with handle", status: leaseMissing, withHandle: true, want: "missing detached"}, + {name: "active with handle", status: leaseActive, withHandle: true, want: "active detached"}, + {name: "orphaned without handle", status: leaseOrphaned, want: "did not return its lock"}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + removed, closed := 0, 0 + var events []string + deps := testDependencies(nil, nil, nil, &events) + deps.discoverLeases = func(hostenv.WSLLayout) ([]string, error) { return []string{runID}, nil } + deps.probeLease = func(hostenv.WSLLayout, string) (leaseStatus, lease, error) { + if tc.withHandle { + return tc.status, &fakeLease{removed: &removed, closed: &closed}, nil + } + return tc.status, nil, nil + } + if _, err := reconcile(context.Background(), testLayout(), true, deps); err == nil || !strings.Contains(err.Error(), tc.want) { + t.Fatalf("unexpected detached lease probe result: %v", err) + } + wantClosed := 0 + if tc.withHandle { + wantClosed = 1 + } + if removed != 0 || closed != wantClosed { + t.Fatalf("invalid probe mutated lease: removed=%d closed=%d", removed, closed) + } + }) + } +} + +func TestReconcileRejectsInvalidDetachedLeaseDiscovery(t *testing.T) { + valid := strings.Repeat("c", 32) + tests := []struct { + name string + runIDs []string + want string + }{ + {name: "invalid", runIDs: []string{"invalid"}, want: "32-character run identity"}, + {name: "duplicate", runIDs: []string{valid, valid}, want: "duplicate run identity"}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + var events []string + deps := testDependencies(nil, nil, nil, &events) + deps.discoverLeases = func(hostenv.WSLLayout) ([]string, error) { return tc.runIDs, nil } + if _, err := reconcile(context.Background(), testLayout(), true, deps); err == nil || !strings.Contains(err.Error(), tc.want) { + t.Fatalf("invalid detached lease discovery result: %v", err) + } + if strings.Contains(strings.Join(events, ","), "remove:") { + t.Fatalf("invalid discovery mutated state: %v", events) + } + }) + } +} + func TestReconcileCompletesAllProofsBeforeMutation(t *testing.T) { firstCandidate, first := testCandidate("e", false) secondCandidate, _ := testCandidate("f", false) @@ -318,14 +441,14 @@ func TestCommandRequiresExactLayoutAndPrintsReport(t *testing.T) { reconcile: func(_ context.Context, _ hostenv.WSLLayout, apply bool) (Report, error) { return Report{Namespace: layout.StateNamespace, Applied: apply, Entries: []Entry{{ ContainerID: strings.Repeat("3", 64), RunID: strings.Repeat("3", 32), Tool: "go", Running: true, - }}}, nil + }}, Leases: []LeaseEntry{{RunID: strings.Repeat("7", 32)}}}, nil }, } var out bytes.Buffer if err := cmd.run(context.Background(), []string{"--check"}, &out); err != nil { t.Fatal(err) } - for _, want := range []string{"read-only check", "orphan-running:", "active=0 orphaned=1 reconciled=0", "cb wsl cleanup --apply"} { + for _, want := range []string{"read-only check", "orphan-running:", "lease-residue:", "active=0 orphaned=1 reconciled=0 lease_active=0 lease_residue=1 lease_reaped=0", "cb wsl cleanup --apply"} { if !strings.Contains(out.String(), want) { t.Errorf("output missing %q:\n%s", want, out.String()) }