Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
49 commits
Select commit Hold shift + click to select a range
a35d525
fix(command): support process liveness and tree termination on Windows
devsy-app[bot] Sep 25, 2026
bfdc066
fix(task): make cancellation commit after worker termination
devsy-app[bot] Sep 25, 2026
65f592f
fix(workspace): quiesce persisted up tasks before stop and delete
devsy-app[bot] Sep 25, 2026
e44c86a
fix(tunnel): make local tunnel health checks side-effect-free
devsy-app[bot] Sep 25, 2026
853f30a
feat(command): own Windows background process trees with Job Objects
devsy-app[bot] Sep 25, 2026
1d618ed
test(e2e): cover Windows task cancellation and tunnel lifecycle
devsy-app[bot] Sep 25, 2026
7de8357
docs: note background up task reaping on stop and delete
devsy-app[bot] Sep 25, 2026
eae4336
style(task,tunnel): satisfy repo lint gates
devsy-app[bot] Sep 26, 2026
499e694
fix(task,workspace,command): close review findings on cancel and tree…
devsy-app[bot] Sep 26, 2026
ecdf9df
fix(task,up): abort workers canceled before they claim their lock
devsy-app[bot] Sep 26, 2026
9df825d
test(up): root the task store under DEVSY_HOME
devsy-app[bot] Sep 26, 2026
132bd40
test(e2e): poll for the tunnel SSH config after ready
devsy-app[bot] Sep 26, 2026
71cc8ab
fix(task): serialize cancellation with worker startup
skevetter Sep 26, 2026
3d388b5
test(e2e): harden tunnel specs against cold-start races
devsy-app[bot] Sep 26, 2026
cd36120
fix(task): treat terminal state during pid wait as quiesced
devsy-app[bot] Sep 26, 2026
27aa6a4
fix(task): close remaining cancellation lifecycle races
skevetter Sep 26, 2026
be1a2dc
style(task): apply golangci-lint formatting
skevetter Sep 26, 2026
3ad8efb
fix(task): verify process tree identity during cancellation
skevetter Sep 27, 2026
b523252
fix(task): preserve worker job identity across launcher exit
skevetter Sep 27, 2026
739f233
refactor(task): simplify Linux process identity checks
skevetter Sep 27, 2026
69d9a7f
refactor(task): tighten process checks and test seams
skevetter Sep 27, 2026
f098776
refactor(task): clarify process identity checks
skevetter Sep 27, 2026
c8cf81a
fix(task): reject stale process identity handles
skevetter Sep 27, 2026
d9c5216
refactor(task): simplify Linux process matching
skevetter Sep 27, 2026
2238b22
fix(task): preserve absent Linux process identity
skevetter Sep 27, 2026
0513d46
fix(task): address lifecycle review findings
skevetter Sep 27, 2026
762eb5a
fix(task): tighten legacy worker cleanup checks
skevetter Sep 27, 2026
0462e8c
fix(command): simplify Linux process group verification
skevetter Sep 27, 2026
883f9cf
fix(command): satisfy Linux process group lint rules
skevetter Sep 27, 2026
8b3df5f
style(command): satisfy golines in Linux tests
skevetter Sep 27, 2026
0a2bb18
style(command): apply golangci-lint formatting
skevetter Sep 27, 2026
a0fb202
style(task): remove redundant comments
skevetter Sep 27, 2026
d6c8362
fix(command): continue Linux process group scans
skevetter Sep 27, 2026
9e8f4db
fix(command): satisfy process identity lint
skevetter Sep 27, 2026
64aaee5
fix(workspace): ignore canceled task status override
skevetter Sep 27, 2026
fdc88e0
refactor(task): supervise detached worker lifecycle
skevetter Sep 28, 2026
1842990
fix(task): avoid shadowing builtin copy
skevetter Sep 28, 2026
06a0748
fix(command): handle process permission errors safely
skevetter Sep 28, 2026
fedbf0b
fix(command): validate saved worker identity
skevetter Sep 28, 2026
ee7eeb2
fix(task): complete abandoned worker cleanup safely
skevetter Sep 28, 2026
3415f4d
fix(task): retain legacy worker identity during cleanup
skevetter Sep 28, 2026
fab0b00
fix(task): verify terminal worker cleanup across platforms
skevetter Sep 28, 2026
c9211f9
refactor(command): simplify process group identity check
skevetter Sep 28, 2026
ab852e2
fix(command): resolve process helper collision
skevetter Sep 28, 2026
f44cf61
fix(command): correct process mismatch assignment
skevetter Sep 28, 2026
35e7114
fix(command): simplify process leader helper
skevetter Sep 28, 2026
3442fed
refactor(command): simplify process group field parsing
skevetter Sep 28, 2026
5ea848d
fix(task): reconcile canceled workers after failed cancellation
skevetter Sep 28, 2026
4e54460
fix(e2e): wait for usable SSH tunnel on Windows
skevetter Sep 28, 2026
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
17 changes: 17 additions & 0 deletions .github/workflows/pr-ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -302,6 +302,12 @@ jobs:
install-kind: false
requires-secret: false

