Skip to content

guest/stdio: keep stdio alive across LCOW live migration - #2793

Open
shreyanshjain7174 wants to merge 5 commits into
microsoft:mainfrom
shreyanshjain7174:stdio-survive-live-migration
Open

guest/stdio: keep stdio alive across LCOW live migration#2793
shreyanshjain7174 wants to merge 5 commits into
microsoft:mainfrom
shreyanshjain7174:stdio-survive-live-migration

Conversation

@shreyanshjain7174

Copy link
Copy Markdown
Contributor

A live migration pauses the UVM and drops the GCS vsock bridge, which then
re-dials on the destination. Today the stdio relay tears the process down when
that happens, and io.Copy throws away whatever it had buffered, so in-flight
stdout/stderr is lost.

Now the relay pauses instead. On a copy failure mid-migration a manager
goroutine re-dials, swaps the conn, and resumes. A bridgeDown flag, set by
cmd/gcs around the reconnect loop, is what tells a migration pause apart from a
real process exit. The output copy drops io.Copy for a read/write-all loop that
keeps the unwritten tail and replays it on the new conn, so the relay does not
lose bytes.

I tried two other designs first: a per-conn wrapper that parks Read/Write until
reconnect (leaves the relays alone but wraps every read/write), and rewriting
both relays into pump loops with a registry (touches the shared relay path
every container and exec runs through). Went with pause-on-error because the
conn is only swapped after the copiers stop, so nothing mutates a live conn
under a running copy.

Caveat: it is reactive. Whatever the kernel or socket already dropped at the
blackout is gone; this only removes the relay-level drop. And if the host
closes the conns just before bridgeDown flips, that stream still tears down.

Tested under -race and on a two-node migration: the container keeps streaming
across the move.

written := 0
for written < len(p) {
n, err := w.Write(p[written:])
written += n

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Shouldnt you only increment written after the err check. If its an err you didnt write it and so shouldnt have incremented.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

This matches io.Copy: per the io.Writer contract n is the bytes actually written even when err is non-nil, so on a partial write those bytes already left. copyOut holds p[written:] and replays only that on the re-dialed conn, so counting the partial n is what stops it re-sending and duplicating those bytes. Moving the increment after the err check would replay the already-sent prefix; TestCopyOutHoldsPartialWriteRemainder covers it. Added a comment on that line in the latest push to make the intent explicit.

@shreyanshjain7174
shreyanshjain7174 marked this pull request as ready for review June 26, 2026 05:12
@shreyanshjain7174
shreyanshjain7174 requested a review from a team as a code owner June 26, 2026 05:12
Comment thread internal/guest/stdio/connection.go Outdated
}()
if settings.StdIn != nil {
c, err := tport.Dial(*settings.StdIn)
port := *settings.StdIn

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

nit: These are all cosmetic changes and do not impact any functionality. Please revert it to earlier state.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Reverted these back to the original in the rebase (your 756c712). Thanks.

Comment thread internal/guest/stdio/connection.go Outdated
}
if settings.StdOut != nil {
c, err := tport.Dial(*settings.StdOut)
port := *settings.StdOut

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

nit: These are all cosmetic changes and do not impact any functionality. Please revert it to earlier state.

Comment thread internal/guest/stdio/connection.go Outdated
}
if settings.StdErr != nil {
c, err := tport.Dial(*settings.StdErr)
port := *settings.StdErr

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

nit: These are all cosmetic changes and do not impact any functionality. Please revert it to earlier state.

Comment thread cmd/gcs/main.go Outdated
bridgeOut = bridgeCon
}

// The bridge is up again; let the stdio relays resume any copiers that

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

We can simplify this logic about indicating the bridge being disrupted due to migration. We can make it generic so that If the connection was disrupted due to any reason, we would retry for 6 Seconds before giving up.

@jterry75 @shreyanshjain7174 What do you think?

See commit- 756c712

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Picked up your 756c712, thanks. The bridgeDown flag and the cmd/gcs wiring are gone, and the copiers now just retry on any disconnect for the redial window instead of keying off a migration flag.

Comment thread internal/guest/stdio/stdio.go Outdated
logrus.ErrorKey: err,
"file": name,
}).Error("opengcs::PipeRelay::copyAndCleanClose - error reading for clean close")
pr.mu.Lock()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The code in this file is too dense and hard to follow. There are way too many lock/unlock patterns. Please simplify the same.

