Skip to content

fix: bound WinRM command.Wait() and remotefs upload against dead sessions - #473

Merged
kke merged 1 commit into
k0sproject:mainfrom
james-nesbitt:fix/winrm-command-wait-timeout
Sep 21, 2026
Merged

kke merged 1 commit into
k0sproject:mainfrom
james-nesbitt:fix/winrm-command-wait-timeout

Conversation

@james-nesbitt

@james-nesbitt james-nesbitt commented Sep 16, 2026 •

Copy link
Copy Markdown
Contributor

What

Bound every point where a WinRM command or a remote-file transfer could block forever on a session that has silently died.

Why

Fixes #472. Observed as a 40+ minute hang in Mirantis/launchpad's smoke-windows CI, right after a Windows host reboot, during a remotefs.Upload call as part of an MCR uninstall. A goroutine dump from the test's own timeout panic pinned both stuck points precisely (see the issue for the full stacks).

How

protocol/winrm

  • command.Wait() takes the ctx StartProcess already receives and races it against completion. context.Background() callers see unchanged (unbounded) behaviour; callers with a deadline are now honoured.
  • Giving up needs care: the goroutines copying the command's output outlive Wait(). StartProcess now wraps the caller's writers in a detachableWriter that Wait() cuts loose before returning, and closes the command and the shell in the background — in separate goroutines, so a close that never returns cannot strand the other — since each is a synchronous request to a host that has very likely stopped answering.
  • Detaching is deliberately non-blocking, so it cannot interrupt a write already inside the caller's writer: an io.Writer offers no way to do that, and waiting for one would make Wait() unbounded again. StartProcess's documentation now states the contract — when Wait() returns a context error the command is still running, and the caller must not recycle or concurrently read the writers it passed in.
  • Rig's own writers are made safe for that window rather than relying on it being narrow: cmd.ExecOutputContext drops its stdout buffer instead of pooling it when the context expired; the stderr buffer behind ExecOptions.ErrString — read by waiterWrapper.Wait the moment Wait returns — is mutex guarded; and iostream.ScanWriter and redact.Writer, the per-line trace writers closed at that same moment, guard the state Write and Close share. Closing them therefore waits for a write in flight, so on the abandoned path waiterWrapper.Wait hands that to a goroutine instead of doing it itself — they still get closed, releasing the scanner goroutine each one owns, just not on the caller's time.
  • Command input is copied through a context-aware reader, so that goroutine stops at the next read boundary instead of outliving an abandoned command.

remotefs

  • winFile had no bound anywhere: not on writing a rigrcp request, not on waiting for its response, and not on the payload transfers in Write, Read and CopyTo that happen between two round trips. All are now bounded by commandTimeout (30s, a var so tests can lower it).
  • It is an idle timeout, not a transfer budget: the countdown restarts on every step forward, and Write chunks its payload so one large call reports progress as it goes. A slow but progressing multi-megabyte upload is not killed for taking longer than 30s. What counts as a step differs by direction, and commandTimeout documents it: a round trip completing, each successful read of a payload coming back, but on the way out only a whole chunk handed to the connection — io.PipeWriter.Write does not return before that — so the write side has a floor of one payloadChunkSize per commandTimeout, roughly a kilobyte a second.
  • Hitting the timeout means the session is dead, so winFile.abort() tears it down instead of leaving it half-open. Closing the far ends of the pipes releases everything parked on them (the response reader, a payload write, a payload read) with the timeout error rather than leaking those goroutines; cancelling the command context stops the remote helper; the file is marked closed so later operations fail fast.
  • New exported remotefs.ErrTimeout so callers can recognise this failure mode. It wraps context.DeadlineExceeded, so errors.Is(err, context.DeadlineExceeded) matches too.
  • The watchdog decides whether to abort from the time of the last progress, under its own lock, rather than leaving it to the timer: neither Stop nor Reset has any effect on an AfterFunc callback that has already begun, and such a callback can sit waiting for that lock while the operation it covers finishes or moves on. Without this, a watchdog expiring in a photo finish could tear down a session that had just succeeded, or one that had just reported progress.
  • Errors from a rigrcp round trip no longer carry an fs.PathError of their own, leaving the public operation to name itself: a stalled Read reports read rather than open, and callers that already wrapped these errors stop nesting two path errors.
  • rigrcp.ps1 accumulates a w N payload up to the declared count. $in is a FileStream over stdin, so Read returns as soon as any data is there, and a payload larger than one transport chunk has always arrived in several — the single-Read short-read check could reject a perfectly good WinFS.WriteFile. Predates this PR (bytes.Reader implements io.WriterTo, so WriteFile hands over the whole payload at once), surfaced while reviewing it.
  • Upload skips its temp-file cleanup after a session timeout: RemoveAll would run against the connection that just died and block there, leaving the public call unbounded on the very path this adds.
  • For that to work the timeout has to survive the trip out. winFile records why it aborted, because closing the pipes is what releases a parked transfer but io.Pipe keeps the first close error it is given — a transfer that raced the rigrcp command exiting came back with a plain closed pipe, and Upload would not have recognised the timeout it has to skip cleanup for. copyAndVerifyUpload joins a failed close onto a failed copy rather than dropping it, and WinFS.ReadFile/WriteFile/ReadDir report a close that timed out instead of discarding it in a defer, so they cannot return success over a session that has gone.
  • Close reports a quit that timed out instead of only logging it, and waits for the helper to actually exit rather than treating the local write as proof it shut down. The file is closed by then either way, but the session may not be, and Upload goes straight from there to checksumming the file it just wrote.
  • rigrcp.ps1's quit handler called Close-Dipose; the function is Close-Dispose. Every quit threw, got caught by the loop's handler and emitted a stray error object instead of disposing the streams. Harmless before — nothing read a response to q — but not something to leave in place now that Close waits on that exit.
  • winFileDirBase.closed becomes an atomic.Bool — it was already written from the goroutine watching the rigrcp command exit while callers read it, and the watchdog adds a third goroutine.

