Skip to content

Commit fcd6d96

Browse files
committed
[live-migration] restore container stdio on source rollback
When a live migration rolls back to the source, the VM resumes but the container's stdout/stderr were dropped during blackout and never restored, so anything watching the container's output saw it stop for good. Resume now brings those streams back the same way the destination already does, so a rolled-back container keeps streaming its output as if the migration had never been attempted. Signed-off-by: Harsh Rawat <harshrawat@microsoft.com>
1 parent 4e4a70a commit fcd6d96

7 files changed

Lines changed: 117 additions & 42 deletions

File tree

internal/controller/process/process.go

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -70,12 +70,12 @@ type Controller struct {
7070
// exitedCh is closed when the process has exited and all cleanup is done.
7171
exitedCh chan struct{}
7272

73-
// vsock ports restored from a migrated process, used to reattach the
74-
// stdio relay on resume.
73+
// vsock ports for reattaching the stdio relay on resume, captured at Save on
74+
// the source and on import on the destination.
7575
stdinPort, stdoutPort, stderrPort uint32
7676

77-
// Wait request id carried over from a migrated process, reused on resume
78-
// so no duplicate wait is issued. Zero if absent.
77+
// Wait request id reused on resume so no duplicate wait is issued, captured
78+
// at Save on the source and on import on the destination. Zero if absent.
7979
waitCallID int64
8080
}
8181

@@ -164,14 +164,17 @@ func (c *Controller) Start(ctx context.Context, events chan interface{}) (int, e
164164
c.processID = c.process.Pid()
165165
c.state = StateRunning
166166

167-
go c.handleProcessExit(ctx, execCmd, events)
167+
go c.handleProcessExit(ctx, execCmd, events, true)
168168

169169
return c.processID, nil
170170
}
171171