Note: Specific to lock/unlock pattern- Do not snapshot under lock and then use it outside of lock as it defeats the purpose. There can be race condition wherein the value got swapped as soon as you snapshotted and released the lock.
Therefore, consider taking longer locks if needed.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Simplified in 93e5130. run and runCopiers no longer take the lock at all. The swap and teardown now go through a small setConn helper, plus a teardown helper on the tty relay, and the snapshot-under-lock-then-use-outside reads are gone. The manager goroutine is the only writer of the set, so the lock is only there to keep the connection close from racing Wait's CloseRead. Race tested at 50 runs.

@shreyanshjain7174
shreyanshjain7174 force-pushed the stdio-survive-live-migration branch from 67496fa to 93e5130 Compare July 20, 2026 08:57
@shreyanshjain7174
shreyanshjain7174 force-pushed the stdio-survive-live-migration branch 6 times, most recently from d5627a6 to e6d811d Compare July 24, 2026 04:17
Shreyansh Sancheti and others added 5 commits July 29, 2026 19:11
A live migration pauses the UVM and drops the GCS vsock bridge, which then
re-dials on the destination. Today the stdio relay tears the process down when
that happens, and io.Copy throws away whatever it had buffered, so in-flight
stdout/stderr is lost.

Now the relay pauses instead. On a copy failure mid-migration a manager
goroutine re-dials, swaps the conn, and resumes. A bridgeDown flag, set by
cmd/gcs around the reconnect loop, is what tells a migration pause apart from a
real process exit. The output copy drops io.Copy for a read/write-all loop that
keeps the unwritten tail and replays it on the new conn, so the relay does not
lose bytes.

I tried two other designs first: a per-conn wrapper that parks Read/Write until
reconnect (leaves the relays alone but wraps every read/write), and rewriting
both relays into pump loops with a registry (touches the shared relay path
every container and exec runs through). Went with pause-on-error because the
conn is only swapped after the copiers stop, so nothing mutates a live conn
under a running copy.

Caveat: it is reactive. Whatever the kernel or socket already dropped at the
blackout is gone; this only removes the relay-level drop. And if the host
closes the conns just before bridgeDown flips, that stream still tears down.

Tested under -race and on a two-node migration: the container keeps streaming
across the move.

Signed-off-by: Shreyansh Sancheti <shsancheti@microsoft.com>
The io.Writer contract returns n as the bytes written even on a partial-write error, so counting it before the error check lets copyOut replay only the unwritten tail and never re-send those bytes. Addresses a review question.

Signed-off-by: Shreyansh Sancheti <shsancheti@microsoft.com>
Signed-off-by: Harsh Rawat <harshrawat@microsoft.com>
Collapse the repeated connection swap and teardown lock blocks into a small setConn helper (plus a teardown helper for the tty relay), and drop the snapshot-under-lock-then-use-outside reads in run and runCopiers since the manager goroutine is the sole writer of the connection set. Same serialization of the conn close against the Wait CloseRead, fewer lock sites. Also drop a stale bridgeDown mention left in a comment.

Signed-off-by: Shreyansh Sancheti <shsancheti@microsoft.com>
Start relays only after runtime start succeeds and tear them down
synchronously when start fails, and replace the pause-flag plumbing with
explicit needsRedial/canRedial semantics.

A relay round ends only when every copier returns, but a copier parked in
a blocking read of an idle process pipe or pty yields neither data nor EOF
while the container runs. On a live-migration destination the host
connections are dead, so the copier that notices returns errNeedsRedial
and then waits forever on its siblings: the round never ends, the redial
never starts, the stdout pipe fills, and the workload freezes.

Wake the parked copiers instead of waiting for them, by arming an
already-elapsed read deadline on the relay-owned readers and shutting down
the stdin connection. Deadlines are used for the pipe and pty reads
because the vendored vsockConn.SetReadDeadline is a no-op stub and Close
does not interrupt an in-flight read; CloseRead is used for stdin for the
same reason Wait already used it. The container's own pipe ends are
untouched, so the process keeps its stdio across the redial.