- label: task-cancel
runner: ubuntu-latest
free-disk-space: false
install-kind: false
requires-secret: false

- label: ssh-credentials-server-race
runner: ubuntu-latest
free-disk-space: false
Expand Down Expand Up @@ -592,6 +598,17 @@ jobs:
free-disk-space: false
install-kind: true
requires-secret: false
- label: ssh-tunnel-mode
runner: windows-latest
free-disk-space: false
install-kind: true
requires-secret: false

- label: task-cancel
runner: windows-latest
free-disk-space: false
install-kind: true
requires-secret: false


runs-on: ${{ matrix.runner }}
Expand Down
12 changes: 11 additions & 1 deletion Taskfile.yml
Original file line number Diff line number Diff line change
Expand Up @@ -116,10 +116,20 @@ tasks:
host_os="$(go env GOOS)"
host_arch="$(go env GOARCH)"
host_binary="devsy-${host_os}-${host_arch}"
host_dir="dist/devsy-dev_${host_os}_${host_arch}_v1"
if [ "$host_os" = "windows" ]; then
host_binary="${host_binary}.exe"
fi
host_dir=""
for candidate in dist/devsy-dev_${host_os}_${host_arch}_*; do
if [ -d "$candidate" ]; then
if [ -n "$host_dir" ]; then
echo "multiple host build directories match ${host_os}/${host_arch}" >&2
exit 1
fi
host_dir="$candidate"
fi
done
test -n "$host_dir"
test -f "${host_dir}/${host_binary}"
cp "${host_dir}/${host_binary}" "e2e/bin/${host_binary}"