172172
// handleProcessExit blocks until the process exits, cleans up IO, and
173173
// publishes the exit event via events channel.
174-
func (c *Controller) handleProcessExit(ctx context.Context, execCmd *cmd.Cmd, events chan interface{}) {
174+
// In case of source rollback, there would be an existing instance of
175+
// handleProcessExit which would report the exit. Therefore, for the
176+
// duplicate call, we would exit early post cmd cleanup via cmd.Wait.
177+
func (c *Controller) handleProcessExit(ctx context.Context, execCmd *cmd.Cmd, events chan interface{}, reportExit bool) {
175178
// Detach from the caller's context so upstream cancellation does
176179
// not abort the background teardown.
177180
ctx = context.WithoutCancel(ctx)
@@ -182,6 +185,12 @@ func (c *Controller) handleProcessExit(ctx context.Context, execCmd *cmd.Cmd, ev
182185
log.G(ctx).WithError(err).Warn("process exit wait failed")
183186
}
184187

188+
// A source rollback's re-attached relay only needs draining; the watcher
189+
// started with the process reports the exit.
190+
if !reportExit {
191+
return
192+
}
193+
185194
exitCode := execCmd.ExitState.ExitCode()
186195

187196
// Record the exit status under the lock.

internal/controller/process/process_test.go

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import (
1313
"github.com/opencontainers/runtime-spec/specs-go"
1414
"go.uber.org/mock/gomock"
1515

16+
"github.com/Microsoft/hcsshim/internal/cmd"
1617
"github.com/Microsoft/hcsshim/internal/controller/process/mocks"
1718
hcs "github.com/Microsoft/hcsshim/internal/hcs/v2"
1819
)
@@ -256,6 +257,49 @@ func TestStart_HostCreateProcessFails(t *testing.T) {
256257
}
257258
}
258259

260+
// TestHandleProcessExit_DrainOnly verifies that with reportExit=false — a source
261+
// rollback's re-attached relay — handleProcessExit drains its command but leaves
262+
// exit reporting (state transition, upstream IO close, and the exit event) to the
263+
// watcher started with the process.
264+
func TestHandleProcessExit_DrainOnly(t *testing.T) {
265+
t.Parallel()
266+
mockCtrl, _, mockIO, controller := newSetup(t)
267+
controller.upstreamIO = mockIO
268+
controller.state = StateRunning
269+
mockProc := mocks.NewMockProcess(mockCtrl)
270+
271+
// cmd.Attach reads Pid (for logging) and Stdio; nil IO means no relay goroutines.
272+
mockProc.EXPECT().Pid().Return(testPID)
273+
mockProc.EXPECT().Stdio().Return(nil, nil, nil)
274+
// execCmd.Wait drives Process.Wait, ExitCode, and Close exactly once.
275+
mockProc.EXPECT().Wait().Return(nil)
276+
mockProc.EXPECT().ExitCode().Return(0, nil)
277+
mockProc.EXPECT().Close().Return(nil)
278+
279+
execCmd, err := cmd.Attach(context.WithoutCancel(t.Context()), mockProc, nil, nil, nil)
280+
if err != nil {
281+
t.Fatalf("Attach() = %v; want nil", err)
282+
}
283+
284+
// No upstreamIO.Close is expected: the unset mock would fail if it were called.
285+
events := make(chan interface{}, 1)
286+
controller.handleProcessExit(t.Context(), execCmd, events, false)
287+
288+
if controller.State() != StateRunning {
289+
t.Errorf("state = %s; want unchanged StateRunning", controller.State())
290+
}
291+
select {
292+
case <-controller.exitedCh:
293+
t.Error("exitedCh was closed; want left to the original watcher")
294+
default:
295+
}
296+
select {
297+
case ev := <-events:
298+
t.Errorf("published event %v; want none", ev)
299+
default:
300+
}
301+
}
302+
259303
// TestKill_NotCreatedState verifies that Kill on a process that was never
260304
// created transitions it directly to StateTerminated without error. Because
261305
// upstreamIO has not been populated yet, abortInternal must tolerate a nil

internal/controller/process/save.go

Lines changed: 18 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,10 @@ func (c *Controller) Save(ctx context.Context) (*anypb.Any, error) {
4848
ms := c.process.MigrationState()
4949
state.StdinPort, state.StdoutPort, state.StderrPort = ms.StdinPort, ms.StdoutPort, ms.StderrPort
5050
state.WaitCallID = ms.WaitCallID
51+
52+
// Retain them so a source rollback resume re-opens IO like the destination.
53+
c.stdinPort, c.stdoutPort, c.stderrPort = ms.StdinPort, ms.StdoutPort, ms.StderrPort
54+
c.waitCallID = ms.WaitCallID
5155
}
5256

5357
// Exec processes carry their OCI spec; init processes leave it unset.
@@ -179,8 +183,9 @@ func (c *Controller) Patch(ctx context.Context, containerID string, opts *Create
179183

180184
// Resume returns a migrating process to the running state. On the destination
181185
// it reattaches the patched process to its live guest counterpart, wires up the
182-
// stdio relay, and begins watching for exit. On the source it simply lifts the
183-
// freeze that Save applied, since the live process and IO are still intact.
186+
// stdio relay, and begins watching for exit. On the source it re-opens the IO
187+
// the blackout dropped and resumes the relay, since the live process is intact
188+
// but its IO connections are not.
184189
// Pass events=nil for an init process, whose exit is reported by its owning
185190
// container instead.
186191
func (c *Controller) Resume(ctx context.Context, gcsContainer *gcs.Container, events chan interface{}) error {
@@ -192,19 +197,16 @@ func (c *Controller) Resume(ctx context.Context, gcsContainer *gcs.Container, ev
192197
return nil
193198
}
194199

195-
// Source rollback: the live process and IO are intact, so just lift the
196-
// freeze that Save applied.
197-
if c.state == StateSourceMigrating {
198-
c.state = StateRunning
199-
return nil
200-
}
201-
202-
if c.state != StateDestinationMigrating {
200+
if c.state != StateDestinationMigrating && c.state != StateSourceMigrating {
203201
return fmt.Errorf("process %q in container %q is in state %s; cannot resume: %w", c.execID, c.containerID, c.state, errdefs.ErrFailedPrecondition)
204202
}
205203

206-
// Reopen the live process on its preserved IO ports and wait id.
207-
gcsProc, err := gcsContainer.OpenProcessWithIO(ctx, uint32(c.processID), c.stdinPort, c.stdoutPort, c.stderrPort, c.waitCallID)
204+
// Flag to determine if the resume is happening on destination.
205+
isDestination := c.state == StateDestinationMigrating
206+
207+
// Reopen the process on its preserved IO ports and wait id. A source rollback
208+
// reuses the still-outstanding wait, so it does not start a second one.
209+
gcsProc, err := gcsContainer.OpenProcessWithIO(ctx, uint32(c.processID), c.stdinPort, c.stdoutPort, c.stderrPort, c.waitCallID, isDestination)
208210
if err != nil {
209211
return fmt.Errorf("open gcs process pid %d in container %q: %w", c.processID, c.containerID, err)
210212
}
@@ -223,9 +225,10 @@ func (c *Controller) Resume(ctx context.Context, gcsContainer *gcs.Container, ev
223225
// Ports are single-use; clear them now that IO is reattached.
224226
c.stdinPort, c.stdoutPort, c.stderrPort = 0, 0, 0
225227

226-
// Watch for exit in the background, mirroring a freshly started process.
227-
go c.handleProcessExit(ctx, execCmd, events)
228+
// The destination owns exit reporting; a source rollback leaves that to the
229+
// watcher from Start, so this handler only drains the re-attached relay.
230+
go c.handleProcessExit(ctx, execCmd, events, isDestination)
228231

229-
log.G(ctx).WithField(logfields.ProcessID, c.processID).Debug("resumed migrated process on destination")
232+
log.G(ctx).WithField(logfields.ProcessID, c.processID).Debug("resumed migrated process")
230233
return nil
231234
}

internal/controller/process/save_test.go

Lines changed: 8 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,14 @@ func TestSave_Succeeds(t *testing.T) {
116116
if controller.state != StateSourceMigrating {
117117
t.Errorf("state = %s; want StateSourceMigrating", controller.state)
118118
}
119+
// The ports and wait id are retained on the controller so a source
120+
// rollback resume re-opens IO the same way the destination does.
121+
if controller.stdinPort != testStdinPort || controller.stdoutPort != testStdoutPort || controller.stderrPort != testStderrPort {
122+
t.Errorf("controller ports = (%d,%d,%d); want (%d,%d,%d)", controller.stdinPort, controller.stdoutPort, controller.stderrPort, testStdinPort, testStdoutPort, testStderrPort)
123+
}
124+
if controller.waitCallID != testWaitCallID {
125+
t.Errorf("controller waitCallID = %d; want %d", controller.waitCallID, testWaitCallID)
126+
}
119127
})
120128
}
121129
}
@@ -334,22 +342,6 @@ func TestResume_WrongState(t *testing.T) {
334342
}
335343
}
336344