Testing

  • remotefs/winfile_internal_test.go: each previously unbounded point (command write, response wait, Write payload, Read payload) returns ErrTimeout in well under a second instead of hanging, and each asserts the session was actually torn down. Two positive tests pin the idle-vs-budget semantics: a slow but steady Write and CopyTo that both outlast commandTimeout still complete. Two tests pin the watchdog photo finish, one per direction: neither stop() nor progress() loses to a callback that has already begun. Happy path and "session ended" path unaffected.
  • protocol/winrm/connection_test.go: command now holds the shell and the command behind the small unexported interfaces its own methods use, rather than *winrm.Shell/*winrm.Command directly — seam enough to stand a fake in, since the concrete types talk SOAP to a real host. Both halves of Wait are covered: finishing inside the deadline, exit codes, waiting for the output copies, and the deadline passing first (prompt context error, writers out of the caller's reach by the time Wait returns, teardown landing in the background). Without the fix that last test does not fail — it hangs, which is WinRM command.Wait() and remotefs upload can hang forever on a dead session #472's own shape. Also covered: detachableWriter (including that detach() returns with a write parked inside the caller's writer) and ctxReader.
  • go build, go vet, go test -race ./... and golangci-lint run ./... all clean.
  • End-to-end validation is via the launchpad smoke-windows re-run this was found on.

Written by AI: claude-sonnet-5, claude-opus-5

james-nesbitt added a commit to Mirantis/launchpad that referenced this pull request Sep 16, 2026
smoke-windows CI hung for the full 60-minute test timeout: a Windows
host rebooted mid-uninstall, reconnected successfully, and the very
next remotefs.Upload call then hung indefinitely. A goroutine dump
from the test's own timeout panic traced the root cause to rig v2:
command.Wait() (protocol/winrm/connection.go) and the remotefs
rigrcp helper's command loop (remotefs/winfile.go) had no timeout
at all, so a WinRM session that silently died post-reboot could
block the caller forever. Filed and fixed upstream as
k0sproject/rig#472 / k0sproject/rig#473.

The upstream fix only helps if a caller actually supplies a bounded
context: rig's plain Exec/ExecOutput hardcode context.Background(),
which never has a deadline. Add that bound here as defense in depth,
independent of when the upstream fix lands:

- pkg/configurer/host.go: extend the Host interface with
  cmd.ContextRunner so configurers can call ExecContext/
  ExecOutputContext.
- pkg/product/mke/config/host.go: add matching ExecContext/
  ExecOutputContext wrapper methods on Host, mirroring Exec/
  ExecOutput's existing sudo/SudoDocker routing (the promoted methods
  from the embedded *rig.Client would silently skip that routing).
- pkg/configurer/windows.go: bound every Exec/ExecOutput call in
  InstallMCR, UninstallMCR, and RestartMCR -- the MCR lifecycle
  operations that run immediately around host reboots -- with a new
  windowsExecTimeout (15 minutes, generous for legitimate slow
  installs/image pulls, not a tight SLA). remotefs.Upload calls in
  these functions are unaffected by this change; they are already
  self-bounded by the upstream winfile.go fix regardless of caller
  context.

Points the go.mod replace directive at the fork commit carrying the
upstream fix (k0sproject/rig#473) until it merges and is tagged
upstream, at which point the replace should be dropped and the
version bumped normally.

Added TestExecCtxIsBoundedAndCancelable pinning that the new helper
actually carries a deadline and that cancel works.

Verified: go build, go vet, full go test --tags 'testing' ./pkg/...,
golangci-lint run pkg/configurer/... pkg/product/mke/config/... (one
pre-existing, unrelated gofumpt finding in hosts.go, unchanged by
this commit). End-to-end verification is the smoke-windows re-run
this was found on.

Signed-off-by: James Nesbitt <jnesbitt@mirantis.com>
@kke
kke requested a lite review from Copilot September 17, 2026 11:23

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Unresolved timeout coverage, cleanup, and payload-write blocking issues remain.

Get a fresh assessment by requesting another Copilot review.

Pull request overview

This PR bounds WinRM command waits and remote-file control operations to prevent indefinite hangs after dead sessions.

Changes:

  • Propagates contexts into WinRM command waits.
  • Adds a 30-second rigrcp command timeout.
  • Adds timeout, success, and ended-session tests.
File summaries
File Review findings
remotefs/winfile.go Critical: payload writes can still block indefinitely. Moderate: timed-out operations may leave goroutines and the file daemon active; timeout errors lack a public sentinel.
remotefs/winfile_internal_test.go Nit: add coverage for a blocked writer and its cleanup.
protocol/winrm/connection.go Applies caller context deadlines to command waits.
Review details

Suppressed comments (2)

remotefs/winfile.go:279

  • On a timeout after the file daemon has been opened, this returns without canceling f.cancel or closing the pipe. The response reader goroutine above (and a writer goroutine when the write itself is the operation that timed out) can therefore remain parked forever, while the WinRM command was started with a cancellable-but-not-deadlined context. Cancel/tear down the daemon on this failure so the per-command goroutines are released and the file is not left half-open.
	case <-ctx.Done():
		return nil, f.pathErr(OpOpen, fmt.Errorf("%w: writing rcp command %q", errRcpTimeout, cmd))

remotefs/winfile_internal_test.go:29

  • This test uses io.Discard for stdin, so fmt.Fprintf can never block and the newly added write-timeout path (and its blocked-writer cleanup) is untested; it only covers waiting for a response. Add a separate case with an unconsumed io.Pipe writer or another blocking writer, while retaining this response-timeout case.
		stdin:  nopWriteCloser{io.Discard},
		stdout: bufio.NewReader(stdoutR),
  • Files reviewed: 3/3 changed files
  • Comments generated: 2
  • Review effort level: Lite

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread remotefs/winfile.go Outdated
Comment thread remotefs/winfile.go Outdated
@kke
kke force-pushed the fix/winrm-command-wait-timeout branch from b534cbe to 18ad7ce Compare September 17, 2026 12:53
@kke
kke requested a lite review from Copilot September 17, 2026 12:55

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

WinRM abandonment can still block or leak resources, and timeout handling and test timing need correction.

Get a fresh assessment by requesting another Copilot review.

Review details

Suppressed comments (2)

remotefs/winfile.go:352

  • timedOut hard-codes OpOpen, but command is also used by Read (which returns this error directly). A read that hits the new timeout therefore exposes an fs.PathError whose Op is open, contradicting the filesystem convention that the operation names the public call (remotefs/patherror_test.go:10-19). Return the timeout cause here and let each public operation add its own path error, as Read and the other callers require.
func (f *winFile) timedOut(what string) error {
	err := fmt.Errorf("%w: %s", ErrTimeout, what)
	f.abort(err)
	return f.pathErr(OpOpen, err)

remotefs/winfile_internal_test.go:213

  • This test sleeps for exactly half of the 100 ms idle timeout before each chunk, so scheduler/GC jitter can make the gap between progress reports reach the timeout and fire the watchdog before the next read. With three chunks the test is already intended to outlast the timeout; use more chunks (or otherwise leave a margin per interval) while keeping the total transfer longer than timeout.
		timeout = 100 * time.Millisecond
		chunks  = 3
  • Files reviewed: 5/5 changed files
  • Comments generated: 2
  • Review effort level: Lite

Comment thread protocol/winrm/connection.go Outdated
Comment thread protocol/winrm/connection.go

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Unresolved writer-lifetime, cleanup, timeout-race, and buffer-handling issues remain.

Get a fresh assessment by requesting another Copilot review.

Review details

Suppressed comments (3)

protocol/winrm/connection.go:365

  • This changes Wait() so it can return while the remote command and its copying goroutines are still running, but the exported StartProcess documentation below still says that its Wait() blocks until the command finishes. Update the public contract (and the Waiter documentation if applicable) to explain the early context return and the resulting writer-lifetime requirement.
// Wait blocks until the command finishes or ctx (the context StartProcess was
// called with) is done, whichever happens first.
//
// Without this, a command whose underlying WinRM connection has silently
// died -- for example the host rebooted mid-command and this shell was
// never actually torn down on this end -- blocks forever: neither
// c.wg.Wait() nor the underlying masterzen/winrm Command.Wait() it calls
// take a context or have any internal timeout of their own. If ctx has no
// deadline this preserves the previous unbounded behaviour; callers that
// want a bound must set one on the context passed to StartProcess.

protocol/winrm/connection.go:352

  • The two cleanup requests are issued serially even though this path is specifically for an unresponsive host. If c.cmd.Close() blocks on the dead WinRM session, c.sh.Close() is never attempted, leaving the shell cleanup behind indefinitely. Run the cleanup requests independently or give each its own bounded cancellation path.
	go func() {
		_ = c.cmd.Close()
		_ = c.sh.Close()
	}()

remotefs/winfile.go:120

  • Timer.Stop does not wait for an AfterFunc callback that has already started, so the callback can still call abort after the transfer returns successfully. If expiry races with the final read/write, this marks the file closed and closes its pipes, causing a subsequent operation to fail with fs.ErrClosed despite the completed operation. Synchronize callback execution with stop (or use an active/generation check) so an operation cannot be aborted after it has ended.
func (w *watchdog) stop() {
	w.timer.Stop()
}
  • Files reviewed: 6/6 changed files
  • Comments generated: 2
  • Review effort level: Lite

Comment thread protocol/winrm/connection.go
Comment thread cmd/executor.go Outdated
@kke
kke force-pushed the fix/winrm-command-wait-timeout branch from 1d0b723 to a7862eb Compare September 17, 2026 13:17
@kke
kke requested a lite review from Copilot September 17, 2026 13:18

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Unresolved critical and moderate findings affect timeout coverage and concurrency safety.

Get a fresh assessment by requesting another Copilot review.

Review details

Suppressed comments (3)

protocol/winrm/connection.go:351

  • On the context-error path, abandon lets Wait return while the stdout/stderr copy goroutines can still be inside the writers. waiterWrapper.Wait then immediately closes its traceClosers (for example, redactWriter/iostream.ScanWriter), and detach cannot prevent an already-started write. Those writers are not concurrency-safe, so cancellation with output logging enabled can race their buffers/closed flags or block while the copy is still using them. Owned closers need to be shut down only after the copy goroutines have exited, or made safe for this lifetime.
func (c *command) abandon() error {
	c.stdout.detach()
	c.stderr.detach()
	go func() { _ = c.cmd.Close() }()
	go func() { _ = c.sh.Close() }()

protocol/winrm/connection.go:419

  • The context-error path can also leave the goroutine copying stdin running: ctxReader only notices cancellation before the next read, and a read already in progress may continue after Wait returns. The contract warns callers not to reuse the output writers but says nothing about reusing or closing the input reader, so callers can still race that goroutine; document the same lifetime restriction for stdin (or make the input side part of the teardown guarantee).
// that recycles or concurrently reads the writers it passed in must therefore
// not do so when Wait reports a context error.

remotefs/winfile.go:215

  • f.stdin is the io.PipeWriter created in open, whose Write does not return until the entire slice has been consumed. Thus dog.progress() is not called while a 32 KiB chunk is transferring: a steadily progressing connection slower than about 1 KiB/s can spend over 30 seconds in one Write and be aborted as stalled, contrary to the documented idle-timeout behavior. The watchdog needs to observe partial consumption rather than only chunk completion.
	dog := f.watch(fmt.Sprintf("writing %d byte payload", len(p)))
	defer dog.stop()
	written := 0
	for written < len(p) {
		n, err := f.stdin.Write(p[written:min(written+payloadChunkSize, len(p))])
  • Files reviewed: 7/7 changed files
  • Comments generated: 2
  • Review effort level: Lite

Comment thread remotefs/winfile.go Outdated
Comment thread remotefs/winfile.go
@kke
kke force-pushed the fix/winrm-command-wait-timeout branch from a7862eb to b566771 Compare September 17, 2026 13:27
@kke
kke requested a lite review from Copilot September 17, 2026 13:28

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Two unresolved moderate findings require correction before approval.

Get a fresh assessment by requesting another Copilot review.

Review details

Suppressed comments (1)

remotefs/winfile.go:511

  • If the final q round-trip times out, command aborts the session and returns ErrTimeout, but this code logs that error and still returns nil. In the upload path, remote.Close() can therefore report success and the caller proceeds to Sha256 on a dead WinRM session, potentially re-entering an unbounded background command instead of seeing the timeout. Propagate the q error after marking/closing the file.
	_, err = f.command("q")
	log.Trace(context.Background(), "rigrcp quit", log.ErrorAttr(err))
	f.stdin.Close()
	f.closed.Store(true)
  • Files reviewed: 7/7 changed files
  • Comments generated: 1
  • Review effort level: Lite

Comment thread cmd/executor.go Outdated
@kke
kke force-pushed the fix/winrm-command-wait-timeout branch from b566771 to 8da4416 Compare September 17, 2026 13:39
@kke
kke requested a lite review from Copilot September 17, 2026 13:40

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Cleanup can still block on writer closure, and abandoned-command teardown may accumulate goroutines.

Get a fresh assessment by requesting another Copilot review.

Review details

Suppressed comments (1)

protocol/winrm/connection.go:351

  • Each abandoned command starts two teardown goroutines, and the comments explicitly allow either Close call to never return. Repeated context-bounded commands against a dead host therefore permanently accumulate Wait/close goroutines and their captured shell/command state, eventually exhausting process resources even though callers no longer hang. Please add connection-level tracking or a bounded/deduplicated teardown strategy before treating this as a general fix.
func (c *command) abandon() error {
	c.stdout.detach()
	c.stderr.detach()
	go func() { _ = c.cmd.Close() }()
	go func() { _ = c.sh.Close() }()
  • Files reviewed: 9/9 changed files
  • Comments generated: 2
  • Review effort level: Lite

Comment thread cmd/executor.go Outdated
Comment thread redact/writer.go
@kke
kke force-pushed the fix/winrm-command-wait-timeout branch from 8da4416 to 83294fd Compare September 17, 2026 13:47
@kke
kke requested a lite review from Copilot September 17, 2026 13:48

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Add coverage for command.Wait cancellation paths and correct the misleading writer-lifetime comment.

Get a fresh assessment by requesting another Copilot review.

Review details

Suppressed comments (1)

protocol/winrm/connection.go:286

  • This new internal comment says the caller is entitled to reuse its buffers as soon as Wait returns, but the StartProcess contract below explicitly forbids recycling, closing, or concurrently using those writers when Wait returns a context error. Since detach cannot cover a write that already passed the atomic check, the sentence is misleading and should describe the caller's required lifetime instead.
// those goroutines outlive Wait, while a caller is entitled to reuse the
// buffers it passed in as soon as Wait returns.
  • Files reviewed: 9/9 changed files
  • Comments generated: 1
  • Review effort level: Lite

Comment thread protocol/winrm/connection.go
@kke
kke force-pushed the fix/winrm-command-wait-timeout branch from 83294fd to b168e2f Compare September 17, 2026 13:56
@kke
kke force-pushed the fix/winrm-command-wait-timeout branch from b168e2f to 4840941 Compare September 17, 2026 13:56
@kke
kke requested a lite review from Copilot September 17, 2026 13:57

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Critical issues remain with unbounded upload cleanup and incompatible chunked payload handling.

Get a fresh assessment by requesting another Copilot review.

Review details
  • Files reviewed: 9/9 changed files
  • Comments generated: 2
  • Review effort level: Lite

Comment thread remotefs/winfile.go
Comment thread remotefs/winfile.go
@kke
kke force-pushed the fix/winrm-command-wait-timeout branch from 4840941 to 0bffd76 Compare September 17, 2026 14:04
@kke
kke requested a lite review from Copilot September 17, 2026 14:05

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Needs a closer look

The positive transfer test concurrently reads one pipe through separate readers, making it flaky and potentially incorrect.

Review details

Suppressed comments (1)

remotefs/winfile_internal_test.go:254

  • This test starts serve's reader on s.commands, then drains payload bytes from the separate s.requests reader, but both fields wrap the same stdinR pipe. The serveAndDrain helper is likewise concurrently reading s.commands, so payload bytes can be consumed and discarded by one reader while the test waits on the other, making this positive transfer test flaky or falsely exercising a different stream than the real helper. Consume the command and subsequent payload from one reader (the helper's single stdin stream) instead of having concurrent readers.
		<-served // s.requests is serve's until it has read the command
		var total int64
		for range chunks {
			time.Sleep(timeout / 4)
			n, err := io.CopyN(io.Discard, s.requests, payloadChunkSize)
			total += n
  • Files reviewed: 12/12 changed files
  • Comments generated: 0 new
  • Review effort level: Lite

@kke
kke force-pushed the fix/winrm-command-wait-timeout branch from 0bffd76 to bff41fb Compare September 17, 2026 14:10
@kke
kke requested a lite review from Copilot September 17, 2026 14:10

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Unresolved timeout propagation and error-handling issues must be addressed before approval.

Get a fresh assessment by requesting another Copilot review.

Review details

Suppressed comments (2)

remotefs/upload.go:109

  • The timeout guard only sees the error returned by copyAndVerifyUpload, but that helper discards remote.Close() errors when CopyFrom already failed. If the source fails and the subsequent close hits the new session timeout, failure remains the source error, so this defer calls RemoveAll on the dead connection and can hang forever. Propagate or otherwise record an ErrTimeout from that close before relying on this check.
	var failure error
	defer func() {
		if errors.Is(failure, ErrTimeout) {
			return
		}
		_ = fsys.RemoveAll(tmpPath)
	}()

	if failure = copyAndVerifyUpload(fsys, tmpPath, local); failure != nil {

remotefs/winfile.go:519

  • Although Close now returns ErrTimeout, the Windows convenience helpers still call it via defer f.Close() and discard the result (winfs.go:103,309,323). Thus ReadFile, WriteFile, and ReadDir can report success after the quit timed out and the WinRM session died; propagate the close error in those helpers (or otherwise ensure a timeout is not swallowed).
	// The file itself is closed by now, so a helper that merely exited ahead
	// of the quit is not a failure. A quit that timed out is: the session
	// died rather than shut down, and the caller would otherwise carry on
	// against a dead host -- Upload goes straight from here to checksumming
	// the file it just wrote.
	if errors.Is(quitErr, ErrTimeout) {
		return f.pathErr(OpClose, quitErr)
  • Files reviewed: 12/12 changed files
  • Comments generated: 2
  • Review effort level: Lite

Comment thread remotefs/upload.go
Comment thread remotefs/winfile.go
@kke
kke force-pushed the fix/winrm-command-wait-timeout branch from bff41fb to 438d7af Compare September 17, 2026 14:20
@kke
kke requested a lite review from Copilot September 17, 2026 14:20

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Needs a closer look

Transfer progress is reported only after a full chunk completes, which can incorrectly trigger the idle timeout on slow, continuously progressing links.

Review details

Suppressed comments (1)

remotefs/winfile.go:256

  • io.PipeWriter.Write does not return until the entire 32 KiB slice has been consumed, so dog.progress() is not called when individual bytes move; it is called only after a whole chunk completes. A continuously progressing link that takes more than commandTimeout to deliver one chunk is therefore aborted as idle, despite the documented idle-timeout contract and the claim that slow transfers may outlast the timeout. Progress needs to be reported from a lower-level write/read boundary (or the protocol needs an explicitly documented per-chunk minimum rate), rather than only after this blocking write returns.
		n, err := f.stdin.Write(p[written:min(written+payloadChunkSize, len(p))])
  • Files reviewed: 14/14 changed files
  • Comments generated: 0 new
  • Review effort level: Lite

@kke
kke force-pushed the fix/winrm-command-wait-timeout branch from 438d7af to b055155 Compare September 17, 2026 14:26
@kke
kke requested a lite review from Copilot September 17, 2026 14:26

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Unresolved timeout and cleanup paths can still hang or incorrectly skip temporary-file removal.

Get a fresh assessment by requesting another Copilot review.

Review details

Suppressed comments (2)

remotefs/upload.go:117

  • The cleanup decision is based on errors.Is(failure, ErrTimeout) after aggregating every CopyFrom error. A source reader is allowed to return this exported sentinel itself; io.Copy then propagates it through remote.CopyFrom while the remote file can still close successfully, but Upload will incorrectly skip removing the temporary file. Keep the session-timeout signal separate from the copy error (for example, derive it from the remote close/typed session error) instead of classifying the aggregate error by sentinel alone.
	var failure error
	defer func() {
		if errors.Is(failure, ErrTimeout) {
			return
		}
		_ = fsys.RemoveAll(tmpPath)
	}()

	if failure = copyAndVerifyUpload(fsys, tmpPath, local); failure != nil {

remotefs/winfile.go:315

  • io.CopyN can block inside dst.Write after src.Read has reported progress. If callers pass a backpressured writer such as an io.PipeWriter with no reader, the watchdog will abort and close the remote pipes after commandTimeout, but that cannot interrupt the caller's writer, so CopyTo still never returns. Run the copy behind a cancellation/return boundary or explicitly define and enforce a non-blocking destination contract; as written this does not bound all CopyTo waits.
		n, err := io.CopyN(dst, src, resp.N-total)
		total += n
		if err != nil {
			return total, f.pathErr(OpCopyTo, fmt.Errorf("copy: %w", f.abortCause(err)))
  • Files reviewed: 14/14 changed files
  • Comments generated: 1
  • Review effort level: Lite

Comment thread remotefs/winfile.go
…ions

Fixes k0sproject#472.

Both protocol/winrm.(*command).Wait() and the rigrcp session behind a
remotefs file on a Windows host could block forever when the underlying
WinRM session had silently died -- observed in practice as a 40+ minute
hang in launchpad's smoke-windows CI, right after a Windows host reboot,
during a remotefs.Upload call. A full goroutine dump from the test
timeout panic pinned both stuck points precisely.

command.Wait() called c.wg.Wait() then the underlying masterzen/winrm
Command.Wait(), neither of which take a context or have an internal
timeout. StartProcess already receives a ctx and uses it to start the
command, but never threaded it into Wait(), so a caller-supplied deadline
had no effect on how long Wait() could block. Thread ctx through the
command struct and race it against completion in Wait(); callers that
pass context.Background() see unchanged (unbounded) behaviour, callers
with a deadline are now honoured.

Giving up on a command needs care, because the goroutines copying its
output outlive Wait(). StartProcess hands them a detachable writer that
Wait() cuts loose from the caller's writers before returning, and closes
the command and the shell in the background -- in separate goroutines, so
a close that never returns cannot strand the other -- since each is a
synchronous request to a host that has very likely stopped answering.
Detaching is deliberately non-blocking, and so cannot interrupt a write
already inside the caller's writer: an io.Writer offers no way to do
that, and waiting for one would make Wait unbounded again, which is the
whole point of the change. A writer whose Write can park is in any case
one the caller is still reading from at the other end --
ExecReaderContext's pipe -- rather than one it is about to recycle. The
exported documentation on StartProcess now states this contract.

Rig's own writers are made safe for that window rather than relying on it
being narrow. cmd.ExecOutputContext drops its stdout buffer instead of
pooling it when the caller's context expired. The stderr buffer behind
ExecOptions.ErrString, which waiterWrapper.Wait reads the moment Wait
returns, is mutex guarded. iostream.ScanWriter and redact.Writer, the
per-line trace writers waiterWrapper.Wait closes at the same moment, guard
the state Write and Close share. Closing them therefore waits for a write
in flight, so on the abandoned path waiterWrapper.Wait hands that to a
goroutine rather than doing it itself: they are still closed, releasing the
scanner goroutine each of them owns, just not on the caller's time.

Command input is copied through a context-aware reader, so that goroutine
stops at the next read boundary instead of outliving an abandoned command.

winFile had no bound anywhere: not on the write of a rigrcp request, not
on the wait for its response, and not on the payload transfers in Write,
Read and CopyTo that happen between two round trips. Bound all of them
with commandTimeout, as an idle timeout rather than a duration budget --
the countdown restarts on every step forward, and Write chunks its payload
so a single large call reports progress as it goes. A big but still
progressing transfer is not killed for taking longer than 30s. What counts
as a step differs by direction, and commandTimeout says so: a round trip
completing, each successful read of a payload coming back, but on the way
out only a whole chunk handed to the connection, since io.PipeWriter.Write
does not return before that -- a floor of one payloadChunkSize per
commandTimeout, roughly a kilobyte a second.

Reaching that timeout means the session is dead, so winFile.abort() tears
it down rather than returning and leaving it half-open: closing the far
ends of the pipes releases everything parked on them (the response
reader, a payload write, a payload read) with the timeout error instead
of leaking those goroutines, cancelling the command context stops the
remote helper, and the file is marked closed so later operations fail
fast. Callers can recognise the new failure mode through the exported
remotefs.ErrTimeout, which also wraps context.DeadlineExceeded.

The watchdog decides whether to abort from the time of the last progress,
under its own lock, rather than leaving it to the timer: neither Stop nor
Reset has any effect on an AfterFunc callback that has already begun, and
such a callback can sit waiting for that lock while the operation it
covers finishes or moves on. Without this, a watchdog expiring in a photo
finish could tear down a session that had just succeeded, or one that had
just reported progress.

winFileDirBase.closed becomes an atomic.Bool. It was already written from
the goroutine watching the rigrcp command exit while callers read it, and
the watchdog adds a third goroutine to that.

Errors from a rigrcp round trip no longer carry an fs.PathError of their
own, leaving the public operation to name itself: a stalled Read now
reports "read" rather than "open", and the ops that already wrapped these
errors stop nesting two path errors. Close reports a quit that timed out
instead of only logging it, and waits for the helper to exit rather than
taking the local write as proof it shut down -- the file is closed by then
either way, but the session may not be, and Upload goes straight from there
to checksumming the file it just wrote. While in that path: rigrcp.ps1's
quit handler called Close-Dipose, which is not a thing, so every quit threw
and emitted a stray error instead of disposing the streams.

Two things the new timeout path ran into. rigrcp.ps1 answered "w N" with a
single $in.Read of N bytes and called anything less a short read, but $in
is a FileStream over stdin, which returns as soon as any data is there,
and a payload larger than one transport chunk has always arrived in
several; it now accumulates to the declared count. And Upload skips its
temp-file cleanup after a session timeout: RemoveAll would run against the
connection that just died and block there, leaving the public call
unbounded on the very path this adds.

For that to work the timeout has to survive the trip out. winFile records
why it aborted, because closing the pipes is what releases a parked
transfer but io.Pipe keeps the first close error it is given -- a transfer
that raced the rigrcp command exiting came back with a plain closed pipe,
and Upload would not have recognised the timeout it has to skip cleanup
for. copyAndVerifyUpload joins a failed close onto a failed copy instead of
dropping it, and WinFS.ReadFile, WriteFile and ReadDir report a close that
timed out rather than discarding it in a defer, so they cannot return
success over a session that has gone.

Tests cover each previously unbounded point -- the command write, the
response wait, the Write payload and the Read payload -- asserting both
that the operation returns promptly with ErrTimeout and that the session
was torn down, plus that slow but still progressing Write and CopyTo
transfers complete, and that neither half of the watchdog photo finish --
stop() or progress() -- loses to a callback that has already begun.

The winrm side is tested too. command now holds the shell and the command
behind the small unexported interfaces its own methods use, rather than
*winrm.Shell and *winrm.Command directly, which is seam enough to stand a
fake in: the concrete types talk SOAP to a real host and cannot be faked
otherwise. That covers both halves of Wait -- finishing inside the
deadline, exit codes, waiting for the output copies, and the deadline
passing first, where the test pins the prompt context error, the writers
being out of the caller's reach by the time Wait returns, and the teardown
landing in the background. Without the fix that last test does not fail,
it hangs, which is the shape of k0sproject#472 itself. Also covered: the detachable
writer, including that detaching does not wait behind a parked write, and
the context-aware input reader.

End-to-end validation is via the launchpad smoke-windows re-run this was
found on.

Written by AI: claude-sonnet-5, claude-opus-5

Co-Authored-By: James Nesbitt <jnesbitt@mirantis.com>
Signed-off-by: James Nesbitt <jnesbitt@mirantis.com>
Signed-off-by: Kimmo Lehto <klehto@mirantis.com>
@kke
kke force-pushed the fix/winrm-command-wait-timeout branch from b055155 to 9ad5ed7 Compare September 17, 2026 14:34
@kke
kke requested a lite review from Copilot September 17, 2026 14:34

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Needs a closer look

Broad concurrent lifecycle and teardown changes warrant final human review.

Review details
  • Files reviewed: 14/14 changed files
  • Comments generated: 0 new
  • Review effort level: Lite

@kke

kke commented Sep 17, 2026

Copy link
Copy Markdown
Contributor

Ok that grew quite dramatically..

james-nesbitt added a commit to Mirantis/launchpad that referenced this pull request Sep 19, 2026
* refactor: migrate to rig v2

Rebased onto main. Resolved the SLES InstallMCR conflict to retain the
--allow-vendor-change fix (PRODENG-3623 / #652) expressed in rig v2's API.
Build/test fixes required by the migration: go.mod/go.sum tidied for
github.com/k0sproject/rig/v2, validate_facts_test.go updated to rig v2
CompositeConfig/ssh.Config, and %w error wrapping in the EL/SLES/Ubuntu
configurers. Adds TestUpgradeModernClusterFromLegacy.

Signed-off-by: Kimmo Lehto <kimmo.lehto@gmail.com>

Written by AI: claude-sonnet-5

* fix: restore GET-based MKE health check and unblock its deadlock

Two bugs made every Linux smoke job hang until the harness killed it. MKE
installed fine, then 'Validating MKE Health' looped 'waiting for MKE at
https://<ip>/_ping to become healthy' every 30s until the test panicked.

1. GET -> HEAD regression (introduced by the rig v2 migration)

   CheckHTTPStatus was switched to remotefs.HTTPStatusInsecure, which issues a
   HEAD request on both PosixFS (curl -kIso) and WinFS (Method='HEAD'). The
   previous per-OS implementations issued a GET, and the MKE health endpoints do
   not answer HEAD with 200, so the check could never succeed. rig v2's Windows
   path additionally does not skip TLS verification on PowerShell 5.x, which
   breaks against MKE's self-signed certs.

   Reinstate HTTPStatus on the Linux and Windows configurers (GET, TLS skipped),
   restoring the pre-migration semantics, and have CheckHTTPStatus delegate to
   the configurer again. Keeps the per-OS logic in the per-OS layer.

2. pingHost deadlock (pre-existing on main, latent until the check fails)

   On the error path pingHost sent twice on errCh (the error, then nil) while
   errCh is buffered to len(hosts) and only drained after wg.Wait(). Any failure
   overflowed the buffer, blocked the second send, and left waitgroup.Done()
   unreached, so wg.Wait() blocked forever -- converting a bounded ~5 minute
   failure into an indefinite hang. Now sends exactly one value and defers Done.

Renamed the pingHost host parameter for varnamelen after the added comment
extended the function scope.

Written by AI: claude-sonnet-5

* fix: restore WinRM HTTPS port derivation lost in the rig v2 migration

Both Windows smoke jobs failed at Open Remote Connection with
'connect <ip>:5985: All attempts fail'. Windows hosts listen for WinRM over
TLS on 5986; 5985 is the plaintext port.

creasty/defaults applies rig's winRM port struct-tag default (5985) during
Host.UnmarshalYAML, before rig ever sees the config, so the port is never zero
by the time rig defaults it. rig v0 corrected this by bumping 5985 -> 5986 when
useHTTPS was set; rig v2 only derives the port when it is zero (and only infers
useHTTPS when the port is already 5986). A host with 'useHTTPS: true' and no
explicit port -- exactly what the terraform modules generate -- was therefore
left attempting TLS against the plaintext port.

Restore the bump in Host.UnmarshalYAML so existing configs keep working without
having to name the port explicitly.

Adds TestHostWinRMHTTPSPortDefault, which covers the compatibility matrix
(useHTTPS with no port, with 5985, with 5986, a custom port, and no useHTTPS).
Verified the test fails without the fix, reproducing the CI symptom exactly
(expected 5986, got 5985).

Written by AI: claude-sonnet-5

* fix: stop Windows hosts poisoning Linux OS detection

Mixed Linux/Windows clusters failed OS detection on every Linux host:

  Detect host operating systems => failed to resolve configurer for
  <host>: unsupported OS: linux

rig's os.DefaultRegistry holds [ResolveLinux, ResolveLinuxCompat,
ResolveWindows, ResolveDarwin] and is a process-global. On a successful
match it promotes the winning resolver to the front of the slice so
later hosts hit it first.

That optimisation is only sound while no resolver matches a superset of
another. ResolveLinuxCompat breaks it: it is a fallback for hosts with
no /etc/os-release and reports ID "linux" for any Linux host at all.
Resolving a Windows host swaps ResolveWindows from index 2 to index 0,
which leaves the order [Windows, LinuxCompat, Linux, Darwin] -- the
fallback now sits ahead of the real resolver. Every Linux host detected
after a Windows host is reported as "linux", matches no configurer and
fails the phase.

This is why smoke-fips and smoke-windows failed while smoke-modern,
smoke-legacy and smoke-upgrade passed: only the mixed clusters ever
resolve a Windows host, and the CI logs show the Windows host resolving
immediately before the Linux host failed.

Give the client its own registry, identical to rig's minus the compat
fallback, via rig.WithOSReleaseProvider. Reordering is then harmless
because every remaining resolver matches exactly one OS family.

Dropping the fallback also restores rig v0's semantics: v0 had no compat
resolver, so an unreadable os-release produced a real error instead of a
silent misclassification. Every OS launchpad supports ships an
os-release file, so the fallback could never yield a usable configurer.

The AMI was not at fault -- verified against a live Ubuntu 22.04 FIPS
instance, where rig resolves ID=ubuntu Version=22.04 correctly, 20 runs
out of 20.

Both tests fail without the fix, the first reproducing the exact CI
symptom (expected "ubuntu", got "linux").

Written by AI: claude-sonnet-5

* fix: retry WinRM auth rejections while waiting for hosts

smoke-fips abandoned a Windows host on the first connect attempt:

  Open Remote Connection => connect 44.211.52.127:5986: All attempts fail:
  #1: retry: abort condition reached after 1 attempts: operation cannot be
      completed: create shell: http response error: 401 - invalid content type

The Connect phase exists to wait for hosts to become reachable, and on
Windows an auth rejection is part of that wait: the WinRM HTTPS listener
answers before provisioning has finished configuring authentication, so a
freshly booted host returns 401 for a while with entirely correct
credentials.

rig v2 wraps 401/403 as protocol.ErrNonRetryable, and this phase's
RetryIf honours that, so the wait ended on attempt 1 of 60. rig v0 did
not classify these errors, so launchpad retried them and the condition
healed itself. The migration swapped the predicate from ErrCantConnect to
ErrNonRetryable and inherited the new classification with it.

The contrast is visible within a single CI run: smoke-windows retried an
i/o timeout to attempt 36 of 60, while smoke-fips aborted a 401 at
attempt 1. Both are the same underlying condition - a Windows host whose
provisioning has not finished - and the same host connected fine on the
previous run, so this is timing, not credentials.

Treat 401/403 as retryable again while still honouring ErrNonRetryable
for everything else: bad certificates, host key mismatches and
misconfigured bastions, none of which waiting can fix.

The cost is that genuinely wrong credentials take the retry budget to
report rather than failing at once. That is the right trade here: a
cluster that would have come up must not fail, and the budget is bounded.

Matching is by substring because the WinRM library returns untyped
formatted errors and rig exports no sentinel; rig's own isAuthError does
the same, and both message shapes it covers are matched.

The test drives shouldRetryConnect, the predicate handed to RetryIf. Its
three auth cases fail without this change while the four others pass.

Written by AI: claude-sonnet-5

* fix: recognize exit code 3010 across rig v1/v2 error phrasing

isExitCode3010 matched the exact substring "non-zero exit code: 3010",
which is rig v1's wording. rig v2's WinRM transport formats the same
error as "command exited with a non-zero exit code: exit code 3010"
(note the doubled "exit code"), so the substring never matched and a
successful-but-reboot-required MCR install (ERROR_SUCCESS_REBOOT_REQUIRED)
was treated as a hard failure instead of triggering the reboot phase.

Reproduced on smoke-windows and smoke-fips CI runs for PRODENG-3594:
both failed identically with "failed to install container runtime: ...
command exited with a non-zero exit code: exit code 3010" during
"Install Mirantis Container Runtime on the hosts".

Match on the exit code number via regexp instead of a fixed phrase, so
future wording changes in either rig transport don't silently break
reboot handling again. Added pkg/configurer/windows_test.go covering
both known phrasings, an unrelated exit code, and a numeric substring
collision (13010) that a naive `strings.Contains(err, "3010")` fix
would have wrongly matched.

Signed-off-by: James Nesbitt <jnesbitt@mirantis.com>

* fix: bound Windows MCR install/uninstall/restart execs with a timeout

smoke-windows CI hung for the full 60-minute test timeout: a Windows
host rebooted mid-uninstall, reconnected successfully, and the very
next remotefs.Upload call then hung indefinitely. A goroutine dump
from the test's own timeout panic traced the root cause to rig v2:
command.Wait() (protocol/winrm/connection.go) and the remotefs
rigrcp helper's command loop (remotefs/winfile.go) had no timeout
at all, so a WinRM session that silently died post-reboot could
block the caller forever. Filed and fixed upstream as
k0sproject/rig#472 / k0sproject/rig#473.

The upstream fix only helps if a caller actually supplies a bounded
context: rig's plain Exec/ExecOutput hardcode context.Background(),
which never has a deadline. Add that bound here as defense in depth,
independent of when the upstream fix lands:

- pkg/configurer/host.go: extend the Host interface with
  cmd.ContextRunner so configurers can call ExecContext/
  ExecOutputContext.
- pkg/product/mke/config/host.go: add matching ExecContext/
  ExecOutputContext wrapper methods on Host, mirroring Exec/
  ExecOutput's existing sudo/SudoDocker routing (the promoted methods
  from the embedded *rig.Client would silently skip that routing).
- pkg/configurer/windows.go: bound every Exec/ExecOutput call in
  InstallMCR, UninstallMCR, and RestartMCR -- the MCR lifecycle
  operations that run immediately around host reboots -- with a new
  windowsExecTimeout (15 minutes, generous for legitimate slow
  installs/image pulls, not a tight SLA). remotefs.Upload calls in
  these functions are unaffected by this change; they are already
  self-bounded by the upstream winfile.go fix regardless of caller
  context.

Points the go.mod replace directive at the fork commit carrying the
upstream fix (k0sproject/rig#473) until it merges and is tagged
upstream, at which point the replace should be dropped and the
version bumped normally.

Added TestExecCtxIsBoundedAndCancelable pinning that the new helper
actually carries a deadline and that cancel works.

Verified: go build, go vet, full go test --tags 'testing' ./pkg/...,
golangci-lint run pkg/configurer/... pkg/product/mke/config/... (one
pre-existing, unrelated gofumpt finding in hosts.go, unchanged by
this commit). End-to-end verification is the smoke-windows re-run
this was found on.

Signed-off-by: James Nesbitt <jnesbitt@mirantis.com>

* test: temporarily exclude $ from generated Windows password

Unblocks this branch's CI. launchpad.tf embeds the generated
windows_password directly into the launchpad_yaml output's
password/adminPassword fields, which launchpad's config loader then
runs through envsubst (pkg/config/config.go). PRODENG-3751 made an
unescaped "$word" in that YAML fail config loading loudly if the
implied variable is unset, correctly replacing silent password
truncation -- but the test's password generator was never updated to
account for it, so smoke-windows/smoke-fips fail whenever the random
password happens to contain "$" (e.g. run with password
"Nq4@CLkhD6%HvwO&$K2f": "variable ${K2f} not set").

This is a workaround, not the fix: the real fix is to escape "$" as
"$$" where launchpad.tf embeds the password into that YAML, so a
literal "$" -- legitimate in a real password -- keeps working end to
end, then restore "$" to this generator's symbol set. Tracked under
PRODENG-3751.

Written by AI: claude-sonnet-5

Signed-off-by: James Nesbitt <jnesbitt@mirantis.com>

* fix: confirm swarm NodeID is reported before JoinWorkers returns

smoke-windows failed at the (much later) Label nodes phase:

  failed to label node 100.53.69.105:5986 (): command result:
  process finished with error: Process exited with status 1
  ("docker node update" requires exactly 1 argument. ...)

The empty "()" is swarm.NodeID(h) having returned an empty string
with no error. docker swarm join succeeding only means the join
command itself completed; it does not guarantee this host's own
docker engine has finished updating its local view of swarm state,
particularly right after the reconnect JoinWorkers already does for
Windows hosts (swarm join tears down and re-establishes the WinRM
connection). LabelNodes runs several phases later and had no reason
to expect this, so it used the empty NodeID as-is.

Add a bounded retry (20 attempts, 3s delay) after joining -- and
after the Windows reconnect -- confirming swarm.NodeID(h) actually
returns a non-empty NodeID before JoinWorkers considers the host
joined. Applies to all hosts, not just Windows, since the race is
generic (docker's local swarm-state sync lag), even though it was
only actually observed on Windows in this session's testing, likely
because the Windows reconnect makes the race window more visible.

No dedicated unit test: swarm.NodeID takes the concrete *Host type
(not an interface), consistent with the rest of this package, so
testing this meaningfully would need a live connection; verified via
build, vet, golangci-lint (clean), the full unit suite, and the
smoke-windows run this was found on.

Written by AI: claude-sonnet-5

Signed-off-by: James Nesbitt <jnesbitt@mirantis.com>

* chore: trigger a single clean CI run after AWS quota exhaustion from duplicate label runs

Signed-off-by: James Nesbitt <jnesbitt@mirantis.com>

---------

Signed-off-by: James Nesbitt <jnesbitt@mirantis.com>
Co-authored-by: Kimmo Lehto <kimmo.lehto@gmail.com>
@kke
kke merged commit 502c800 into k0sproject:main Sep 21, 2026
17 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

WinRM command.Wait() and remotefs upload can hang forever on a dead session

3 participants