Expand Down
29 changes: 29 additions & 0 deletions cmd/workspace/stop.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import (
type StopCmd struct {
*flags.GlobalFlags
client2.StopOptions
quiescer workspace2.UpTaskQuiescer
}

// NewStopCmd creates a new destroy command.
Expand Down Expand Up @@ -123,6 +124,12 @@ func (cmd *StopCmd) run(
devsyConfig *config.Config,
client client2.BaseWorkspaceClient,
) error {
// Quiesce before waiting for the workspace lock: a still-provisioning up
// worker can be the one holding it.
if err := cmd.quiesce(ctx, client.Workspace()); err != nil {
return err
}

// lock workspace
if !cmd.Platform.Enabled {
err := status.RunStep(
Expand All @@ -137,6 +144,13 @@ func (cmd *StopCmd) run(
defer client.Unlock()
}

// Quiesce again under the lock: a task may have become visible while
// the lock wait blocked, and a surviving up worker can restart the
// workspace after the stop.
if err := cmd.quiesce(ctx, client.Workspace()); err != nil {
return err
}

// get instance status
var instanceStatus client2.Status
err := status.RunStep(
Expand Down Expand Up @@ -255,3 +269,18 @@ func otherWorkspaceUsesMachine(
}
return false
}

func (cmd *StopCmd) quiesce(ctx context.Context, workspaceID string) error {
quiescer := cmd.quiescer
if quiescer == nil {
quiescer = workspace2.StoreUpTaskQuiescer{}
}
if err := quiescer.Quiesce(ctx, workspaceID); err != nil {
return fmt.Errorf(
"cancel detached up tasks for workspace %s: %w",
workspaceID,
err,
)
}
return nil
}
131 changes: 131 additions & 0 deletions cmd/workspace/stop_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
package workspace

import (
"context"
"errors"
"testing"

"github.com/devsy-org/devsy/cmd/flags"
client2 "github.com/devsy-org/devsy/pkg/client"
"github.com/devsy-org/devsy/pkg/config"
"github.com/devsy-org/devsy/pkg/provider"
"github.com/devsy-org/devsy/pkg/task"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

type stopRecordingClient struct {
*fakeWorkspaceClient
calls *[]string
}

type quiescerFunc func(context.Context, string) error

func (f quiescerFunc) Quiesce(ctx context.Context, workspaceID string) error {
return f(ctx, workspaceID)
}

func (c *stopRecordingClient) Stop(ctx context.Context, opts client2.StopOptions) error {
*c.calls = append(*c.calls, "stop")
return c.fakeWorkspaceClient.Stop(ctx, opts)
}

func newStopTestConfig() *config.Config {
return &config.Config{
DefaultContext: testContext,
Contexts: map[string]*config.ContextConfig{testContext: {}},
}
}

func TestStopCancelsPersistedUpTaskBeforeProviderStop(t *testing.T) {
var calls []string
fake := &fakeWorkspaceClient{
workspace: testWorkspaceName,
context: testContext,
provider: testProvider,
config: &provider.Workspace{ID: testWorkspaceName, Context: testContext},
status: client2.StatusRunning,
}
client := &stopRecordingClient{fakeWorkspaceClient: fake, calls: &calls}
cmd := &StopCmd{GlobalFlags: &flags.GlobalFlags{ResultFormat: formatPlain}}
cmd.quiescer = quiescerFunc(func(_ context.Context, workspaceID string) error {
assert.Equal(t, testWorkspaceName, workspaceID)
calls = append(calls, "quiesce")
return nil
})

require.NoError(t, cmd.run(t.Context(), newStopTestConfig(), client))

require.NotEmpty(t, calls)
assert.Equal(t, "quiesce", calls[0])
assert.Contains(t, calls, "stop")
stopIndex := -1
for i, call := range calls {
if call == "stop" {
stopIndex = i
break
}
}
quiesceBeforeStop := 0
for _, call := range calls[:stopIndex] {
if call == "quiesce" {
quiesceBeforeStop++
}
}
assert.Equal(t, 2, quiesceBeforeStop, "stop must quiesce before and after the workspace lock")
}

func TestStopDoesNotProceedWhenActiveTaskCannotBeKilled(t *testing.T) {
fake := &fakeWorkspaceClient{
workspace: testWorkspaceName,
context: testContext,
provider: testProvider,
config: &provider.Workspace{ID: testWorkspaceName, Context: testContext},
status: client2.StatusRunning,
}
var calls []string
client := &stopRecordingClient{fakeWorkspaceClient: fake, calls: &calls}
cmd := &StopCmd{GlobalFlags: &flags.GlobalFlags{ResultFormat: formatPlain}}
cmd.quiescer = quiescerFunc(func(context.Context, string) error {
return errors.New("boom")
})

err := cmd.run(t.Context(), newStopTestConfig(), client)
require.Error(t, err)
assert.NotContains(t, calls, "stop")
}

func TestStopFindsTaskWithoutDesktopState(t *testing.T) {
home := t.TempDir()
t.Setenv("DEVSY_HOME", home)
t.Setenv("HOME", home)
t.Setenv("USERPROFILE", home)
config.ResetPathManager()
t.Cleanup(config.ResetPathManager)

// A persisted up task with no live worker, as a Desktop restart leaves
// behind.
store, err := task.NewStore()
require.NoError(t, err)
tk, err := store.Create(task.CreateOptions{Command: "up", WorkspaceID: testWorkspaceName})
require.NoError(t, err)

fake := &fakeWorkspaceClient{
workspace: testWorkspaceName,
context: testContext,
provider: testProvider,
config: &provider.Workspace{ID: testWorkspaceName, Context: testContext},
status: client2.StatusRunning,
}
var calls []string
client := &stopRecordingClient{fakeWorkspaceClient: fake, calls: &calls}
cmd := &StopCmd{GlobalFlags: &flags.GlobalFlags{ResultFormat: formatPlain}}

require.NoError(t, cmd.run(t.Context(), newStopTestConfig(), client))
assert.Contains(t, calls, "stop")

state, err := store.Get(tk.ID())
require.NoError(t, err)
assert.Equal(t, task.StatusFailed, state.Status)
assert.Equal(t, task.ErrCanceled.Error(), state.Error)
}
84 changes: 69 additions & 15 deletions cmd/workspace/up/detach.go
Original file line number Diff line number Diff line change
@@ -1,16 +1,20 @@
package up

import (
"errors"
"fmt"
"os"
"os/exec"
"strings"
"time"

"github.com/devsy-org/devsy/pkg/command"
config2 "github.com/devsy-org/devsy/pkg/devcontainer/config"
"github.com/devsy-org/devsy/pkg/file"
"github.com/devsy-org/devsy/pkg/flags/names"
"github.com/devsy-org/devsy/pkg/output"
"github.com/devsy-org/devsy/pkg/task"
workspace2 "github.com/devsy-org/devsy/pkg/workspace"
)

// runDetached submits this invocation as a background task and returns
Expand All @@ -20,15 +24,24 @@ func (cmd *UpCmd) runDetached(args []string) error {
if err != nil {
return err
}
workspaceID, err := cmd.detachWorkspaceLabel(args)
if err != nil {
return err
}
t, err := store.Create(task.CreateOptions{
Command: "up",
WorkspaceID: cmd.detachWorkspaceLabel(args),
WorkspaceID: workspaceID,
})
if err != nil {
return err
}

if err := launchDetached(t.ID()); err != nil {
if err := t.BeginLaunch(); err != nil {
_ = t.Fail(err)
return fmt.Errorf("prepare detached up: %w", err)
}
if err := launchDetached(t); err != nil {
_ = t.FinishLaunch()
_ = t.Fail(err)
return fmt.Errorf("launch detached up: %w", err)
}
Expand All @@ -46,14 +59,19 @@ func (cmd *UpCmd) runDetached(args []string) error {
return err
}

func launchDetached(taskID string) error {
func launchDetached(t *task.Task) error {
execPath, err := os.Executable()
if err != nil {
return err
}

args := append(detachedArgs(os.Args[1:]), names.Flag(names.TaskID), taskID)
return command.StartBackground(task.WorkerProcessName(taskID), func() (*exec.Cmd, error) {
args := append(detachedArgs(os.Args[1:]), names.Flag(names.TaskID), t.ID())
return command.StartSupervisedBackground(command.SupervisedStartOptions{
Name: task.WorkerProcessName(t.ID()),
OnStarted: func(ref command.ProcessRef) error {
return t.SetProcess(ref)
},
}, func() (*exec.Cmd, error) {
return &exec.Cmd{
Path: execPath,
Args: append([]string{execPath}, args...),
Expand All @@ -76,15 +94,22 @@ func detachedArgs(args []string) []string {
return out
}

// detachWorkspaceLabel is a best-effort label for task list.
func (cmd *UpCmd) detachWorkspaceLabel(args []string) string {
// detachWorkspaceLabel derives the ID stop and delete use before worker startup.
func (cmd *UpCmd) detachWorkspaceLabel(args []string) (string, error) {
if cmd.FromSnapshot != "" {
if _, err := cmd.resolveExplicitSource(); err != nil {
return "", err
}
}
if cmd.ID != "" {
return cmd.ID
return cmd.ID, nil
}
args = cmd.ensureArgs(args)
if len(args) > 0 {
return args[0]
_, source := file.IsLocalDir(args[0])
return workspace2.ToID(source), nil
}
return ""
return "", nil
}

func wd() string {
Expand All @@ -105,16 +130,45 @@ func (cmd *UpCmd) openTask() (*task.Task, error) {
return nil, err
}
t := store.Open(cmd.taskID)
// obtain the worker lock first.
if err := t.HoldWorkerLock(); err != nil {
failTask(t, err)
return nil, err
}
if err := t.SetPID(os.Getpid()); err != nil {
failTask(t, err)
return nil, err
deadline := time.NewTimer(5 * time.Second)
defer deadline.Stop()
ticker := time.NewTicker(20 * time.Millisecond)
defer ticker.Stop()
for {
state, err := store.Get(cmd.taskID)
if err != nil {
failTask(t, err)
_ = t.ReleaseWorkerLock()
return nil, err
}
if state.Status.Terminal() || state.CancelRequested {
if err := t.ReleaseWorkerLock(); err != nil {
return nil, fmt.Errorf("release canceled worker lock: %w", err)
}
return nil, task.ErrCanceled
}
if !state.LaunchPending {
if _, ok := state.ProcessReference(); !ok {
err := errors.New("detached worker launch did not publish process metadata")
failTask(t, err)
_ = t.ReleaseWorkerLock()
return nil, err
}
return t, nil
}
select {
case <-deadline.C:
err := errors.New("timed out waiting for detached worker launch metadata")
failTask(t, err)
_ = t.ReleaseWorkerLock()
return nil, err
case <-ticker.C:
}
}
return t, nil
}

func failTask(t *task.Task, err error) {
Expand Down
Loading
Loading