337-
// TestResume_SourceRollback verifies that resuming a source-migrating process
338-
// lifts the freeze and returns it to running without touching the host.
339-
func TestResume_SourceRollback(t *testing.T) {
340-
t.Parallel()
341-
_, _, _, controller := newSetup(t)
342-
controller.state = StateSourceMigrating
343-
344-
// nil host/events are unused: the live process and IO stay intact.
345-
if err := controller.Resume(t.Context(), nil, nil); err != nil {
346-
t.Fatalf("Resume() = %v; want nil", err)
347-
}
348-
if controller.state != StateRunning {
349-
t.Errorf("state = %s; want StateRunning", controller.state)
350-
}
351-
}
352-
353345
// TestResume_IdempotentWhenRunning verifies that resuming an already-resumed
354346
// process is a no-op, so a retry after a completed resume is safe.
355347
func TestResume_IdempotentWhenRunning(t *testing.T) {

internal/gcs/bridge.go

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -572,8 +572,10 @@ func (brdg *bridge) PreregisterRPC(id int64, proc prot.RPCProc, resp responseMes
572572
if brdg.rpcs == nil {
573573
return nil, ErrBridgeClosed
574574
}
575-
if _, dup := brdg.rpcs[id]; dup {
576-
return nil, fmt.Errorf("preregister rpc: id %d already in use", id)
575+
if existing, dup := brdg.rpcs[id]; dup {
576+
// A source rollback re-opens a process whose wait is still outstanding;
577+
// hand back that call.
578+
return existing, nil
577579
}
578580
brdg.rpcs[id] = call
579581
return call, nil

internal/gcs/bridge_test.go

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -256,3 +256,24 @@ func TestRPCErrorUnwrapHCSCode(t *testing.T) {
256256
t.Fatalf("hcs.IsNotExist(wrapped) = false; want true (err=%v)", wrapped)
257257
}
258258
}
259+
260+
// TestPreregisterRPCReusesOutstanding verifies that pre-registering an id that
261+
// is already outstanding hands back the existing call (a source rollback
262+
// re-opens a process whose wait is still pending) rather than failing.
263+
func TestPreregisterRPCReusesOutstanding(t *testing.T) {
264+
s, _ := pipeConn()
265+
b := newBridge(s, nil, logrus.NewEntry(logrus.StandardLogger()))
266+
267+
first, err := b.PreregisterRPC(7, prot.RPCWaitForProcess, &testResp{})
268+
if err != nil {
269+
t.Fatalf("first PreregisterRPC = %v; want nil", err)
270+
}
271+
272+
again, err := b.PreregisterRPC(7, prot.RPCWaitForProcess, &testResp{})
273+
if err != nil {
274+
t.Fatalf("duplicate PreregisterRPC = %v; want nil", err)
275+
}
276+
if again != first {
277+
t.Errorf("duplicate PreregisterRPC returned a new call; want the outstanding one")
278+
}
279+
}

internal/gcs/container.go

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -125,8 +125,10 @@ func (c *Container) CreateProcess(ctx context.Context, config interface{}) (_ co
125125
// [Container.CreateProcess]: it attaches to a process already running
126126
// in this container, re-listens on the supplied vsock ports, and
127127
// pre-registers the source bridge's WaitForProcess id so the guest's
128-
// still-outstanding response is routed without arming a duplicate wait.
129-
func (c *Container) OpenProcessWithIO(ctx context.Context, pid uint32, stdinPort, stdoutPort, stderrPort uint32, waitCallID int64) (_ *Process, err error) {
128+
// still-outstanding response is routed.
129+
// startWait launches the background exit wait; pass false when the caller
130+
// already has one outstanding (a source rollback reuses the live process's).
131+
func (c *Container) OpenProcessWithIO(ctx context.Context, pid uint32, stdinPort, stdoutPort, stderrPort uint32, waitCallID int64, startWait bool) (_ *Process, err error) {
130132
ctx, span := ot.StartSpan(ctx, "gcs::Container::OpenProcessWithIO", ot.WithClientSpanKind)
131133
defer span.End()
132134
defer func() { ot.SetSpanStatus(span, err) }()
@@ -177,7 +179,9 @@ func (c *Container) OpenProcessWithIO(ctx context.Context, pid uint32, stdinPort
177179
if err != nil {
178180
return nil, fmt.Errorf("preregister wait for pid %d in container %s (id %d): %w", pid, c.id, waitCallID, err)
179181
}
180-
go p.waitBackground()
182+
if startWait {
183+
go p.waitBackground()
184+
}
181185
log.G(ctx).WithField("pid", p.id).Debug("opened existing process with IO")
182186
return p, nil
183187
}

0 commit comments

Comments
 (0)