Deep Review: 20260323-003519-pr-242
| Date | 2026-03-23 00:35 |
| Author | @jandubois |
| PR | #242 — Run Lima BATS tests on Windows via MSYS2 |
| Branch | msys2-ci |
| Commits |
74f3442 Fix Windows process signaling for Lima hostagent lifecyclef99d44e Fix stopInstanceForcibly to terminate WSL2 distro and clean up filesf525d62 Enable Lima BATS tests on Windowscec19bc Pre-generate Lima SSH keys on Windows to avoid broken path conversionf4a83e7 Run Windows BATS tests via MSYS2 instead of WSL2a224b19 Honor RDD_LOG_LEVEL in external controllers
|
| Reviewers | Claude Opus 4.6, Codex GPT 5.4, Gemini 3.1 Pro |
| Verdict | Merge with fixes — one important edge case in the deletion path for broken instances on Windows |
| Wall-clock time | 21 min 27 s |
Executive Summary ¶
This PR isolates Lima hostagents into their own Windows process groups (CREATE_NEW_PROCESS_GROUP + targeted CTRL_BREAK_EVENT), replaces Lima's console-wide StopForcibly with a KillTree-based implementation, pre-generates SSH keys to avoid MSYS2 path conversion issues, and switches CI from WSL2-based BATS to MSYS2. The implementation handles Windows process semantics and WSL2 deadlock prevention with care. One important gap remains: the deletion path force-stops broken instances using PIDs that may have been recycled on Windows.
Structure: 0 critical, 1 important, 5 suggestions. Design observations highlight the sequential shutdown timeout concern (pre-existing TODO) and several well-executed patterns.
Critical Issues ¶
None.
Important Issues ¶
This PR adds StatusBroken to the force-stop guard at line 47. On Windows, Lima's ReadPIDFile does not clean stale PID files (unlike Unix, where it removes the file and returns 0 for dead processes). A stopped instance whose PID files survive can appear as StatusBroken if its hostagent PID was recycled to an unrelated process. stopInstanceForcibly (line 596) calls KillTree on inst.HostAgentPID and inst.DriverPID at lines 598–603, terminating the unrelated process.
The PID zeroing at lines 55–58 protects against Lima's internal StopForcibly during limainstance.Delete, but runs after stopInstanceForcibly has already acted on the stale PIDs.
Before this PR, handleDeletion called StopForcibly only for StatusRunning. Adding StatusBroken introduces a new path where stale PIDs reach KillTree.
existingInst, err := store.Inspect(ctx, limaVM.Name)
if err != nil {
logger.Error(err, "Failed to inspect Lima instance for deletion")
}
if existingInst != nil {
if existingInst.Status == limatype.StatusRunning || existingInst.Status == limatype.StatusBroken {
stopInstanceForcibly(ctx, logger, existingInst)
} else if existingInst.VMType == limatype.WSL2 {
// A "stopped" WSL2 distro can retain kernel state that deadlocks
// wsl.exe --unregister. Terminate it without PID-based killing,
// since the PIDs may have been recycled on Windows.
terminateWSL2Distro(ctx, logger, existingInst.Name)
}
// Clear PIDs so Lima's Delete → StopForcibly does not kill
// unrelated processes if the PIDs were recycled (likely on Windows).
existingInst.DriverPID = 0
existingInst.HostAgentPID = 0
func stopInstanceForcibly(ctx context.Context, logger logr.Logger, inst *limatype.Instance) {
allKilled := true
for _, pid := range []int{inst.DriverPID, inst.HostAgentPID} {
if pid > 0 {
if err := process.KillTree(ctx, pid); err != nil {
logger.V(1).Info("Failed to kill process tree", "pid", pid, "error", err)
allKilled = false
}
}
}
Fix: Restrict PID-based force-stop to StatusRunning; for StatusBroken, use terminateWSL2Distro (on WSL2) and stale-file cleanup without PID killing:
- if existingInst.Status == limatype.StatusRunning || existingInst.Status == limatype.StatusBroken {
+ if existingInst.Status == limatype.StatusRunning {
stopInstanceForcibly(ctx, logger, existingInst)
} else if existingInst.VMType == limatype.WSL2 {
Suggestions ¶
The old code sent SIGTERM via process.Kill. The new code sends SIGINT via process.Interrupt, falling back to Kill only on error. Both signals are handled identically by the apiserver signal handler (which registers both os.Interrupt and syscall.SIGTERM), so this is functionally correct. On Unix, process.Interrupt (which sends SIGINT via syscall.Kill) always succeeds for a valid PID, so the Kill fallback never fires. The fallback exists for Windows, where GenerateConsoleCtrlEvent can fail cross-console. The asymmetry deserves a comment.
pid := PID()
// Try graceful shutdown first. On Unix, Kill already sends SIGTERM which
// triggers the Go signal handler. On Windows, Kill uses TerminateProcess
// which bypasses all handlers, so we send Interrupt (CTRL_BREAK_EVENT)
// first to let the service run its shutdown path (shutdownAllHostagents).
//
// On Windows, Interrupt uses GenerateConsoleCtrlEvent, which only works
// when caller and target share a console. If invoked from a different
// terminal, the call fails and we fall back to Kill (TerminateProcess),
// bypassing graceful shutdown. Hostagents survive in their own process
// groups and self-heal on the next service start via killOrphanedHostagent.
if err := process.Interrupt(pid); err != nil {
if err := process.Kill(pid); err != nil {
return fmt.Errorf("failed to stop %q control plane with pid %d: %w", instance.Name(), pid, err)
}
}
Fix:
+ // On Unix, Interrupt (SIGINT) always succeeds for a valid PID; the Kill
+ // fallback fires only on Windows when caller and target lack a shared console.
if err := process.Interrupt(pid); err != nil {
When signalHostagent returns false (no watcher), killHostagent is also a no-op (same nil check). The message "killing process directly" is misleading; the actual work happens in forceStop() via stopInstanceForcibly.
logger.Info("Hostagent did not exit gracefully, forcing stop")
forceStop()
waitAfterKill()
}
} else {
logger.Info("Could not signal hostagent, killing process directly")
r.killHostagent(name)
forceStop()
waitAfterKill()
}
}
Fix:
- logger.Info("Could not signal hostagent, killing process directly")
- r.killHostagent(name)
+ logger.Info("No watcher for hostagent, forcing stop via stored PIDs")
+ r.killHostagent(name) // no-op if watcher absent; forceStop uses stored PIDs
vm_template_running passes through RDD_VM_TYPE only on Unix (line 88). On Windows, _wsl2_template always sets vmType: wsl2 and ignores the variable.
# Lima VM template selection for BATS tests.
#
# On Windows (WSL2), set RDD_WSL_DISTRO to choose between distros:
# - "finch" (default): Finch rootfs (Fedora-based, stable for testing)
# - "opensuse": rancher-desktop-opensuse (the target distro for RD2)
#
# Usage in test files:
# VM_TEMPLATE=$(vm_template) # for limavm-instance tests
# VM_TEMPLATE=$(vm_template_running) # for limavm-running tests (supports RDD_VM_TYPE)
#
: "${RDD_WSL_DISTRO:=finch}"
Fix:
-# VM_TEMPLATE=$(vm_template_running) # for limavm-running tests (supports RDD_VM_TYPE)
+# VM_TEMPLATE=$(vm_template_running) # for limavm-running tests (supports RDD_VM_TYPE on Unix)
The helper uses a fixed temp path (user.tmp) without an inter-process lock, unlike Lima's upstream DefaultPubKeys() which wraps generation in a config-dir lock. If two controller processes race through startup, one can delete the other's temp files. In practice, only one lima-controller runs per instance, so this race is unlikely. The code already handles crash recovery (orphaned .tmp files) correctly.
// Remove orphaned public key (e.g. from a previous partial rename
// failure). On Windows, os.Rename fails if the destination exists.
_ = os.Remove(pubPath)
configDir := filepath.Dir(privPath)
if err := os.MkdirAll(configDir, 0o700); err != nil {
return fmt.Errorf("could not create %q: %w", configDir, err)
}
// Generate into a temporary path and rename on success, so an
// interrupted ssh-keygen does not leave a partial key that blocks
// future attempts.
tmpPath := privPath + ".tmp"
// Clean up stale temp files from a prior crash so ssh-keygen does not
// prompt "Overwrite (y/n)?" and hang waiting for a TTY that doesn't exist.
_ = os.Remove(tmpPath)
_ = os.Remove(tmpPath + ".pub")
cmd := exec.CommandContext(ctx, "ssh-keygen", "-t", "ed25519", "-q", "-N", "",
"-C", "lima", "-f", tmpPath)
if out, err := cmd.CombinedOutput(); err != nil {
// Clean up any partial files left by ssh-keygen.
_ = os.Remove(tmpPath)
_ = os.Remove(tmpPath + ".pub")
return fmt.Errorf("failed to generate SSH key: %s: %w", out, err)
}
if err := os.Rename(tmpPath+".pub", pubPath); err != nil {
_ = os.Remove(tmpPath)
_ = os.Remove(tmpPath + ".pub")
return fmt.Errorf("failed to rename public key: %w", err)
Fix: Consider wrapping the generate/rename section in lockutil.WithDirLock(configDir, ...), or document why single-controller-per-instance makes locking unnecessary.
The main RDD service uses logrus, which supports warn, warning, error, and fatal. Setting RDD_LOG_LEVEL=error (valid for the service) triggers four warning messages to stderr — one per spawned external controller. Since klog has no equivalent granularity, mapping these levels to the default info verbosity is correct, but the warning is noise.
func setVerbosityFromEnv() {
level := strings.TrimSpace(os.Getenv("RDD_LOG_LEVEL"))
switch strings.ToLower(level) {
case "trace":
if err := flag.Set("v", "4"); err != nil {
fmt.Fprintf(os.Stderr, "failed to set klog verbosity: %v\n", err)
}
case "debug":
if err := flag.Set("v", "2"); err != nil {
fmt.Fprintf(os.Stderr, "failed to set klog verbosity: %v\n", err)
}
case "", "info":
// info is the default klog verbosity; nothing to change.
default:
// klog has no warn/error levels; unsupported values fall through to
// the default verbosity (v=0), which is equivalent to info-only.
fmt.Fprintf(os.Stderr, "RDD_LOG_LEVEL=%q is not supported for klog; valid values are trace, debug, info\n", level)
}
}
Fix: Accept valid logrus levels silently:
- case "", "info":
+ case "", "info", "warn", "warning", "error", "fatal":
// info is the default klog verbosity; nothing to change.
+ // klog cannot suppress info-level messages, so higher logrus levels
+ // fall through to the default verbosity (v=0).
default:
Design Observations ¶
Concerns
Sequential hostagent shutdown can exceed StopWithWait timeout (future) Claude Gemini — The TODO at limavm_controller.go:504 acknowledges this. The sequential loop takes N x 30s in the worst case. StopWithWait enforces a 60s total timeout (service.go:360). With N > 2 hanging hostagents, the service is force-killed before cleanup finishes, leaving hostagents orphaned. This PR adds stopInstanceForcibly to the non-graceful path (lines 519–528), which adds WSL2 termination time per hostagent. Parallelizing the wait loop with a sync.WaitGroup would bound total time to a single timeout window regardless of N.
Strengths
Platform-appropriate signal semantics — Using CTRL_BREAK_EVENT targeted at process groups rather than Lima's console-wide GenerateConsoleCtrlEvent fixes the root cause of the service self-destructing when stopping a hostagent. Claude Codex Gemini
Crash-safe SSH key generation — ensureSSHKeysAt generates keys to a temp path, then renames. It handles all partial-state combinations (missing pub key, corrupt private key, stale temps) and cleans up on every failure path. Unit tests cover each state comprehensively. Claude Gemini
Conditional file cleanup — stopInstanceForcibly removes PID/socket/tmp files only when all KillTree calls succeed. Since Lima's store.Inspect derives StatusStopped from missing PID files, removing them while a process is alive would mask a running process. Claude Codex
PID recycling defense in deletion — handleDeletion zeroes DriverPID and HostAgentPID before passing to limainstance.Delete, preventing Lima's internal StopForcibly from killing recycled processes. Claude
Testing Assessment ¶
Test coverage is strong. The BATS tests exercise VM lifecycle, crash recovery, orphaned hostagent recovery, graceful shutdown, template changes, and cleanup. The SSH key unit tests cover all partial-state combinations. Untested scenarios, ranked by risk:
- StatusBroken deletion with recycled PIDs on Windows — The highest-risk gap (see Important Issue 1). No test exercises deletion of a broken instance with stale PID files.
shutdownHostagentforce-stop timeout path — Acknowledged in a comment at line 520. Requires a hostagent that ignores shutdown signals.StopWithWaitcross-console failure on Windows — Comment at lines 346–350 describes the scenario. Self-heals viakillOrphanedHostagenton next start.- Concurrent SSH key generation — Unit tests cover crash recovery but not concurrent callers (low risk: single controller per instance).
Documentation Assessment ¶
No gaps. Comments explain platform-specific behavior clearly. The process_windows.go and process_unix.go docstrings mirror each other's platform notes, aiding cross-platform understanding.
Acknowledged Limitations ¶
limavm_controller.go:504-505— "TODO: Wait on all hostagents in parallel instead of sequentially; with the current loop, the total wait is N x gracefulShutdownTimeout in the worst case." This PR adds force-stop to the timeout path, making the worst case slightly worse per-hostagent.process_windows.go:61-75— "Platform asymmetry: if the target process is already dead, taskkill /T returns exit code 128 (treated as success), but surviving children (e.g., SSH port forwarders) are not killed because taskkill cannot traverse the tree from a dead parent." Acceptable:KillTreeruns while the parent is alive; orphaned port forwarders are harmless.limavm_lifecycle.go:520-522— "Not tested: the forceStop fallback requires a hostagent that ignores shutdown signals." The orphaned hostagent test exercises forced stop indirectly.service.go:346-350— "On Windows, Interrupt uses GenerateConsoleCtrlEvent, which only works when caller and target share a console." Falls back toTerminateProcess; hostagents self-heal on next start.
Agent Performance Retro ¶
Claude Opus 4.6
Claude completed in 4 min 17 s and produced the most detailed review (173 lines). It identified a valid but low-severity SIGINT-vs-SIGTERM asymmetry, raised several code-clarity suggestions, and delivered thorough design observations. Claude traced through callers and callees (e.g., confirming the apiserver signal handler accepts both SIGINT and SIGTERM). It correctly downgraded findings where the code is functionally correct but documentation-worthy.
Claude did not flag the StatusBroken PID recycling issue (Codex's unique insight) or the klog verbosity warning spam (Gemini's unique insight).
Codex GPT 5.4
Codex completed in 6 min 13 s with a concise 93-line review. It raised the strongest finding: the StatusBroken deletion path killing recycled PIDs, with a well-argued trace through ReadPIDFile behavior asymmetry on Windows. Codex also noted the SSH key locking gap.
Codex missed the SIGINT/SIGTERM asymmetry in service.go (which Claude caught) and the klog verbosity issue (which Gemini caught). Its coverage summary marks service.go and process_windows.go as "Reviewed, no issues" — the former is debatable given Claude's suggestion about the same file.
Gemini 3.1 Pro
Gemini completed in 6 min 14 s with 124 lines. It raised a practical suggestion about klog verbosity warnings for valid logrus levels — a quality-of-life improvement that both other agents missed. Its important finding about sequential shutdown is valid but flags a pre-existing TODO rather than a regression.
Gemini marked limavm_lifecycle.go as "Reviewed, no issues" despite the StatusBroken PID recycling gap that Codex found — a coverage miss.
Summary
| Metric | Claude Opus 4.6 | Codex GPT 5.4 | Gemini 3.1 Pro |
|---|---|---|---|
| Duration | 4:17 | 6:13 | 6:14 |
| Critical | 0 | 0 | 0 |
| Important | 1 (downgraded to suggestion) | 1 | 1 (pre-existing TODO) |
| Suggestion | 4 | 1 | 1 |
| Design observations | 2 concerns, 4 strengths | 2 strengths | 3 strengths |
| False positives | 0 | 0 | 0 |
| Unique insights | SIGINT asymmetry, log message, comment accuracy | StatusBroken PID recycling | klog verbosity mapping |
| Files reviewed | 20/20 | 20/20 | 20/20 |
| Coverage misses | StatusBroken gap, klog gap | SIGINT asymmetry | StatusBroken gap |
Codex provided the highest-value unique insight (the StatusBroken PID recycling finding). Claude delivered the broadest coverage with the most suggestions and design observations. Gemini caught a practical usability issue the others missed. The multi-agent approach justified itself: no single agent found all three unique insights.