Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions pkg/devspace/services/sync/controller.go
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Can we add a case for onError as well with hook.LogExecuteHooks(...

// 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
Expand Down
93 changes: 92 additions & 1 deletion pkg/devspace/services/sync/controller_test.go
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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")
}
2 changes: 1 addition & 1 deletion pkg/devspace/services/sync/sync.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}

Expand Down