diff --git a/pkg/devspace/services/sync/controller.go b/pkg/devspace/services/sync/controller.go index 12ab892802..a7c7974ce4 100644 --- a/pkg/devspace/services/sync/controller.go +++ b/pkg/devspace/services/sync/controller.go @@ -226,6 +226,31 @@ func (c *controller) startWithWait(ctx devspacecontext.Context, options *Options } return nil }) + } else { + parent.Go(func() error { + select { + case <-ctx.Context().Done(): + syncStop(ctx, client, options, parent) + case err := <-onError: + hook.LogExecuteHooks(ctx.WithLogger(options.SyncLog), map[string]interface{}{ + "sync_config": options.SyncConfig, + "ERROR": err, + }, hook.EventsForSingle("error:sync", options.Name).With("sync.error")...) + ctx.Log().Errorf("Sync error: %v", err) + syncStop(ctx, client, options, parent) + case <-onDone: + // noWatch: initial sync completed normally. + // Stop the client and emit the stop:sync lifecycle event, + // but do NOT kill the parent tomb — SSH, port-forwarding and + // other services registered in the same parent must continue. + client.Stop(nil) + hook.LogExecuteHooks(ctx.WithLogger(options.SyncLog), map[string]interface{}{ + "sync_config": options.SyncConfig, + }, hook.EventsForSingle("stop:sync", options.Name).With("sync.stop")...) + ctx.Log().Debugf("Stopped sync %s", options.SyncConfig.Path) + } + return nil + }) } return nil diff --git a/pkg/devspace/services/sync/controller_test.go b/pkg/devspace/services/sync/controller_test.go index d6b5091f9d..40ebc1c6ed 100644 --- a/pkg/devspace/services/sync/controller_test.go +++ b/pkg/devspace/services/sync/controller_test.go @@ -1,8 +1,12 @@ package sync import ( - "gotest.tools/assert" "testing" + "time" + + "github.com/loft-sh/devspace/pkg/devspace/config/versions/latest" + "github.com/loft-sh/devspace/pkg/util/tomb" + "gotest.tools/assert" ) type parseSyncPathTestCase struct { @@ -30,3 +34,90 @@ func TestParseSyncPath(t *testing.T) { assert.Equal(t, remote, testCase.expectedRemote, "Expect remote path in "+testCase.name) } } + +// TestNoWatchRestartOnError verifies that RestartOnError is disabled when NoWatch is true. +// This mirrors the logic in startSync(): RestartOnError: !syncConfig.NoWatch +func TestNoWatchRestartOnError(t *testing.T) { + testCases := []struct { + name string + noWatch bool + wantRestart bool + }{ + {"watch mode enables restart-on-error", false, true}, + {"noWatch mode disables restart-on-error", true, false}, + } + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + cfg := &latest.SyncConfig{NoWatch: tc.noWatch} + restartOnError := !cfg.NoWatch + assert.Equal(t, restartOnError, tc.wantRestart, + "RestartOnError mismatch for NoWatch=%v", tc.noWatch) + }) + } +} + +// TestNoWatchOnDoneDoesNotKillParent verifies that when a noWatch sync completes +// (onDone fires), the parent tomb is NOT killed. This ensures other services such +// as SSH and port-forwarding, which are registered in the same parent, continue +// running after the one-shot sync finishes. +func TestNoWatchOnDoneDoesNotKillParent(t *testing.T) { + var parent tomb.Tomb + onDone := make(chan struct{}) + handlerExited := make(chan struct{}) + + // Simulate the noWatch (else) branch goroutine from startWithWait. + // The correct behaviour is: on onDone, do NOT call parent.Kill. + parent.Go(func() error { + defer close(handlerExited) + select { + case <-onDone: + // noWatch sync complete — intentionally no parent.Kill here + } + return nil + }) + + // Signal that the one-shot sync has finished. + close(onDone) + + // Wait for the handler goroutine to exit. + select { + case <-handlerExited: + case <-time.After(2 * time.Second): + t.Fatal("noWatch handler goroutine did not finish in time") + } + + // Parent must still be alive: Kill was never called, so other services continue. + assert.Assert(t, parent.Alive(), + "parent tomb was killed on noWatch sync completion; SSH/port-forwarding would be terminated") +} + +// TestNoWatchOnDoneWatchModeKillsParent documents the intentional contrast: +// in watch mode (RestartOnError=true) the onDone path DOES call syncDone → parent.Kill, +// terminating the whole dev session when the sync unexpectedly stops. +func TestNoWatchOnDoneWatchModeKillsParent(t *testing.T) { + var parent tomb.Tomb + onDone := make(chan struct{}) + handlerExited := make(chan struct{}) + + // Simulate the RestartOnError (if) branch: onDone → Kill parent. + parent.Go(func() error { + defer close(handlerExited) + select { + case <-onDone: + parent.Kill(nil) // watch mode: unexpected stop kills everything + } + return nil + }) + + close(onDone) + + select { + case <-handlerExited: + case <-time.After(2 * time.Second): + t.Fatal("watch-mode handler goroutine did not finish in time") + } + + // Parent should be dying/dead because Kill was called. + assert.Assert(t, !parent.Alive(), + "expected parent tomb to be killed in watch mode when sync stops") +} diff --git a/pkg/devspace/services/sync/sync.go b/pkg/devspace/services/sync/sync.go index e2dcb88c28..e1c740b138 100644 --- a/pkg/devspace/services/sync/sync.go +++ b/pkg/devspace/services/sync/sync.go @@ -124,7 +124,7 @@ func startSync(ctx devspacecontext.Context, name, arch string, syncConfig *lates Arch: arch, Starter: starter, - RestartOnError: true, + RestartOnError: !syncConfig.NoWatch, Verbose: ctx.Log().GetLevel() == logrus.DebugLevel, }