The authoritative trigger is the GCS bridge reconnecting, since a copier
write failure cannot fire for a stream with no traffic. That signal is
generation counted rather than a bare closed channel, so an event landing
between rounds or inside redialWithRetry is not lost, and each relay holds
a durable subscription across rounds.

The pty ioctls move off os.File.Fd, which reverts the descriptor to
blocking mode and drops it from the runtime poller for good. After that
SetReadDeadline returns nil and does nothing, which would have left
TtyRelay silently unfixable; the fd received from runc is also set
non-blocking so os.NewFile can register it.

The redial budget is bounded by elapsed time rather than an attempt count,
and sized against what one failed attempt costs: VsockTransport.Dial
retries ETIMEDOUT ten times internally, so a Connect to a port nothing is
listening on takes roughly 20s to fail. Giving up is the expensive
direction, because a relay that keeps retrying costs one goroutine while
one that stops leaves the container wedged on a full stdout pipe with no
way back. Each failed attempt is logged, since a host silently not
listening on a stdio port was otherwise only visible in shim ETW.

Verified on a two-node LCOW live-migration rig with a per-second workload
and with a container that stays completely silent across the migration.
Shim ETW from the silent runs shows the guest re-dialing its stdio ports
about 56 seconds before the workload produced its first byte, so the
bridge notification, not a copier write failure, is what recovered that
relay.

Signed-off-by: Shreyansh Sancheti <shsancheti@microsoft.com>
@shreyanshjain7174
shreyanshjain7174 force-pushed the stdio-survive-live-migration branch from e6d811d to c7f7caf Compare July 29, 2026 13:47
@shreyanshjain7174

Copy link
Copy Markdown
Contributor Author

@rawahars — while verifying this on the two-node rig I hit a second, independent failure that I think belongs to the host side rather than this PR. Flagging it here so it doesn't get lost.

Symptom: after a successful migration the destination container keeps running but its stdout never reaches the host — CRI log frozen, attach returns 0 bytes — and the workload eventually wedges on a full stdout pipe. Roughly 1 migration in 3 on my rig (4 failures in ~13 runs).

Cause: the host's stdio IO channel listener is one-shot. internal/gcs/iochannel.go:

func (c *ioChannel) accept() {
	c.c, c.err = c.l.Accept()
	c.l.Close()          // listener destroyed after the first accept
	close(c.ch)
}

That is fine for a normal container — one connection per process lifetime. But the whole guest-side recovery in this PR is built on the guest re-dialing its stdio ports after resume. The host can satisfy exactly one redial; a second one has nothing to connect to, permanently.

Evidence (shim ETW, Microsoft.Virtualization.containerd-shim-lcow-v2, guest-side timestamps):

run stdout port 0x40000001 outcome
passing pod cb1a27a65e29 dialed once (07:55:21.306) stdout flows
failing pod c6f27f98da90 dialed twice early, then 3× retry never recovers

The failing run:

07:52:45.748  vsock dial 0x40000001
07:52:47.915  vsock dial 0x40000001      <- second dial
07:53:09.185  vsock dial 0x40000001      ~21s (10x ETIMEDOUT)
07:53:30.433  vsock dial 0x40000001
07:53:51.581  failed creating stdout Connection: can't connect after 10 attempts
              opengcs::PipeRelay::run - redial failed; ending relay

VsockTransport.Dial only retries ETIMEDOUT, which is what you get when nothing is bound on the port — not ECONNREFUSED. So the endpoint is absent, not busy.

Ruled out that the host is merely slow: I raised the guest's redial budget from 60s to 5 minutes, rebuilt the guest, and re-ran. Still no recovery at 105s+ with the relay still retrying. Extending guest-side patience cannot help when nothing will ever bind.

Why I think it's worth a separate fix rather than folding into this PR: ioChannel serves every container's stdio, not just migrated ones, so making the listener durable across reconnects has a wider blast radius than this change and deserves its own review. Worth noting the two interact — this PR adds the bridge-reconnect trigger alongside the existing write-failure one, which makes a second dial more likely and therefore makes the host limitation easier to hit.

The guest-side fix here is still necessary: without it the relay never re-dials at all, because a copier parked in a blocking read on an idle pipe keeps runCopiers from ever returning. It just isn't sufficient on its own.

Happy to pick up the host-side change if you'd like, or leave it with you — let me know which you prefer.

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.

3 participants