From 791867dbf6ed07bb4a2a5266641ea7a32fd35115 Mon Sep 17 00:00:00 2001 From: Samuel K Date: Tue, 28 Jul 2026 16:33:34 -0500 Subject: [PATCH 01/21] feat(workspace): support async, durable workspace up execution workspace up currently runs synchronously end-to-end, streaming a single JSON blob to the caller only once the container is ready. That blocks the CLI/desktop for the whole provisioning window and gives up all in-progress state on a crash or disconnect. Adds a submit/poll durable execution model alongside the existing synchronous flow: - pkg/task: a JSON-file task store (atomic writes, cross-process flock) recording status/PID/result for background-launched work. - workspace up --detach re-execs itself as a background process and returns a task ID immediately. - workspace task list/get/logs/cancel/rm: standard verb commands (kubectl/docker/gh-style) to manage submitted tasks. - pkg/devcontainer/status: a structured Phase/Event/Reporter model threaded through Runner.Up and the devcontainer build/run/setup pipeline, replacing ad hoc log lines with typed progress events. - A StatusUpdate RPC on the agent tunnel streams those events back to the CLI host live instead of waiting for the final result. - Desktop app updated to submit+poll via the new task model instead of parsing a single terminal JSON blob. See docs/rfcs/async-workspace-up.md for the full design. --- cmd/internal/agentworkspace/up.go | 7 +- cmd/internal/container_tunnel.go | 2 + cmd/workspace/task.go | 350 ++++++++++++++ cmd/workspace/task_test.go | 38 ++ cmd/workspace/up/agent.go | 3 + cmd/workspace/up/detach.go | 129 +++++ cmd/workspace/up/detach_test.go | 30 ++ cmd/workspace/up/status.go | 64 +++ cmd/workspace/up/up.go | 47 +- cmd/workspace/up/up_flags.go | 6 + cmd/workspace/workspace.go | 1 + desktop/src/main/ipc.ts | 83 +++- desktop/src/renderer/src/lib/ipc/events.ts | 10 + desktop/src/renderer/src/lib/types/index.ts | 10 + desktop/src/shared/cli-error.ts | 57 +++ docs/rfcs/async-workspace-up.md | 441 ++++++++++++++++++ pkg/agent/tunnel/tunnel.pb.go | 301 ++++++------ pkg/agent/tunnel/tunnel.proto | 10 + pkg/agent/tunnel/tunnel_grpc.pb.go | 74 ++- pkg/agent/tunnelserver/options.go | 9 + pkg/agent/tunnelserver/status_sender.go | 53 +++ pkg/agent/tunnelserver/tunnelserver.go | 18 +- pkg/client/client.go | 18 +- .../clientimplementation/daemonclient/stop.go | 3 +- .../clientimplementation/daemonclient/up.go | 69 ++- .../daemonclient/up_test.go | 106 +++++ .../clientimplementation/workspace_client.go | 64 ++- .../workspace_client_status_test.go | 124 +++++ pkg/command/process_supported.go | 12 +- pkg/command/process_test.go | 49 ++ pkg/compose/helper_test.go | 12 + pkg/config/pathmanager.go | 11 + pkg/devcontainer/build.go | 10 +- pkg/devcontainer/config/envelope.go | 82 ++++ pkg/devcontainer/feature/extend.go | 42 +- pkg/devcontainer/feature/lockfile.go | 12 +- pkg/devcontainer/run.go | 34 +- pkg/devcontainer/setup.go | 34 +- pkg/devcontainer/single.go | 7 + pkg/devcontainer/status/log.go | 23 + pkg/devcontainer/status/status.go | 71 +++ pkg/devcontainer/status/status_test.go | 65 +++ pkg/flags/names/names.go | 3 + pkg/task/store.go | 208 +++++++++ pkg/task/task.go | 155 ++++++ pkg/task/task_test.go | 361 ++++++++++++++ 46 files changed, 3129 insertions(+), 189 deletions(-) create mode 100644 cmd/workspace/task.go create mode 100644 cmd/workspace/task_test.go create mode 100644 cmd/workspace/up/detach.go create mode 100644 cmd/workspace/up/detach_test.go create mode 100644 cmd/workspace/up/status.go create mode 100644 docs/rfcs/async-workspace-up.md create mode 100644 pkg/agent/tunnelserver/status_sender.go create mode 100644 pkg/client/clientimplementation/daemonclient/up_test.go create mode 100644 pkg/client/clientimplementation/workspace_client_status_test.go create mode 100644 pkg/command/process_test.go create mode 100644 pkg/devcontainer/status/log.go create mode 100644 pkg/devcontainer/status/status.go create mode 100644 pkg/devcontainer/status/status_test.go create mode 100644 pkg/task/store.go create mode 100644 pkg/task/task.go create mode 100644 pkg/task/task_test.go diff --git a/cmd/internal/agentworkspace/up.go b/cmd/internal/agentworkspace/up.go index ee855b876..438234428 100644 --- a/cmd/internal/agentworkspace/up.go +++ b/cmd/internal/agentworkspace/up.go @@ -24,6 +24,7 @@ import ( "github.com/devsy-org/devsy/pkg/devcontainer" config2 "github.com/devsy-org/devsy/pkg/devcontainer/config" "github.com/devsy-org/devsy/pkg/devcontainer/crane" + "github.com/devsy-org/devsy/pkg/devcontainer/status" "github.com/devsy-org/devsy/pkg/dockercredentials" "github.com/devsy-org/devsy/pkg/dockerinstall" "github.com/devsy-org/devsy/pkg/extract" @@ -150,7 +151,7 @@ func (cmd *UpCmd) up( workspaceInfo *provider.AgentWorkspaceInfo, tunnelClient tunnel.TunnelClient, ) error { - result, err := cmd.devsyUp(ctx, workspaceInfo) + result, err := cmd.devsyUp(ctx, workspaceInfo, tunnelClient) if err != nil { errResult := &config2.Result{ Error: err.Error(), @@ -200,16 +201,18 @@ func (cmd *UpCmd) sendResult( func (cmd *UpCmd) devsyUp( ctx context.Context, workspaceInfo *provider.AgentWorkspaceInfo, + tunnelClient tunnel.TunnelClient, ) (*config2.Result, error) { runner, err := CreateRunner(ctx, workspaceInfo) if err != nil { return nil, err } + reporter := tunnelserver.NewTunnelStatusReporter(ctx, tunnelClient) return runner.Up(ctx, devcontainer.UpOptions{ CLIOptions: workspaceInfo.CLIOptions, RegistryCache: workspaceInfo.RegistryCache, - }, workspaceInfo.InjectTimeout) + }, workspaceInfo.InjectTimeout, reporter) } func CreateRunner( diff --git a/cmd/internal/container_tunnel.go b/cmd/internal/container_tunnel.go index b3fb4af1b..d02fbbaf4 100644 --- a/cmd/internal/container_tunnel.go +++ b/cmd/internal/container_tunnel.go @@ -13,6 +13,7 @@ import ( "github.com/devsy-org/devsy/pkg/agent" pkgconfig "github.com/devsy-org/devsy/pkg/config" "github.com/devsy-org/devsy/pkg/devcontainer" + "github.com/devsy-org/devsy/pkg/devcontainer/status" "github.com/devsy-org/devsy/pkg/devcontainer/config" "github.com/devsy-org/devsy/pkg/encoding" cliflags "github.com/devsy-org/devsy/pkg/flags" @@ -161,6 +162,7 @@ func StartContainer( ctx, devcontainer.UpOptions{NoBuild: true}, workspaceConfig.InjectTimeout, + status.Nop(), ) if err != nil { return result, err diff --git a/cmd/workspace/task.go b/cmd/workspace/task.go new file mode 100644 index 000000000..b455496a4 --- /dev/null +++ b/cmd/workspace/task.go @@ -0,0 +1,350 @@ +package workspace + +import ( + "context" + "encoding/json" + "fmt" + "os" + "time" + + "github.com/devsy-org/devsy/cmd/flags" + config2 "github.com/devsy-org/devsy/pkg/devcontainer/config" + "github.com/devsy-org/devsy/pkg/devcontainer/status" + cliflags "github.com/devsy-org/devsy/pkg/flags" + "github.com/devsy-org/devsy/pkg/flags/names" + "github.com/devsy-org/devsy/pkg/output" + "github.com/devsy-org/devsy/pkg/task" + "github.com/spf13/cobra" +) + +// NewTaskCmd builds the 'devsy workspace task' parent command. +func NewTaskCmd(globalFlags *flags.GlobalFlags) *cobra.Command { + taskCmd := &cobra.Command{ + Use: "task", + Short: "Manage background tasks (e.g. from 'up --detach')", + } + taskCmd.AddCommand(newTaskListCmd(globalFlags)) + taskCmd.AddCommand(newTaskGetCmd(globalFlags)) + taskCmd.AddCommand(newTaskLogsCmd(globalFlags)) + taskCmd.AddCommand(newTaskCancelCmd(globalFlags)) + taskCmd.AddCommand(newTaskRmCmd(globalFlags)) + return taskCmd +} + +// --- list --- + +type taskListCmd struct { + *flags.GlobalFlags +} + +func newTaskListCmd(globalFlags *flags.GlobalFlags) *cobra.Command { + cmd := &taskListCmd{GlobalFlags: globalFlags} + return &cobra.Command{ + Use: "list", + Aliases: []string{"ls"}, + Short: "List background tasks, most recently started first", + Args: cobra.NoArgs, + RunE: func(*cobra.Command, []string) error { + return cmd.run() + }, + } +} + +func (cmd *taskListCmd) run() error { + emitJSON, err := resolveEmitJSON(cmd.GlobalFlags) + if err != nil { + return err + } + + store, err := task.NewStore() + if err != nil { + return err + } + states, err := store.List() + if err != nil { + return err + } + + if emitJSON { + return json.NewEncoder(os.Stdout).Encode(states) + } + for _, s := range states { + fmt.Printf("%s\t%s\t%s\t%s\n", s.ID, s.Status, s.Command, s.WorkspaceID) + } + return nil +} + +// --- get --- + +type taskGetCmd struct { + *flags.GlobalFlags +} + +func newTaskGetCmd(globalFlags *flags.GlobalFlags) *cobra.Command { + cmd := &taskGetCmd{GlobalFlags: globalFlags} + return &cobra.Command{ + Use: "get ", + Aliases: []string{"describe", "show"}, + Short: "Show a task's current status", + Args: cobra.ExactArgs(1), + RunE: func(_ *cobra.Command, args []string) error { + return cmd.run(args[0]) + }, + } +} + +func (cmd *taskGetCmd) run(id string) error { + emitJSON, err := resolveEmitJSON(cmd.GlobalFlags) + if err != nil { + return err + } + store, err := task.NewStore() + if err != nil { + return err + } + state, err := store.Get(id) + if err != nil { + return err + } + return reportTaskState(state, emitJSON) +} + +// --- logs --- + +type taskLogsCmd struct { + *flags.GlobalFlags + + Follow bool + Interval string +} + +func newTaskLogsCmd(globalFlags *flags.GlobalFlags) *cobra.Command { + cmd := &taskLogsCmd{GlobalFlags: globalFlags} + logsCmd := &cobra.Command{ + Use: "logs ", + Aliases: []string{"attach"}, + Short: "Show a task's status, or follow it until it finishes", + Args: cobra.ExactArgs(1), + RunE: func(_ *cobra.Command, args []string) error { + return cmd.run(args[0]) + }, + } + cliflags.Add(logsCmd, + cliflags.Bool(&cmd.Follow, names.Follow, false, + "Poll until the task reaches a terminal state instead of reporting once"). + Shorthand("f"), + cliflags.String(&cmd.Interval, names.Interval, "500ms", + "Poll interval when --follow is set"), + ) + return logsCmd +} + +func (cmd *taskLogsCmd) run(id string) error { + emitJSON, err := resolveEmitJSON(cmd.GlobalFlags) + if err != nil { + return err + } + store, err := task.NewStore() + if err != nil { + return err + } + + if !cmd.Follow { + state, err := store.Get(id) + if err != nil { + return err + } + return reportTaskState(state, emitJSON) + } + + interval, err := time.ParseDuration(cmd.Interval) + if err != nil { + return fmt.Errorf("parse --interval: %w", err) + } + return followTask(context.Background(), store, id, interval, emitJSON) +} + +func followTask( + ctx context.Context, + store *task.Store, + id string, + interval time.Duration, + emitJSON bool, +) error { + var last *task.State + ticker := time.NewTicker(interval) + defer ticker.Stop() + + for { + state, err := store.Get(id) + if err != nil { + return err + } + emitTaskTransition(last, state, emitJSON) + last = state + + if state.Status.Terminal() { + return reportTaskState(state, emitJSON) + } + + select { + case <-ctx.Done(): + return ctx.Err() + case <-ticker.C: + } + } +} + +// emitTaskTransition prints a line only when phase/step changed since the +// last poll. +func emitTaskTransition(last, current *task.State, emitJSON bool) { + if last != nil && last.Phase == current.Phase && last.Step == current.Step { + return + } + if current.Phase == "" { + return + } + event := status.Event{Phase: status.Phase(current.Phase), Step: current.Step, Started: true} + if emitJSON { + _ = config2.WriteStatusJSON(os.Stdout, event) + return + } + fmt.Printf("task %s: %s\n", current.ID, current.Phase) +} + +// --- cancel --- + +type taskCancelCmd struct { + *flags.GlobalFlags +} + +func newTaskCancelCmd(globalFlags *flags.GlobalFlags) *cobra.Command { + cmd := &taskCancelCmd{GlobalFlags: globalFlags} + return &cobra.Command{ + Use: "cancel ", + Aliases: []string{"stop"}, + Short: "Stop a task's process and mark it failed", + Args: cobra.ExactArgs(1), + RunE: func(_ *cobra.Command, args []string) error { + return cmd.run(args[0]) + }, + } +} + +func (cmd *taskCancelCmd) run(id string) error { + emitJSON, err := resolveEmitJSON(cmd.GlobalFlags) + if err != nil { + return err + } + store, err := task.NewStore() + if err != nil { + return err + } + if err := store.Open(id).Cancel(); err != nil { + return err + } + state, err := store.Get(id) + if err != nil { + return err + } + + // Report the raw state rather than routing through reportTaskState, + // which would turn state.Error (the task is now Failed) into this + // command's exit code — canceling successfully isn't itself a failure. + if emitJSON { + return json.NewEncoder(os.Stdout).Encode(state) + } + fmt.Printf("task %s: canceled\n", state.ID) + return nil +} + +// --- rm --- + +type taskRmCmd struct { + *flags.GlobalFlags + + Force bool +} + +func newTaskRmCmd(globalFlags *flags.GlobalFlags) *cobra.Command { + cmd := &taskRmCmd{GlobalFlags: globalFlags} + rmCmd := &cobra.Command{ + Use: "rm ", + Aliases: []string{"delete", "remove"}, + Short: "Delete a finished task's state", + Args: cobra.ExactArgs(1), + RunE: func(_ *cobra.Command, args []string) error { + return cmd.run(args[0]) + }, + } + cliflags.Add(rmCmd, + cliflags.Bool(&cmd.Force, names.Force, false, + "Stop the task first if it's still pending or running, then delete"), + ) + return rmCmd +} + +func (cmd *taskRmCmd) run(id string) error { + store, err := task.NewStore() + if err != nil { + return err + } + if cmd.Force { + if err := store.Open(id).Cancel(); err != nil { + return err + } + } + return store.Delete(id, cmd.Force) +} + +// --- shared --- + +func resolveEmitJSON(g *flags.GlobalFlags) (bool, error) { + mode, err := output.ResolveMode(g.ResultFormat) + if err != nil { + return false, err + } + return mode == output.ModeJSON, nil +} + +func reportTaskState(state *task.State, emitJSON bool) error { + if emitJSON { + return reportTaskStateJSON(state) + } + + fmt.Printf("task %s: %s\n", state.ID, state.Status) + if state.Status == task.StatusFailed { + return fmt.Errorf("%s", state.Error) + } + return nil +} + +func reportTaskStateJSON(state *task.State) error { + switch state.Status { + case task.StatusFailed: + if err := config2.WriteErrorJSON(os.Stdout, state.Error); err != nil { + return err + } + return fmt.Errorf("%s", state.Error) + case task.StatusSucceeded: + return config2.WriteResultJSON(os.Stdout, resultEnvelopeFrom(state)) + default: + return config2.WriteStatusJSON(os.Stdout, status.Event{ + Phase: status.Phase(state.Phase), + Step: state.Step, + Started: true, + }) + } +} + +func resultEnvelopeFrom(state *task.State) config2.ResultEnvelope { + if state.Result == nil { + return config2.ResultEnvelope{} + } + return config2.ResultEnvelope{ + ContainerID: config2.GetContainerID(state.Result), + RemoteUser: config2.GetRemoteUser(state.Result), + Warnings: state.Result.HostWarnings, + Recovery: state.Result.RecoveryContainer, + } +} diff --git a/cmd/workspace/task_test.go b/cmd/workspace/task_test.go new file mode 100644 index 000000000..1be2c956a --- /dev/null +++ b/cmd/workspace/task_test.go @@ -0,0 +1,38 @@ +package workspace + +import ( + "testing" + + "github.com/devsy-org/devsy/pkg/devcontainer/config" + "github.com/devsy-org/devsy/pkg/task" +) + +func TestResultEnvelopeFrom_NilResult(t *testing.T) { + got := resultEnvelopeFrom(&task.State{}) + if got.ContainerID != "" || got.RemoteUser != "" || got.Recovery || len(got.Warnings) != 0 { + t.Errorf("expected zero envelope, got %+v", got) + } +} + +func TestResultEnvelopeFrom_PopulatedResult(t *testing.T) { + state := &task.State{ + Result: &config.Result{ + HostWarnings: []string{"warn"}, + RecoveryContainer: true, + ContainerDetails: &config.ContainerDetails{ + ID: "abc123", + }, + }, + } + + got := resultEnvelopeFrom(state) + if got.ContainerID != "abc123" { + t.Errorf("ContainerID = %q, want %q", got.ContainerID, "abc123") + } + if !got.Recovery { + t.Error("expected Recovery = true") + } + if len(got.Warnings) != 1 || got.Warnings[0] != "warn" { + t.Errorf("Warnings = %v", got.Warnings) + } +} diff --git a/cmd/workspace/up/agent.go b/cmd/workspace/up/agent.go index 5df7d3222..801424d59 100644 --- a/cmd/workspace/up/agent.go +++ b/cmd/workspace/up/agent.go @@ -99,6 +99,7 @@ func (cmd *UpCmd) devsyUpProxy( true, client.WorkspaceConfig(), tunnelserver.WithGitToken(cmd.GitToken), + tunnelserver.WithStatusReporter(cmd.reporter()), ) if err != nil { return nil, fmt.Errorf("run tunnel machine: %w", err) @@ -137,6 +138,7 @@ func (cmd *UpCmd) devsyUpDaemon( return client.Up(ctx, client2.UpOptions{ CLIOptions: baseOptions, Debug: cmd.Debug, + Reporter: cmd.reporter(), }) } @@ -230,6 +232,7 @@ func (cmd *UpCmd) devsyUpMachineSSH( client.AgentInjectDockerCredentials(cmd.CLIOptions), client.WorkspaceConfig(), tunnelserver.WithGitToken(cmd.GitToken), + tunnelserver.WithStatusReporter(cmd.reporter()), ) }, }) diff --git a/cmd/workspace/up/detach.go b/cmd/workspace/up/detach.go new file mode 100644 index 000000000..3692892e1 --- /dev/null +++ b/cmd/workspace/up/detach.go @@ -0,0 +1,129 @@ +package up + +import ( + "fmt" + "os" + "os/exec" + "strings" + + "github.com/devsy-org/devsy/pkg/command" + config2 "github.com/devsy-org/devsy/pkg/devcontainer/config" + "github.com/devsy-org/devsy/pkg/flags/names" + "github.com/devsy-org/devsy/pkg/output" + "github.com/devsy-org/devsy/pkg/task" +) + +// runDetached submits this invocation as a background task and returns +// immediately instead of waiting for the workspace to be ready. The task is +// a re-exec of this same command with --detach swapped for --task-id, so it +// runs the same pipeline an attached `up` would. +func (cmd *UpCmd) runDetached(args []string) error { + store, err := task.NewStore() + if err != nil { + return err + } + t, err := store.Create(task.CreateOptions{ + Command: "up", + WorkspaceID: cmd.detachWorkspaceLabel(args), + }) + if err != nil { + return err + } + + if err := launchDetached(t.ID()); err != nil { + _ = t.Fail(err) + return fmt.Errorf("launch detached up: %w", err) + } + + mode, err := output.ResolveMode(cmd.ResultFormat) + if err != nil { + return err + } + if mode == output.ModeJSON { + return config2.WriteTaskJSON(cmd.stdout(), t.ID()) + } + _, err = fmt.Fprintf(cmd.stdout(), + "Submitted task %s. Poll with 'workspace task get %s' or 'workspace task logs %s -f'.\n", + t.ID(), t.ID(), t.ID()) + return err +} + +func launchDetached(taskID string) error { + execPath, err := os.Executable() + if err != nil { + return err + } + + args := append(detachedArgs(os.Args[1:]), names.Flag(names.TaskID), taskID) + return command.StartBackground("devsy-up-"+taskID, func() (*exec.Cmd, error) { + return &exec.Cmd{ + Path: execPath, + Args: append([]string{execPath}, args...), + Env: os.Environ(), + Dir: wd(), + }, nil + }) +} + +// detachedArgs strips --detach so it isn't duplicated alongside --task-id. +func detachedArgs(args []string) []string { + detachFlag := names.Flag(names.Detach) + out := make([]string, 0, len(args)) + for _, a := range args { + if a == detachFlag || strings.HasPrefix(a, detachFlag+"=") || a == "-d" { + continue + } + out = append(out, a) + } + return out +} + +// detachWorkspaceLabel is a best-effort label for `workspace task list`. +func (cmd *UpCmd) detachWorkspaceLabel(args []string) string { + if cmd.ID != "" { + return cmd.ID + } + if len(args) > 0 { + return args[0] + } + return "" +} + +func wd() string { + dir, err := os.Getwd() + if err != nil { + return "" + } + return dir +} + +// openTask returns nil when this invocation wasn't launched via --detach. +// Records this process's PID so a caller can later cancel the actual work. +func (cmd *UpCmd) openTask() (*task.Task, error) { + if cmd.taskID == "" { + return nil, nil + } + store, err := task.NewStore() + if err != nil { + return nil, err + } + t := store.Open(cmd.taskID) + if err := t.SetPID(os.Getpid()); err != nil { + return nil, err + } + return t, nil +} + +func failTask(t *task.Task, err error) { + if t == nil { + return + } + _ = t.Fail(err) +} + +func succeedTask(t *task.Task, result *config2.Result) { + if t == nil { + return + } + _ = t.Succeed(result) +} diff --git a/cmd/workspace/up/detach_test.go b/cmd/workspace/up/detach_test.go new file mode 100644 index 000000000..e02d79721 --- /dev/null +++ b/cmd/workspace/up/detach_test.go @@ -0,0 +1,30 @@ +package up + +import ( + "reflect" + "testing" +) + +func TestDetachedArgs_StripsDetachFlag(t *testing.T) { + got := detachedArgs([]string{"myrepo", "--detach", "--debug"}) + want := []string{"myrepo", "--debug"} + if !reflect.DeepEqual(got, want) { + t.Errorf("detachedArgs() = %v, want %v", got, want) + } +} + +func TestDetachedArgs_StripsDetachEqualsValue(t *testing.T) { + got := detachedArgs([]string{"myrepo", "--detach=true", "--debug"}) + want := []string{"myrepo", "--debug"} + if !reflect.DeepEqual(got, want) { + t.Errorf("detachedArgs() = %v, want %v", got, want) + } +} + +func TestDetachedArgs_NoDetachFlagIsUnchanged(t *testing.T) { + got := detachedArgs([]string{"myrepo", "--debug"}) + want := []string{"myrepo", "--debug"} + if !reflect.DeepEqual(got, want) { + t.Errorf("detachedArgs() = %v, want %v", got, want) + } +} diff --git a/cmd/workspace/up/status.go b/cmd/workspace/up/status.go new file mode 100644 index 000000000..d044cec9c --- /dev/null +++ b/cmd/workspace/up/status.go @@ -0,0 +1,64 @@ +package up + +import ( + "io" + + config2 "github.com/devsy-org/devsy/pkg/devcontainer/config" + "github.com/devsy-org/devsy/pkg/devcontainer/status" + "github.com/devsy-org/devsy/pkg/log" +) + +// newStatusReporter drives `up`'s progress output: one NDJSON status line +// per phase transition in JSON mode, human-readable info lines otherwise. +func newStatusReporter(emitJSON bool, out io.Writer) status.Reporter { + if emitJSON { + return &jsonStatusReporter{out: out} + } + return plainStatusReporter{} +} + +type jsonStatusReporter struct { + out io.Writer +} + +func (r *jsonStatusReporter) Report(e status.Event) { + _ = config2.WriteStatusJSON(r.out, e) +} + +type plainStatusReporter struct{} + +func (plainStatusReporter) Report(e status.Event) { + switch { + case e.Phase == status.PhaseFailed: + log.Errorf("up: phase %q failed: %s", e.Step, e.Err) + case e.Started && e.Step != "": + log.Infof("up: %s: %s", phaseLabel(e.Phase), e.Step) + case e.Started: + log.Infof("up: %s", phaseLabel(e.Phase)) + } +} + +func phaseLabel(p status.Phase) string { + switch p { + case status.PhaseCloningRepository: + return "cloning repository" + case status.PhaseResolvingConfig: + return "resolving devcontainer config" + case status.PhaseInitializeCommand: + return "running initializeCommand" + case status.PhaseBuildingImage: + return "building image" + case status.PhaseStartingContainer: + return "starting container" + case status.PhaseInjectingAgent: + return "injecting agent" + case status.PhaseRunningLifecycleHook: + return "running lifecycle hooks" + case status.PhaseWaitingFor: + return "waiting for readiness" + case status.PhaseReady: + return "ready" + default: + return string(p) + } +} diff --git a/cmd/workspace/up/up.go b/cmd/workspace/up/up.go index 326ac992c..5882e6dca 100644 --- a/cmd/workspace/up/up.go +++ b/cmd/workspace/up/up.go @@ -13,6 +13,7 @@ import ( client2 "github.com/devsy-org/devsy/pkg/client" "github.com/devsy-org/devsy/pkg/config" config2 "github.com/devsy-org/devsy/pkg/devcontainer/config" + "github.com/devsy-org/devsy/pkg/devcontainer/status" "github.com/devsy-org/devsy/pkg/flags/names" "github.com/devsy-org/devsy/pkg/ide" "github.com/devsy-org/devsy/pkg/ide/opener" @@ -54,8 +55,24 @@ type UpCmd struct { // Read via Changed() so unset is distinguishable from explicit false. pullFromInsideContainerFlag bool + // See cmd.runDetached. + Detach bool + // Set only on the detached child re-exec (--task-id). + taskID string + // Out receives result/error JSON envelopes; nil falls back to os.Stdout. Out io.Writer + + // Set at the start of Run; nil until then. + statusReporter status.Reporter +} + +// reporter falls back to a no-op when Run hasn't set one yet. +func (cmd *UpCmd) reporter() status.Reporter { + if cmd.statusReporter == nil { + return status.Nop() + } + return cmd.statusReporter } // Options is the structured input form of the up command. @@ -215,20 +232,45 @@ func (cmd *UpCmd) Run( emitJSON := mode == output.ModeJSON out := cmd.stdout() + cmd.statusReporter = newStatusReporter(emitJSON, out) + + t, err := cmd.openTask() + if err != nil { + return err + } + if t != nil { + cmd.statusReporter = status.Tee(cmd.statusReporter, t.Reporter()) + // The submitting process's guessed label may not match the + // resolved ID; client.Status() looks tasks up by this label. + if err := t.SetWorkspaceID(client.Workspace()); err != nil { + failTask(t, err) + return err + } + } + wctx, err := cmd.executeDevsyUp(ctx, devsyConfig, client) if err != nil { + failTask(t, err) return reportErr(err, emitJSON, out) } if wctx == nil || cmd.Prebuild { + succeedTask(t, nil) return nil // Platform mode or prebuild-only run. } - return cmd.finalizeUp(ctx, &finalizeUpArgs{ + + err = cmd.finalizeUp(ctx, &finalizeUpArgs{ devsyConfig: devsyConfig, client: client, wctx: wctx, emitJSON: emitJSON, out: out, }) + if err != nil { + failTask(t, err) + return err + } + succeedTask(t, wctx.result) + return nil } func (cmd *UpCmd) checkExtraDevContainerProvider(client client2.BaseWorkspaceClient) error { @@ -349,6 +391,9 @@ func (cmd *UpCmd) execute(cobraCmd *cobra.Command, args []string) error { if err := cmd.validate(); err != nil { return err } + if cmd.Detach && cmd.taskID == "" { + return cmd.runDetached(args) + } cmd.applyPullFromInsideContainerOverride(cobraCmd) devsyConfig, err := config.LoadConfig(cmd.Context, cmd.Provider) if err != nil { diff --git a/cmd/workspace/up/up_flags.go b/cmd/workspace/up/up_flags.go index ea4131309..dd6b0a2a2 100644 --- a/cmd/workspace/up/up_flags.go +++ b/cmd/workspace/up/up_flags.go @@ -251,6 +251,12 @@ func (cmd *UpCmd) registerWorkspaceFlags(upCmd *cobra.Command) { false, "Clone the source inside the container instead of bind-mounting from the host (unset = auto-detect)", ), + flags.Bool(&cmd.Detach, names.Detach, false, + "Submit the workspace for provisioning and return immediately; "+ + "poll progress with 'workspace task get ' or 'workspace task logs -f'"). + Shorthand("d"), + flags.String(&cmd.taskID, names.TaskID, "", + "Internal: report progress into this task ID instead of stdout").Hidden(), ) } diff --git a/cmd/workspace/workspace.go b/cmd/workspace/workspace.go index 4152bc894..6d91765e4 100644 --- a/cmd/workspace/workspace.go +++ b/cmd/workspace/workspace.go @@ -29,5 +29,6 @@ func NewWorkspaceCmd(globalFlags *flags.GlobalFlags) *cobra.Command { cmd.AddCommand(NewImportCmd(globalFlags)) cmd.AddCommand(NewPingCmd(globalFlags)) cmd.AddCommand(NewTroubleshootCmd(globalFlags)) + cmd.AddCommand(NewTaskCmd(globalFlags)) return cmd } diff --git a/desktop/src/main/ipc.ts b/desktop/src/main/ipc.ts index 127105aa0..d028f0c2f 100644 --- a/desktop/src/main/ipc.ts +++ b/desktop/src/main/ipc.ts @@ -7,6 +7,7 @@ import { promisify } from "node:util" import type { BrowserWindow } from "electron" import { app, dialog, ipcMain } from "electron" import type { CLIError } from "../shared/cli-error.js" +import { parseCliEnvelope } from "../shared/cli-error.js" import { hashWorkspaceRef, trackEvent } from "./analytics.js" import { loadCatalog } from "./image-catalog.js" import type { CliRunner } from "./cli.js" @@ -165,6 +166,10 @@ export function registerIpcHandlers(deps: IpcDependencies): { string, import("node:child_process").ChildProcess >() + // Maps workspaceId -> task id for still-running `up --detach` submissions. + // Stopping the tunnelProcesses entry (a poller) doesn't stop provisioning; + // only cancelling the task by id does. + const activeUpTasks = new Map() /** * Terminate every desktop-spawned process tied to a workspace and wait for @@ -173,6 +178,14 @@ export function registerIpcHandlers(deps: IpcDependencies): { * workspace's log directory. */ async function quiesceWorkspace(workspaceId: string): Promise { + const taskId = activeUpTasks.get(workspaceId) + if (taskId) { + activeUpTasks.delete(workspaceId) + await cli + .run(["workspace", "task", "cancel", taskId]) + .catch(() => undefined) + } + const tunnelProc = tunnelProcesses.get(workspaceId) if (tunnelProc) { tunnelProcesses.delete(workspaceId) @@ -737,32 +750,85 @@ export function registerIpcHandlers(deps: IpcDependencies): { (line) => logStore.appendLog(logPath, line), () => logStore.closeLog(logPath), ) - let signalledDone = false - // Kill any existing tunnel process for this workspace before starting a new one + // Kill any existing tunnel/poller process and cancel any still-running + // task for this workspace before starting a new one. const existing = tunnelProcesses.get(wsId) if (existing) { existing.kill("SIGTERM") tunnelProcesses.delete(wsId) } + const existingTask = activeUpTasks.get(wsId) + if (existingTask) { + activeUpTasks.delete(wsId) + await cli + .run(["workspace", "task", "cancel", existingTask]) + .catch(() => undefined) + } + + // Submit: returns almost immediately with the background task's id. + let taskId: string + try { + const submitted = await cli.run<{ kind: string; id: string }>([ + ...cliArgs, + "--detach", + ]) + taskId = submitted.id + } catch (error) { + const err = error as Error & { cliError?: CLIError } + void sink.done(formatLogLine(err.message, "ERROR"), { + level: "error", + cliError: err.cliError ?? { code: "up_failed", message: err.message }, + }) + return cmdId + } + activeUpTasks.set(wsId, taskId) + let signalledDone = false const child = await cli.runStreaming( - cliArgs, - (line) => { + ["workspace", "task", "logs", taskId, "--follow"], + (line, stream) => { if (signalledDone) return + + // Structured NDJSON envelopes only ever appear on stdout; stderr + // carries freeform zap log lines. + const envelope = stream === "stdout" ? parseCliEnvelope(line) : undefined + + if (envelope?.kind === "status") { + deps.getMainWindow()?.webContents.send("workspace-status", { + commandId: cmdId, + workspaceId: wsId, + phase: envelope.phase, + step: envelope.step, + started: envelope.started, + error: envelope.error, + }) + return + } + const formatted = formatLogLine(line) - if (line.includes('"outcome":"success"')) { + if (envelope?.kind === "result") { signalledDone = true - // Track this as a tunnel process (it stays alive for the tunnel) - tunnelProcesses.set(wsId, child) + activeUpTasks.delete(wsId) void sink.done(formatted) return } + if (envelope?.kind === "error") { + signalledDone = true + activeUpTasks.delete(wsId) + void sink.done(formatted, { + level: "error", + cliError: { code: "up_failed", message: envelope.message }, + }) + return + } + if (!sink.line(formatted)) return logStore.onDrain(logPath) }, (code, cliError) => { + activeUpTasks.delete(wsId) if (tunnelProcesses.get(wsId) === child) { tunnelProcesses.delete(wsId) } @@ -774,6 +840,9 @@ export function registerIpcHandlers(deps: IpcDependencies): { }, wsId, ) + // Tracked so quiesceWorkspace can also stop the poller; the task + // cancellation above is what actually stops the provisioning itself. + tunnelProcesses.set(wsId, child) return cmdId }, diff --git a/desktop/src/renderer/src/lib/ipc/events.ts b/desktop/src/renderer/src/lib/ipc/events.ts index 2cb97192a..707afe7aa 100644 --- a/desktop/src/renderer/src/lib/ipc/events.ts +++ b/desktop/src/renderer/src/lib/ipc/events.ts @@ -4,6 +4,7 @@ import type { Machine, Provider, Workspace, + WorkspaceStatus, } from "$lib/types/index.js" import { listen } from "./bridge.js" import type { UnlistenFn } from "./types.js" @@ -48,6 +49,7 @@ export const EVENT_NAMES = { MACHINES_CHANGED: "machines-changed", CONTEXTS_CHANGED: "contexts-changed", COMMAND_PROGRESS: "command-progress", + WORKSPACE_STATUS: "workspace-status", UPDATE_STATUS: "update-status", } as const @@ -105,6 +107,14 @@ export function onCommandProgress( }) } +export function onWorkspaceStatus( + callback: (status: WorkspaceStatus) => void, +): Promise { + return listen(EVENT_NAMES.WORKSPACE_STATUS, (event) => { + callback(event.payload) + }) +} + export function onUpdateStatus( callback: (status: UpdateStatus) => void, ): Promise { diff --git a/desktop/src/renderer/src/lib/types/index.ts b/desktop/src/renderer/src/lib/types/index.ts index 4acbd170c..d8008d5c4 100644 --- a/desktop/src/renderer/src/lib/types/index.ts +++ b/desktop/src/renderer/src/lib/types/index.ts @@ -148,6 +148,16 @@ export interface CommandProgress { cliError?: import("../../../../shared/cli-error.js").CLIError } +/** A `workspace up` phase transition, pushed as it happens. */ +export interface WorkspaceStatus { + commandId: string + workspaceId: string + phase: string + step?: string + started: boolean + error?: string +} + export interface AuditEntry { id: number timestamp: string diff --git a/desktop/src/shared/cli-error.ts b/desktop/src/shared/cli-error.ts index fa67da46a..b78af6033 100644 --- a/desktop/src/shared/cli-error.ts +++ b/desktop/src/shared/cli-error.ts @@ -10,3 +10,60 @@ export interface CliLogLine { cliError?: CLIError [key: string]: unknown } + +/** + * NDJSON envelope shapes written by `workspace up --result-format json`. + * Mirrors pkg/devcontainer/config/envelope.go — keep in sync. + */ +export type CliEnvelopeKind = "status" | "result" | "error" + +export interface CliStatusEnvelope { + kind: "status" + phase: string + step?: string + started: boolean + error?: string +} + +export interface CliResultEnvelope { + kind: "result" + outcome: "success" + containerId: string + remoteUser: string + remoteWorkspaceFolder: string + url?: string + warnings?: string[] + recovery?: boolean +} + +export interface CliErrorEnvelope { + kind: "error" + outcome: "error" + message: string +} + +export type CliEnvelope = + | CliStatusEnvelope + | CliResultEnvelope + | CliErrorEnvelope + +/** Returns undefined when line isn't a recognized envelope. */ +export function parseCliEnvelope(line: string): CliEnvelope | undefined { + const trimmed = line.trim() + if (!trimmed.startsWith("{")) return undefined + try { + const obj = JSON.parse(trimmed) as unknown + if ( + obj && + typeof obj === "object" && + "kind" in obj && + (obj as { kind: unknown }).kind && + ["status", "result", "error"].includes((obj as { kind: string }).kind) + ) { + return obj as CliEnvelope + } + } catch { + // not JSON — fall through + } + return undefined +} diff --git a/docs/rfcs/async-workspace-up.md b/docs/rfcs/async-workspace-up.md new file mode 100644 index 000000000..b47be9730 --- /dev/null +++ b/docs/rfcs/async-workspace-up.md @@ -0,0 +1,441 @@ +# RFC: Asynchronous `workspace up` and structured status + +## Status + +Draft. No backward compatibility is preserved by design — breaking changes to +the CLI JSON output contract, the `Runner` interface, the tunnel proto, and +the desktop IPC contract are in scope. + +## Problem + +`workspace up` is a single synchronous call chain from CLI flag parsing all +the way to IDE launch. Every layer blocks on the one below it and returns a +single terminal value: + +``` +cmd/workspace/up.Run + -> devsyUp -> dispatchClient (blocks on client.Up / tunnel RunUpServer) + -> devcontainer.Runner.Up(ctx, options, timeout) (*config.Result, error) + -> runSingleContainer / runDockerCompose + -> build (docker/buildkit exec, blocking) + -> driver.Run (blocking) + -> setupContainer -> inner tunnel -> RunPreAttachHooks (blocking up to waitFor) + -> finalizeUp (SSH config, tunnel, IDE open) +``` + +The only two structured signals that cross a process boundary are: + +- `tunnel.LogMessage` — a level (`DEBUG/INFO/DONE/WARNING/ERROR`) + freeform + string, sent continuously. +- `tunnel.SendResult` — the final `config.Result`, sent exactly once, at the + end. + +There is no phase, step, or percentage concept anywhere. The desktop app +compensates by spawning the CLI as a subprocess, parsing every stdout line as +a zap JSON log record, and detecting completion by +`line.includes('"outcome":"success"')` (`desktop/src/main/ipc.ts:755`) — a +string sniff on log text, not a contract. + +Consequences: + +- The CLI cannot report progress faster than log lines happen to convey it. +- The desktop app cannot show "building image" vs "running postCreate" vs + "waiting for port" as distinct states — only "still running" or "done". +- Nothing downstream of `up` (IDE open, SSH tunnel) can start until the + entire blocking chain returns, even when only the pieces it actually + depends on (e.g. container is running, `waitFor` phase reached) are ready. + +## Goals + +1. Every meaningful step of `up` (resolve config, initializeCommand, build + image, start container, inject agent, run each lifecycle hook, waitFor, + ready) emits a structured, typed status event as it starts/finishes — + not just a log line. +2. The event stream crosses every process boundary that today only carries + `LogMessage`/`Result` (the outer CLI<->agent tunnel and the inner + container-setup tunnel). +3. The CLI's `--result-format json` mode emits status as NDJSON as it + happens, not only a single envelope at the end. +4. The desktop app consumes the structured stream directly — no string + sniffing on log content for control flow. +5. Steps that don't depend on each other can run concurrently where the + underlying operation allows it (e.g. pulling/building images for + docker-compose services, or preparing SSH config while lifecycle hooks + past `waitFor` are still running in the background). +6. `up` can return control to its caller as soon as the work is submitted, + without waiting for the workspace to be ready — the caller (CLI user or + desktop UI) decides whether to wait or poll. This is the actual meaning of + "asynchronous" this RFC targets: not reordering the devcontainer-spec's + inherently sequential build/inject/hook chain (that chain has real data + dependencies and reordering it would be incorrect), but decoupling + *submission* from *completion* the way the platform/daemon path already + decouples them for remote workspaces. + +## Non-goals + +- Changing the devcontainer.json spec or lifecycle hook semantics themselves. +- A general pub/sub system for arbitrary future features — scope is the + `up`/`build`/`setup` path. +- Preserving the current single-shot JSON result envelope shape unchanged; + it is superseded by the event stream plus a final terminal event. + +## Design + +### 1. Status/phase model (`pkg/devcontainer/status`) + +A closed set of phases mirroring the real dependency chain of `up`: + +```go +type Phase string + +const ( + PhaseCloningRepository Phase = "cloning_repository" + PhaseResolvingConfig Phase = "resolving_config" + PhaseInitializeCommand Phase = "initialize_command" + PhaseBuildingImage Phase = "building_image" + PhaseStartingContainer Phase = "starting_container" + PhaseInjectingAgent Phase = "injecting_agent" + PhaseRunningLifecycleHook Phase = "running_lifecycle_hook" // + HookName field + PhaseWaitingFor Phase = "waiting_for" + PhaseReady Phase = "ready" + PhaseFailed Phase = "failed" +) + +type Event struct { + Phase Phase + Step string // e.g. hook name, service name; empty when not applicable + Started bool // true = entering phase, false = phase complete + Err string // set only when Phase == PhaseFailed +} + +type Reporter interface { + Report(Event) +} +``` + +A `NopReporter` and a `ChannelReporter` (buffered channel + drain goroutine, +same shape as the existing `tunnelLogger` pattern) are the two initial +implementations. + +### 2. `Runner` interface takes a `Reporter` + +```go +type Runner interface { + Up(ctx context.Context, options UpOptions, timeout time.Duration, r status.Reporter) (*config.Result, error) + // ... unchanged otherwise +} +``` + +Breaking change: every existing caller of `Up` must pass a reporter +(`status.Nop()` is fine where nobody listens yet). `run.go`, `single.go`, +`compose_up.go`, `build.go`, `setup.go`, `lifecyclehooks.go` each call +`r.Report(...)` at the start/end of their step instead of (or in addition to) +`log.Info`. + +### 3. Tunnel proto: `StatusUpdate` RPC + +```protobuf +message StatusUpdate { + string phase = 1; + string step = 2; + bool started = 3; + string error = 4; +} + +service Tunnel { + ... + rpc Status(StatusUpdate) returns (Empty) {} +} +``` + +Both tunnels that exist today (`RunUpServer` for the outer CLI<->agent +session, `RunSetupServer` for the inner container-setup session) forward +`status.Reporter` events over this RPC the same way `tunnelLogger` forwards +`LogMessage` today (buffered chan + worker goroutine, `pkg/agent/tunnelserver/logger.go` +is the template). The host-side tunnel client fans inbound `StatusUpdate` +messages into a `status.Reporter` the CLI process owns. + +### 4. CLI: NDJSON status stream + +`cmd/workspace/up` gets a `status.Reporter` that, in JSON result-format mode, +writes one compact JSON object per event to stdout immediately (not +buffered until exit), followed by the existing terminal `ResultEnvelope`/ +`ErrorEnvelope` as the final line. Plain-text mode renders the same events +as human-readable progress lines (this can replace a good number of today's +ad hoc `log.Info` progress messages in the up path). + +This is a breaking change to the CLI's JSON output contract: consumers that +assumed exactly one JSON object on stdout must now expect a stream and +identify the terminal one (e.g. by a `"kind":"result"` discriminator field +added to `ResultEnvelope`/`ErrorEnvelope`, vs `"kind":"status"` on events). + +### 5. Desktop: structured consumption + +`desktop/src/main/ipc.ts`'s `workspace_up` handler stops treating stdout as +opaque log text for control flow. It parses each NDJSON line, and: + +- `"kind":"status"` lines update a per-workspace phase state machine and are + forwarded to the renderer as a new `workspace-status` IPC event (structured + `{ commandId, phase, step, started }`), replacing the current + `command-progress` line-batching for control-flow purposes (raw log text + can still be forwarded for the log viewer, but is no longer load-bearing). +- `"kind":"result"` lines replace the `line.includes('"outcome":"success"')` + sniff. + +`desktop/src/shared/cli-error.ts` (or a new `cli-status.ts`) gains the shared +TypeScript types for `StatusEvent`/`ResultEnvelope` mirroring the Go JSON +shapes, generated or hand-kept in sync (no codegen exists today; hand-kept +with a comment pointing at the Go source of truth is acceptable for now). + +### 6. Two execution flows, and standard verbs for the durable one + +This is the piece that actually decouples "start provisioning" from "wait +for it to finish," per Goal 6. + +**Synchronous** (default, unchanged): `workspace up ` attaches and +waits, exactly as before — no reason to force a UX change on the common +interactive case. + +**Durable**: `workspace up --detach`/`-d` submits the same pipeline +as a background task and returns immediately, printing +`{"kind":"task","id":"..."}`. "Durable" here means the task's state +survives past the submitting process's exit — it's a file, not something +held in memory by a process someone has to keep watching. + +**`pkg/task`** is that file's home: one JSON file per task under +`PathManager.TaskDir()` (`/tasks/.json`), written atomically +(temp file + rename). `State` carries `Status` (`pending` / `running` / +`succeeded` / `failed`), the most recent `Phase`/`Step`, labels +(`Command`, `WorkspaceID`) for listing, a `PID` for cancellation, and — once +terminal — the final `Error` or `*config.Result`. `Task.Reporter()` returns +a `status.Reporter` that persists each event into the file. + +**`cmd/workspace/up/detach.go`** implements submission: create a task, then +re-exec the current binary with this invocation's original arguments — +`--detach` swapped for the hidden `--task-id ` — as a background process +(`pkg/command.StartBackground`, the same primitive already used for deferred +lifecycle hooks). The parent does no devcontainer work itself. **The +re-exec'd child** runs through the exact same `execute` → `Run` → +`executeDevsyUp` → `finalizeUp` path an attached `up` always has — nothing +about the devcontainer-spec-ordered pipeline changes; `Run` just tees its +`status.Reporter` (`status.Tee`) into both the normal NDJSON/log output *and* +the task file, and calls `Succeed`/`Fail` on the task at the same points it +would otherwise just return. The child *is* today's `up`, observed from two +places instead of one — which is why devcontainer-spec ordering needed no +verification beyond "did we change anything in `pkg/devcontainer`?" (no). + +**The task resource gets standard verbs** (`cmd/workspace/task.go`), rather +than one command overloaded with `--wait`/`--cancel` flags, so it reads like +every other resource-oriented CLI (kubectl, docker, gh): + +| Command | Aliases | Verb category | +|---|---|---| +| `workspace task list` | `ls` | List | +| `workspace task get ` | `describe`, `show` | Get/Show/Describe | +| `workspace task logs [-f\|--follow]` | `attach` | Logs/Tail (docker/kubectl `-f`) | +| `workspace task cancel ` | `stop` | Stop/Cancel | +| `workspace task rm [--force]` | `delete`, `remove` | Delete/Rm/Remove | + +`get`/`logs` without `-f` are the same operation (one snapshot); `logs -f` +polls on an interval (`--interval`, default 500ms), printing the phase +observed at each poll when it differs from the last, then the final +result/error envelope (exit code reflects success/failure). `State` stores +only the current phase/step, not a history — this is a latest-state +snapshot, not a replay log, so a burst of transitions between two polls can +be collapsed to just the last one observed. `cancel` sends the signal +recorded via `Task.SetPID` (set by the detached child on itself) and marks +the task failed with `task.ErrCanceled`; canceling successfully is reported +as success even though the task's own terminal state is "failed." `rm` +refuses to delete a non-terminal task unless `--force`, which cancels it +first (stopping the worker) before deleting — mirroring `kubectl delete +--force`/`docker rm -f`, both of which also stop the resource, not just +remove its record. + +This gives every caller — interactive CLI, script, or desktop — the choice +DevContainer-remote already had for platform workspaces: submit and don't +wait, or submit and wait, with status observable in both cases, using verbs +that don't need this doc to explain them. + +**Desktop** (`desktop/src/main/ipc.ts`): `workspace_up` now submits via +`up --detach` (`cli.run`, one-shot), then streams `workspace task logs +--follow` instead of streaming `up` directly — the NDJSON envelope parsing +is unchanged, since `logs` emits the same envelope shapes `up` does. A new +`activeUpTasks: Map` tracks the task backing the real +provisioning process; `quiesceWorkspace` (run before stop/delete) now issues +`workspace task cancel ` against it before touching any child process, +because the child it *can* see (the `logs --follow` poller) is not the +process doing the work — killing only the poller would silently leave +provisioning running. This also means the desktop app can now query a +task's status even if Electron itself was restarted while `up` was running, +which it could not do before (progress was tied to the lifetime of the +child process it spawned). + +### Concurrency + +Structured status is a prerequisite for observing overlap, not a substitute +for it — the point of this RFC is that steps of `up` which don't depend on +each other actually run at the same time. Implemented so far: + +- **Feature resolution** (`pkg/devcontainer/feature.getUserFeatures`): each + user-configured feature is fetched (OCI pull, tarball download, or local + read) concurrently via `errgroup`, instead of one at a time. `lockfileState` + gained a mutex around its `entries` map since `record` is now called from + multiple goroutines. Race-tested with `go test -race`. +- **Agent binary prefetch** (`runner.prefetchAgentBinary`, called from + `runSingleContainer`): the agent binary download/cache-read starts on a + background goroutine at the same time as container resolution + (`resolveContainer`, which builds and starts the container), instead of + starting only once the container is already running and injection is + ready. It's best-effort and silently discards its result on failure — the + real acquisition on the injection path re-runs from scratch and simply + hits the now-warm on-disk cache in the common case. +- **`daemonclient` (platform/pro) `Up` path** + (`pkg/client/clientimplementation/daemonclient/up.go`): `UpOptions` gained + a `Reporter status.Reporter` field, threaded through + `waitTaskDone`/`observeTask`/`printLogs`. The remote task the daemon + observes runs the same `devsy` CLI, so its forwarded log stream already + contains the same `kind":"status"` NDJSON lines a local `up` emits; a new + `statusSniffingWriter` splits that stream into lines, forwards recognized + status lines to the `Reporter` (via the new `config.ParseStatusLine` + helper), and passes every other line through to the existing zap-log + renderer unchanged. This does not make the call return before the remote + task finishes — that's a correctness requirement, not a gap: `Up` must + return the final `*config.Result`, which only exists once the remote task + completes — but it does mean this path now participates in the same + structured-status model as the local path instead of being informationally + disconnected from it (previously: opaque zap JSON only, `status.Reporter` + events did not exist for this path at all). Covered by + `statusSniffingWriter`'s dedicated unit tests (race-tested). + +Already-async before this RFC, noted for completeness: `DeferredHooks` (the +lifecycle hooks that run after `waitFor`) are launched as a detached +background OS process (`cmd/internal/agentcontainer/setup.go:startDeferredHooks`) +rather than awaited inline — `up` does not block on them today. Likewise, +docker-compose service builds are not a sequential loop in this codebase to +begin with: `runComposeBuild` shells out once to `docker compose ... build` +(`compose_build.go:runComposeBuild`), and the Compose CLI itself parallelizes +independent service builds — there's no Go-level per-service loop here to +convert. + +Remaining candidate, not yet converted: + +- Per-lifecycle-hook granularity: the inner container-setup tunnel currently + reports one `PhaseRunningLifecycleHook` span for the whole pre-`waitFor` + hook sequence rather than one per hook. + +This RFC does not mandate converting every step in one pass — each +conversion above was verified independently (build + `go test -race`) rather +than landed as one big-bang rewrite. + +## Breaking changes summary + +| Area | Before | After | +|---|---|---| +| `devcontainer.Runner.Up` | `(ctx, options, timeout) (*Result, error)` | `(ctx, options, timeout, status.Reporter) (*Result, error)` | +| Tunnel proto | `Log` + `SendResult` only | adds `Status` RPC | +| CLI JSON stdout | one `ResultEnvelope`/`ErrorEnvelope` object | NDJSON stream of status events + one terminal result/error object, each tagged with a `kind` discriminator | +| Desktop completion detection | `stdout.includes('"outcome":"success"')` | parse `"kind":"result"` NDJSON line | +| Desktop progress IPC | `command-progress` (raw log line batches) | new `workspace-status` (structured phase) + `command-progress` (raw log, display-only) | +| `client.UpOptions` | no status visibility | gains `Reporter status.Reporter` | +| `workspace up` | always attaches and waits | `--detach`/`-d` submits a `pkg/task` and returns immediately; default behavior unchanged | +| Local task observability | none — progress only exists on the invoking process's stdout | `workspace task {list,get,logs}` reads `pkg/task`'s on-disk state, independent of any live process | +| Canceling an in-progress `up` | kill the CLI process desktop happened to spawn | `workspace task cancel ` (PID-tracked, works regardless of who's watching) | +| Desktop `workspace_up` IPC | spawns `up` directly, streams its output | spawns `up --detach` (one-shot), then streams `workspace task logs --follow`; `quiesceWorkspace` cancels the task, not just its poller | +| `client.Status` | `Running`/`Busy`/`Stopped`/`NotFound` | adds `Provisioning` and `Failed`: `workspaceClient.Status()` looks at the most recently started `up` task on record for the workspace and reports one of them instead of `NotFound` when it's more informative — `Provisioning` while in flight, `Failed` if it errored — so `workspace status` doesn't claim a workspace was never started, or silently forget a failed attempt | +| `git clone` progress | raw `--progress` output logged as-is (unreadable: `\r`-delimited updates collapse into one giant line once captured as log lines) | new `status.PhaseCloningRepository`: `pkg/git`'s `progressWriter` parses the `\r`-delimited updates and reports them as `status.Event`s (thinned to every 10%) instead of logging them; plain, JSON, and desktop consumption all get clone progress through the same pipeline as every other phase, for free | +| `log.Writer` (subprocess Stdout/Stderr, ~65 call sites) | raw byte passthrough straight to the stderr sink, bypassing the configured encoder — every JSON-mode log call except this one produced `{"level":...}`, this one leaked unencoded text into the stream | line-buffers and calls `Info`/`Debug`/`Error` per complete line, so subprocess output goes through the same json/text/logfmt encoder as everything else; contradicted its own doc comment ("writes each line as a log entry") before this fix | + +## Rollout + +1. Land `pkg/devcontainer/status` (event model + Nop/Channel reporters) with + no behavior change — nothing calls `Report` with anything meaningful yet. +2. Thread `status.Reporter` through `Runner.Up` and its call chain, emitting + real events at each existing step boundary (no new proto yet — reporter is + in-process only for the direct/non-tunneled drivers first). +3. Add the `StatusUpdate` tunnel RPC and wire both tunnel sessions to forward + events across the process boundary. +4. Switch CLI JSON output to NDJSON + discriminator, update `pkg/output`. +5. Update desktop `ipc.ts`/`cli.ts`/shared types to consume structured events; + remove the string-sniff. +6. Opportunistically parallelize the concurrency candidates listed above, + one at a time, each covered by its own e2e test under `e2e/tests/up*`. +7. Land `pkg/task` and `workspace up --detach`, reusing the reporter + plumbing from steps 1-4 unchanged — the detached child is the same + attached-mode code path, just with its reporter teed into a task file. + No changes to `pkg/devcontainer` were needed for this step, which is + itself evidence the devcontainer-spec ordering wasn't touched. +8. Wire the desktop app onto the submit/attach model: + `desktop/src/main/ipc.ts`'s `workspace_up` handler now submits via + `up --detach` (`cli.run`, one-shot — returns as soon as the task exists), + then streams the task's status instead of streaming `up` directly. A new + `activeUpTasks: Map` tracks the task backing the real + (detached) provisioning process; `quiesceWorkspace` (called before + stop/delete) now cancels it before touching any live child process, + because the child it *can* see (the poller) is no longer the process + actually doing the work — killing only the poller would silently leave + provisioning running. This needed `pkg/task` to gain PID tracking + (`Task.SetPID`, set by the detached child on itself as soon as it opens + its task) and `Task.Cancel` (`pkg/command.Kill` on that PID, then + `Fail(ErrCanceled)`) — the "some other commands will be impacted" part + of the original ask materializing. +9. Replace the ad hoc `workspace task [--wait] [--cancel]` command with + standard verbs on the task resource — `list`/`get`/`logs [-f]`/`cancel`/ + `rm [--force]` — matching kubectl/docker/gh conventions instead of one + command overloaded with flags. `pkg/task` gained `CreateOptions` + (`Command`/`WorkspaceID` labels, so `list` is useful) and `Store.Delete` + for `rm`. Desktop's two call sites (poll, cancel) were updated to the new + verbs in the same change — see the table above. +10. Close the loop between `pkg/task` and container-level status: + `workspaceClient.Status()` (`pkg/client/clientimplementation`) now + reports `client.StatusProvisioning` or `client.StatusFailed` in place of + `StatusNotFound` based on the most recently started `up` task on record + for the workspace (`workspaceClient.taskStatusOverride()` lists + `pkg/task`, matches on `WorkspaceID`+`Command`, and picks the task with + the latest `StartedAt`) — `Provisioning` while it's non-terminal, + `Failed` if it ended in error, and no override at all if it succeeded + (a successful task implies nothing; if the container's still missing + after that, something else removed it, and `NotFound` is the honest + answer). The detached child corrects its task's `WorkspaceID` label to + the client's resolved ID once known (`Task.SetWorkspaceID`, called from + `Run`) rather than trusting the submitting process's guess (raw + `--id`/source string), since the lookup depends on that label being + accurate. +11. Route git clone progress through the same structured pipeline instead of + dropping it. First pass tried reformatting git's raw `--progress` text + for readability (a client-side writer splitting on `\r`); simpler and + more correct was noticing that no code anywhere parses that text for a + UI, so there was nothing to preserve about the raw format at all — the + real fix was giving clone progress the same treatment every other phase + already gets. `status` gains `PhaseCloningRepository`; `pkg/git` gains + `WithProgressReporter` (a `RepoOption`) and a `progressWriter` that + parses `\r`-delimited "label: NN% (x/y)" updates and reports them as + `status.Event`s (thinned to every 10%, exact-duplicate frames dropped) + instead of writing them to the log — informational lines like "Cloning + into '...'" still log normally. The reporter is threaded from the + agent's tunnel client (`tunnelserver.NewTunnelStatusReporter`, already + built in step 3) through `prepareWorkspace`/`prepareGitWorkspace` into + `agent.CloneWorkspaceParams`, so it crosses the same tunnel boundary as + every other phase and reaches the CLI's NDJSON/plain output and + desktop's `workspace-status` IPC event for free — no new plumbing + needed on either of those ends. + +Each phase should be independently mergeable and leaves the system in a +working (if not yet fully async) state — the plan is incremental even though +no compatibility shims are kept between phases. + +12. Harden `pkg/task` against the two ways it's actually multi-writer: a + detached worker reporting progress and a separate `task cancel`/`rm + --force` process both mutate the same state file, and `Get`/`Delete` + take a caller-supplied ID from argv. `Store.update` now holds an OS file + lock (`github.com/gofrs/flock`, one `.lock` file per task, 5s timeout) + around each read-modify-write, replacing a process-local `sync.Mutex` + that only ever protected against races within one process. `Cancel` + reads the terminal check and applies the state transition inside that + same locked update instead of as a separate Get-then-Fail, so a + concurrent worker report can't land between them. `Store.path` rejects + any ID that isn't a single clean path component, closing a path + traversal a raw `../../etc/passwd`-style ID could otherwise reach. + `pkg/command.kill` (Unix) also stopped discarding `syscall.Kill`'s + error — it now returns real failures and only treats "process already + gone" (`ESRCH`) as success, so `Cancel` can't silently claim a kill + succeeded when it didn't. diff --git a/pkg/agent/tunnel/tunnel.pb.go b/pkg/agent/tunnel/tunnel.pb.go index 55197c060..92a13a112 100644 --- a/pkg/agent/tunnel/tunnel.pb.go +++ b/pkg/agent/tunnel/tunnel.pb.go @@ -2,8 +2,8 @@ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: -// protoc-gen-go v1.36.4 -// protoc v7.34.1 +// protoc-gen-go v1.36.11 +// protoc v7.35.1 // source: tunnel.proto package tunnel @@ -526,6 +526,76 @@ func (x *LogMessage) GetMessage() string { return "" } +// StatusUpdate carries a structured up-pipeline phase transition, distinct +// from the freeform LogMessage stream. See docs/rfcs/async-workspace-up.md. +type StatusUpdate struct { + state protoimpl.MessageState `protogen:"open.v1"` + Phase string `protobuf:"bytes,1,opt,name=phase,proto3" json:"phase,omitempty"` + Step string `protobuf:"bytes,2,opt,name=step,proto3" json:"step,omitempty"` + Started bool `protobuf:"varint,3,opt,name=started,proto3" json:"started,omitempty"` + Error string `protobuf:"bytes,4,opt,name=error,proto3" json:"error,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *StatusUpdate) Reset() { + *x = StatusUpdate{} + mi := &file_tunnel_proto_msgTypes[10] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *StatusUpdate) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*StatusUpdate) ProtoMessage() {} + +func (x *StatusUpdate) ProtoReflect() protoreflect.Message { + mi := &file_tunnel_proto_msgTypes[10] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use StatusUpdate.ProtoReflect.Descriptor instead. +func (*StatusUpdate) Descriptor() ([]byte, []int) { + return file_tunnel_proto_rawDescGZIP(), []int{10} +} + +func (x *StatusUpdate) GetPhase() string { + if x != nil { + return x.Phase + } + return "" +} + +func (x *StatusUpdate) GetStep() string { + if x != nil { + return x.Step + } + return "" +} + +func (x *StatusUpdate) GetStarted() bool { + if x != nil { + return x.Started + } + return false +} + +func (x *StatusUpdate) GetError() string { + if x != nil { + return x.Error + } + return "" +} + type Empty struct { state protoimpl.MessageState `protogen:"open.v1"` unknownFields protoimpl.UnknownFields @@ -534,7 +604,7 @@ type Empty struct { func (x *Empty) Reset() { *x = Empty{} - mi := &file_tunnel_proto_msgTypes[10] + mi := &file_tunnel_proto_msgTypes[11] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -546,7 +616,7 @@ func (x *Empty) String() string { func (*Empty) ProtoMessage() {} func (x *Empty) ProtoReflect() protoreflect.Message { - mi := &file_tunnel_proto_msgTypes[10] + mi := &file_tunnel_proto_msgTypes[11] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -559,105 +629,67 @@ func (x *Empty) ProtoReflect() protoreflect.Message { // Deprecated: Use Empty.ProtoReflect.Descriptor instead. func (*Empty) Descriptor() ([]byte, []int) { - return file_tunnel_proto_rawDescGZIP(), []int{10} + return file_tunnel_proto_rawDescGZIP(), []int{11} } var File_tunnel_proto protoreflect.FileDescriptor -var file_tunnel_proto_rawDesc = string([]byte{ - 0x0a, 0x0c, 0x74, 0x75, 0x6e, 0x6e, 0x65, 0x6c, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x12, 0x06, - 0x74, 0x75, 0x6e, 0x6e, 0x65, 0x6c, 0x22, 0x2a, 0x0a, 0x12, 0x53, 0x74, 0x72, 0x65, 0x61, 0x6d, - 0x4d, 0x6f, 0x75, 0x6e, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x14, 0x0a, 0x05, - 0x6d, 0x6f, 0x75, 0x6e, 0x74, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x6d, 0x6f, 0x75, - 0x6e, 0x74, 0x22, 0x2c, 0x0a, 0x16, 0x53, 0x74, 0x6f, 0x70, 0x46, 0x6f, 0x72, 0x77, 0x61, 0x72, - 0x64, 0x50, 0x6f, 0x72, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x12, 0x0a, 0x04, - 0x70, 0x6f, 0x72, 0x74, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x70, 0x6f, 0x72, 0x74, - 0x22, 0x19, 0x0a, 0x17, 0x53, 0x74, 0x6f, 0x70, 0x46, 0x6f, 0x72, 0x77, 0x61, 0x72, 0x64, 0x50, - 0x6f, 0x72, 0x74, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x28, 0x0a, 0x12, 0x46, - 0x6f, 0x72, 0x77, 0x61, 0x72, 0x64, 0x50, 0x6f, 0x72, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, - 0x74, 0x12, 0x12, 0x0a, 0x04, 0x70, 0x6f, 0x72, 0x74, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, - 0x04, 0x70, 0x6f, 0x72, 0x74, 0x22, 0x15, 0x0a, 0x13, 0x46, 0x6f, 0x72, 0x77, 0x61, 0x72, 0x64, - 0x50, 0x6f, 0x72, 0x74, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x23, 0x0a, 0x07, - 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x12, 0x18, 0x0a, 0x07, 0x6d, 0x65, 0x73, 0x73, 0x61, - 0x67, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, - 0x65, 0x22, 0x48, 0x0a, 0x06, 0x53, 0x65, 0x63, 0x72, 0x65, 0x74, 0x12, 0x12, 0x0a, 0x04, 0x6e, - 0x61, 0x6d, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x12, - 0x14, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, - 0x76, 0x61, 0x6c, 0x75, 0x65, 0x12, 0x14, 0x0a, 0x05, 0x6d, 0x6f, 0x75, 0x6e, 0x74, 0x18, 0x03, - 0x20, 0x01, 0x28, 0x08, 0x52, 0x05, 0x6d, 0x6f, 0x75, 0x6e, 0x74, 0x22, 0x3b, 0x0a, 0x0f, 0x53, - 0x65, 0x63, 0x72, 0x65, 0x74, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x28, - 0x0a, 0x07, 0x73, 0x65, 0x63, 0x72, 0x65, 0x74, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, - 0x0e, 0x2e, 0x74, 0x75, 0x6e, 0x6e, 0x65, 0x6c, 0x2e, 0x53, 0x65, 0x63, 0x72, 0x65, 0x74, 0x52, - 0x07, 0x73, 0x65, 0x63, 0x72, 0x65, 0x74, 0x73, 0x22, 0x21, 0x0a, 0x05, 0x43, 0x68, 0x75, 0x6e, - 0x6b, 0x12, 0x18, 0x0a, 0x07, 0x43, 0x6f, 0x6e, 0x74, 0x65, 0x6e, 0x74, 0x18, 0x01, 0x20, 0x01, - 0x28, 0x0c, 0x52, 0x07, 0x43, 0x6f, 0x6e, 0x74, 0x65, 0x6e, 0x74, 0x22, 0x54, 0x0a, 0x0a, 0x4c, - 0x6f, 0x67, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x12, 0x2c, 0x0a, 0x08, 0x6c, 0x6f, 0x67, - 0x4c, 0x65, 0x76, 0x65, 0x6c, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x10, 0x2e, 0x74, 0x75, - 0x6e, 0x6e, 0x65, 0x6c, 0x2e, 0x4c, 0x6f, 0x67, 0x4c, 0x65, 0x76, 0x65, 0x6c, 0x52, 0x08, 0x6c, - 0x6f, 0x67, 0x4c, 0x65, 0x76, 0x65, 0x6c, 0x12, 0x18, 0x0a, 0x07, 0x6d, 0x65, 0x73, 0x73, 0x61, - 0x67, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, - 0x65, 0x22, 0x07, 0x0a, 0x05, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x2a, 0x41, 0x0a, 0x08, 0x4c, 0x6f, - 0x67, 0x4c, 0x65, 0x76, 0x65, 0x6c, 0x12, 0x09, 0x0a, 0x05, 0x44, 0x45, 0x42, 0x55, 0x47, 0x10, - 0x00, 0x12, 0x08, 0x0a, 0x04, 0x49, 0x4e, 0x46, 0x4f, 0x10, 0x01, 0x12, 0x08, 0x0a, 0x04, 0x44, - 0x4f, 0x4e, 0x45, 0x10, 0x02, 0x12, 0x0b, 0x0a, 0x07, 0x57, 0x41, 0x52, 0x4e, 0x49, 0x4e, 0x47, - 0x10, 0x03, 0x12, 0x09, 0x0a, 0x05, 0x45, 0x52, 0x52, 0x4f, 0x52, 0x10, 0x04, 0x32, 0xc1, 0x06, - 0x0a, 0x06, 0x54, 0x75, 0x6e, 0x6e, 0x65, 0x6c, 0x12, 0x26, 0x0a, 0x04, 0x50, 0x69, 0x6e, 0x67, - 0x12, 0x0d, 0x2e, 0x74, 0x75, 0x6e, 0x6e, 0x65, 0x6c, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x1a, - 0x0d, 0x2e, 0x74, 0x75, 0x6e, 0x6e, 0x65, 0x6c, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x22, 0x00, - 0x12, 0x2a, 0x0a, 0x03, 0x4c, 0x6f, 0x67, 0x12, 0x12, 0x2e, 0x74, 0x75, 0x6e, 0x6e, 0x65, 0x6c, - 0x2e, 0x4c, 0x6f, 0x67, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x1a, 0x0d, 0x2e, 0x74, 0x75, - 0x6e, 0x6e, 0x65, 0x6c, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x22, 0x00, 0x12, 0x2e, 0x0a, 0x0a, - 0x53, 0x65, 0x6e, 0x64, 0x52, 0x65, 0x73, 0x75, 0x6c, 0x74, 0x12, 0x0f, 0x2e, 0x74, 0x75, 0x6e, - 0x6e, 0x65, 0x6c, 0x2e, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x1a, 0x0d, 0x2e, 0x74, 0x75, - 0x6e, 0x6e, 0x65, 0x6c, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x22, 0x00, 0x12, 0x37, 0x0a, 0x11, - 0x44, 0x6f, 0x63, 0x6b, 0x65, 0x72, 0x43, 0x72, 0x65, 0x64, 0x65, 0x6e, 0x74, 0x69, 0x61, 0x6c, - 0x73, 0x12, 0x0f, 0x2e, 0x74, 0x75, 0x6e, 0x6e, 0x65, 0x6c, 0x2e, 0x4d, 0x65, 0x73, 0x73, 0x61, - 0x67, 0x65, 0x1a, 0x0f, 0x2e, 0x74, 0x75, 0x6e, 0x6e, 0x65, 0x6c, 0x2e, 0x4d, 0x65, 0x73, 0x73, - 0x61, 0x67, 0x65, 0x22, 0x00, 0x12, 0x34, 0x0a, 0x0e, 0x47, 0x69, 0x74, 0x43, 0x72, 0x65, 0x64, - 0x65, 0x6e, 0x74, 0x69, 0x61, 0x6c, 0x73, 0x12, 0x0f, 0x2e, 0x74, 0x75, 0x6e, 0x6e, 0x65, 0x6c, - 0x2e, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x1a, 0x0f, 0x2e, 0x74, 0x75, 0x6e, 0x6e, 0x65, - 0x6c, 0x2e, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x22, 0x00, 0x12, 0x35, 0x0a, 0x0f, 0x47, - 0x69, 0x74, 0x53, 0x53, 0x48, 0x53, 0x69, 0x67, 0x6e, 0x61, 0x74, 0x75, 0x72, 0x65, 0x12, 0x0f, - 0x2e, 0x74, 0x75, 0x6e, 0x6e, 0x65, 0x6c, 0x2e, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x1a, - 0x0f, 0x2e, 0x74, 0x75, 0x6e, 0x6e, 0x65, 0x6c, 0x2e, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, - 0x22, 0x00, 0x12, 0x2b, 0x0a, 0x07, 0x47, 0x69, 0x74, 0x55, 0x73, 0x65, 0x72, 0x12, 0x0d, 0x2e, - 0x74, 0x75, 0x6e, 0x6e, 0x65, 0x6c, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x1a, 0x0f, 0x2e, 0x74, - 0x75, 0x6e, 0x6e, 0x65, 0x6c, 0x2e, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x22, 0x00, 0x12, - 0x31, 0x0a, 0x0b, 0x44, 0x65, 0x76, 0x73, 0x79, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x0f, - 0x2e, 0x74, 0x75, 0x6e, 0x6e, 0x65, 0x6c, 0x2e, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x1a, - 0x0f, 0x2e, 0x74, 0x75, 0x6e, 0x6e, 0x65, 0x6c, 0x2e, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, - 0x22, 0x00, 0x12, 0x33, 0x0a, 0x0d, 0x47, 0x50, 0x47, 0x50, 0x75, 0x62, 0x6c, 0x69, 0x63, 0x4b, - 0x65, 0x79, 0x73, 0x12, 0x0f, 0x2e, 0x74, 0x75, 0x6e, 0x6e, 0x65, 0x6c, 0x2e, 0x4d, 0x65, 0x73, - 0x73, 0x61, 0x67, 0x65, 0x1a, 0x0f, 0x2e, 0x74, 0x75, 0x6e, 0x6e, 0x65, 0x6c, 0x2e, 0x4d, 0x65, - 0x73, 0x73, 0x61, 0x67, 0x65, 0x22, 0x00, 0x12, 0x30, 0x0a, 0x0a, 0x4b, 0x75, 0x62, 0x65, 0x43, - 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x0f, 0x2e, 0x74, 0x75, 0x6e, 0x6e, 0x65, 0x6c, 0x2e, 0x4d, - 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x1a, 0x0f, 0x2e, 0x74, 0x75, 0x6e, 0x6e, 0x65, 0x6c, 0x2e, - 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x22, 0x00, 0x12, 0x33, 0x0a, 0x07, 0x53, 0x65, 0x63, - 0x72, 0x65, 0x74, 0x73, 0x12, 0x0d, 0x2e, 0x74, 0x75, 0x6e, 0x6e, 0x65, 0x6c, 0x2e, 0x45, 0x6d, - 0x70, 0x74, 0x79, 0x1a, 0x17, 0x2e, 0x74, 0x75, 0x6e, 0x6e, 0x65, 0x6c, 0x2e, 0x53, 0x65, 0x63, - 0x72, 0x65, 0x74, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x00, 0x12, 0x48, - 0x0a, 0x0b, 0x46, 0x6f, 0x72, 0x77, 0x61, 0x72, 0x64, 0x50, 0x6f, 0x72, 0x74, 0x12, 0x1a, 0x2e, - 0x74, 0x75, 0x6e, 0x6e, 0x65, 0x6c, 0x2e, 0x46, 0x6f, 0x72, 0x77, 0x61, 0x72, 0x64, 0x50, 0x6f, - 0x72, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1b, 0x2e, 0x74, 0x75, 0x6e, 0x6e, - 0x65, 0x6c, 0x2e, 0x46, 0x6f, 0x72, 0x77, 0x61, 0x72, 0x64, 0x50, 0x6f, 0x72, 0x74, 0x52, 0x65, - 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x00, 0x12, 0x54, 0x0a, 0x0f, 0x53, 0x74, 0x6f, 0x70, - 0x46, 0x6f, 0x72, 0x77, 0x61, 0x72, 0x64, 0x50, 0x6f, 0x72, 0x74, 0x12, 0x1e, 0x2e, 0x74, 0x75, - 0x6e, 0x6e, 0x65, 0x6c, 0x2e, 0x53, 0x74, 0x6f, 0x70, 0x46, 0x6f, 0x72, 0x77, 0x61, 0x72, 0x64, - 0x50, 0x6f, 0x72, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1f, 0x2e, 0x74, 0x75, - 0x6e, 0x6e, 0x65, 0x6c, 0x2e, 0x53, 0x74, 0x6f, 0x70, 0x46, 0x6f, 0x72, 0x77, 0x61, 0x72, 0x64, - 0x50, 0x6f, 0x72, 0x74, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x00, 0x12, 0x33, - 0x0a, 0x0f, 0x53, 0x74, 0x72, 0x65, 0x61, 0x6d, 0x57, 0x6f, 0x72, 0x6b, 0x73, 0x70, 0x61, 0x63, - 0x65, 0x12, 0x0d, 0x2e, 0x74, 0x75, 0x6e, 0x6e, 0x65, 0x6c, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79, - 0x1a, 0x0d, 0x2e, 0x74, 0x75, 0x6e, 0x6e, 0x65, 0x6c, 0x2e, 0x43, 0x68, 0x75, 0x6e, 0x6b, 0x22, - 0x00, 0x30, 0x01, 0x12, 0x3c, 0x0a, 0x0b, 0x53, 0x74, 0x72, 0x65, 0x61, 0x6d, 0x4d, 0x6f, 0x75, - 0x6e, 0x74, 0x12, 0x1a, 0x2e, 0x74, 0x75, 0x6e, 0x6e, 0x65, 0x6c, 0x2e, 0x53, 0x74, 0x72, 0x65, - 0x61, 0x6d, 0x4d, 0x6f, 0x75, 0x6e, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x0d, - 0x2e, 0x74, 0x75, 0x6e, 0x6e, 0x65, 0x6c, 0x2e, 0x43, 0x68, 0x75, 0x6e, 0x6b, 0x22, 0x00, 0x30, - 0x01, 0x42, 0x2d, 0x5a, 0x2b, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, - 0x64, 0x65, 0x76, 0x73, 0x79, 0x2d, 0x6f, 0x72, 0x67, 0x2f, 0x64, 0x65, 0x76, 0x73, 0x79, 0x2f, - 0x70, 0x6b, 0x67, 0x2f, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x2f, 0x74, 0x75, 0x6e, 0x6e, 0x65, 0x6c, - 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, -}) +const file_tunnel_proto_rawDesc = "" + + "\n" + + "\ftunnel.proto\x12\x06tunnel\"*\n" + + "\x12StreamMountRequest\x12\x14\n" + + "\x05mount\x18\x01 \x01(\tR\x05mount\",\n" + + "\x16StopForwardPortRequest\x12\x12\n" + + "\x04port\x18\x01 \x01(\tR\x04port\"\x19\n" + + "\x17StopForwardPortResponse\"(\n" + + "\x12ForwardPortRequest\x12\x12\n" + + "\x04port\x18\x01 \x01(\tR\x04port\"\x15\n" + + "\x13ForwardPortResponse\"#\n" + + "\aMessage\x12\x18\n" + + "\amessage\x18\x01 \x01(\tR\amessage\"H\n" + + "\x06Secret\x12\x12\n" + + "\x04name\x18\x01 \x01(\tR\x04name\x12\x14\n" + + "\x05value\x18\x02 \x01(\tR\x05value\x12\x14\n" + + "\x05mount\x18\x03 \x01(\bR\x05mount\";\n" + + "\x0fSecretsResponse\x12(\n" + + "\asecrets\x18\x01 \x03(\v2\x0e.tunnel.SecretR\asecrets\"!\n" + + "\x05Chunk\x12\x18\n" + + "\aContent\x18\x01 \x01(\fR\aContent\"T\n" + + "\n" + + "LogMessage\x12,\n" + + "\blogLevel\x18\x01 \x01(\x0e2\x10.tunnel.LogLevelR\blogLevel\x12\x18\n" + + "\amessage\x18\x02 \x01(\tR\amessage\"h\n" + + "\fStatusUpdate\x12\x14\n" + + "\x05phase\x18\x01 \x01(\tR\x05phase\x12\x12\n" + + "\x04step\x18\x02 \x01(\tR\x04step\x12\x18\n" + + "\astarted\x18\x03 \x01(\bR\astarted\x12\x14\n" + + "\x05error\x18\x04 \x01(\tR\x05error\"\a\n" + + "\x05Empty*A\n" + + "\bLogLevel\x12\t\n" + + "\x05DEBUG\x10\x00\x12\b\n" + + "\x04INFO\x10\x01\x12\b\n" + + "\x04DONE\x10\x02\x12\v\n" + + "\aWARNING\x10\x03\x12\t\n" + + "\x05ERROR\x10\x042\xf2\x06\n" + + "\x06Tunnel\x12&\n" + + "\x04Ping\x12\r.tunnel.Empty\x1a\r.tunnel.Empty\"\x00\x12*\n" + + "\x03Log\x12\x12.tunnel.LogMessage\x1a\r.tunnel.Empty\"\x00\x12/\n" + + "\x06Status\x12\x14.tunnel.StatusUpdate\x1a\r.tunnel.Empty\"\x00\x12.\n" + + "\n" + + "SendResult\x12\x0f.tunnel.Message\x1a\r.tunnel.Empty\"\x00\x127\n" + + "\x11DockerCredentials\x12\x0f.tunnel.Message\x1a\x0f.tunnel.Message\"\x00\x124\n" + + "\x0eGitCredentials\x12\x0f.tunnel.Message\x1a\x0f.tunnel.Message\"\x00\x125\n" + + "\x0fGitSSHSignature\x12\x0f.tunnel.Message\x1a\x0f.tunnel.Message\"\x00\x12+\n" + + "\aGitUser\x12\r.tunnel.Empty\x1a\x0f.tunnel.Message\"\x00\x121\n" + + "\vDevsyConfig\x12\x0f.tunnel.Message\x1a\x0f.tunnel.Message\"\x00\x123\n" + + "\rGPGPublicKeys\x12\x0f.tunnel.Message\x1a\x0f.tunnel.Message\"\x00\x120\n" + + "\n" + + "KubeConfig\x12\x0f.tunnel.Message\x1a\x0f.tunnel.Message\"\x00\x123\n" + + "\aSecrets\x12\r.tunnel.Empty\x1a\x17.tunnel.SecretsResponse\"\x00\x12H\n" + + "\vForwardPort\x12\x1a.tunnel.ForwardPortRequest\x1a\x1b.tunnel.ForwardPortResponse\"\x00\x12T\n" + + "\x0fStopForwardPort\x12\x1e.tunnel.StopForwardPortRequest\x1a\x1f.tunnel.StopForwardPortResponse\"\x00\x123\n" + + "\x0fStreamWorkspace\x12\r.tunnel.Empty\x1a\r.tunnel.Chunk\"\x000\x01\x12<\n" + + "\vStreamMount\x12\x1a.tunnel.StreamMountRequest\x1a\r.tunnel.Chunk\"\x000\x01B-Z+github.com/devsy-org/devsy/pkg/agent/tunnelb\x06proto3" var ( file_tunnel_proto_rawDescOnce sync.Once @@ -672,7 +704,7 @@ func file_tunnel_proto_rawDescGZIP() []byte { } var file_tunnel_proto_enumTypes = make([]protoimpl.EnumInfo, 1) -var file_tunnel_proto_msgTypes = make([]protoimpl.MessageInfo, 11) +var file_tunnel_proto_msgTypes = make([]protoimpl.MessageInfo, 12) var file_tunnel_proto_goTypes = []any{ (LogLevel)(0), // 0: tunnel.LogLevel (*StreamMountRequest)(nil), // 1: tunnel.StreamMountRequest @@ -685,43 +717,46 @@ var file_tunnel_proto_goTypes = []any{ (*SecretsResponse)(nil), // 8: tunnel.SecretsResponse (*Chunk)(nil), // 9: tunnel.Chunk (*LogMessage)(nil), // 10: tunnel.LogMessage - (*Empty)(nil), // 11: tunnel.Empty + (*StatusUpdate)(nil), // 11: tunnel.StatusUpdate + (*Empty)(nil), // 12: tunnel.Empty } var file_tunnel_proto_depIdxs = []int32{ 7, // 0: tunnel.SecretsResponse.secrets:type_name -> tunnel.Secret 0, // 1: tunnel.LogMessage.logLevel:type_name -> tunnel.LogLevel - 11, // 2: tunnel.Tunnel.Ping:input_type -> tunnel.Empty + 12, // 2: tunnel.Tunnel.Ping:input_type -> tunnel.Empty 10, // 3: tunnel.Tunnel.Log:input_type -> tunnel.LogMessage - 6, // 4: tunnel.Tunnel.SendResult:input_type -> tunnel.Message - 6, // 5: tunnel.Tunnel.DockerCredentials:input_type -> tunnel.Message - 6, // 6: tunnel.Tunnel.GitCredentials:input_type -> tunnel.Message - 6, // 7: tunnel.Tunnel.GitSSHSignature:input_type -> tunnel.Message - 11, // 8: tunnel.Tunnel.GitUser:input_type -> tunnel.Empty - 6, // 9: tunnel.Tunnel.DevsyConfig:input_type -> tunnel.Message - 6, // 10: tunnel.Tunnel.GPGPublicKeys:input_type -> tunnel.Message - 6, // 11: tunnel.Tunnel.KubeConfig:input_type -> tunnel.Message - 11, // 12: tunnel.Tunnel.Secrets:input_type -> tunnel.Empty - 4, // 13: tunnel.Tunnel.ForwardPort:input_type -> tunnel.ForwardPortRequest - 2, // 14: tunnel.Tunnel.StopForwardPort:input_type -> tunnel.StopForwardPortRequest - 11, // 15: tunnel.Tunnel.StreamWorkspace:input_type -> tunnel.Empty - 1, // 16: tunnel.Tunnel.StreamMount:input_type -> tunnel.StreamMountRequest - 11, // 17: tunnel.Tunnel.Ping:output_type -> tunnel.Empty - 11, // 18: tunnel.Tunnel.Log:output_type -> tunnel.Empty - 11, // 19: tunnel.Tunnel.SendResult:output_type -> tunnel.Empty - 6, // 20: tunnel.Tunnel.DockerCredentials:output_type -> tunnel.Message - 6, // 21: tunnel.Tunnel.GitCredentials:output_type -> tunnel.Message - 6, // 22: tunnel.Tunnel.GitSSHSignature:output_type -> tunnel.Message - 6, // 23: tunnel.Tunnel.GitUser:output_type -> tunnel.Message - 6, // 24: tunnel.Tunnel.DevsyConfig:output_type -> tunnel.Message - 6, // 25: tunnel.Tunnel.GPGPublicKeys:output_type -> tunnel.Message - 6, // 26: tunnel.Tunnel.KubeConfig:output_type -> tunnel.Message - 8, // 27: tunnel.Tunnel.Secrets:output_type -> tunnel.SecretsResponse - 5, // 28: tunnel.Tunnel.ForwardPort:output_type -> tunnel.ForwardPortResponse - 3, // 29: tunnel.Tunnel.StopForwardPort:output_type -> tunnel.StopForwardPortResponse - 9, // 30: tunnel.Tunnel.StreamWorkspace:output_type -> tunnel.Chunk - 9, // 31: tunnel.Tunnel.StreamMount:output_type -> tunnel.Chunk - 17, // [17:32] is the sub-list for method output_type - 2, // [2:17] is the sub-list for method input_type + 11, // 4: tunnel.Tunnel.Status:input_type -> tunnel.StatusUpdate + 6, // 5: tunnel.Tunnel.SendResult:input_type -> tunnel.Message + 6, // 6: tunnel.Tunnel.DockerCredentials:input_type -> tunnel.Message + 6, // 7: tunnel.Tunnel.GitCredentials:input_type -> tunnel.Message + 6, // 8: tunnel.Tunnel.GitSSHSignature:input_type -> tunnel.Message + 12, // 9: tunnel.Tunnel.GitUser:input_type -> tunnel.Empty + 6, // 10: tunnel.Tunnel.DevsyConfig:input_type -> tunnel.Message + 6, // 11: tunnel.Tunnel.GPGPublicKeys:input_type -> tunnel.Message + 6, // 12: tunnel.Tunnel.KubeConfig:input_type -> tunnel.Message + 12, // 13: tunnel.Tunnel.Secrets:input_type -> tunnel.Empty + 4, // 14: tunnel.Tunnel.ForwardPort:input_type -> tunnel.ForwardPortRequest + 2, // 15: tunnel.Tunnel.StopForwardPort:input_type -> tunnel.StopForwardPortRequest + 12, // 16: tunnel.Tunnel.StreamWorkspace:input_type -> tunnel.Empty + 1, // 17: tunnel.Tunnel.StreamMount:input_type -> tunnel.StreamMountRequest + 12, // 18: tunnel.Tunnel.Ping:output_type -> tunnel.Empty + 12, // 19: tunnel.Tunnel.Log:output_type -> tunnel.Empty + 12, // 20: tunnel.Tunnel.Status:output_type -> tunnel.Empty + 12, // 21: tunnel.Tunnel.SendResult:output_type -> tunnel.Empty + 6, // 22: tunnel.Tunnel.DockerCredentials:output_type -> tunnel.Message + 6, // 23: tunnel.Tunnel.GitCredentials:output_type -> tunnel.Message + 6, // 24: tunnel.Tunnel.GitSSHSignature:output_type -> tunnel.Message + 6, // 25: tunnel.Tunnel.GitUser:output_type -> tunnel.Message + 6, // 26: tunnel.Tunnel.DevsyConfig:output_type -> tunnel.Message + 6, // 27: tunnel.Tunnel.GPGPublicKeys:output_type -> tunnel.Message + 6, // 28: tunnel.Tunnel.KubeConfig:output_type -> tunnel.Message + 8, // 29: tunnel.Tunnel.Secrets:output_type -> tunnel.SecretsResponse + 5, // 30: tunnel.Tunnel.ForwardPort:output_type -> tunnel.ForwardPortResponse + 3, // 31: tunnel.Tunnel.StopForwardPort:output_type -> tunnel.StopForwardPortResponse + 9, // 32: tunnel.Tunnel.StreamWorkspace:output_type -> tunnel.Chunk + 9, // 33: tunnel.Tunnel.StreamMount:output_type -> tunnel.Chunk + 18, // [18:34] is the sub-list for method output_type + 2, // [2:18] is the sub-list for method input_type 2, // [2:2] is the sub-list for extension type_name 2, // [2:2] is the sub-list for extension extendee 0, // [0:2] is the sub-list for field type_name @@ -738,7 +773,7 @@ func file_tunnel_proto_init() { GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_tunnel_proto_rawDesc), len(file_tunnel_proto_rawDesc)), NumEnums: 1, - NumMessages: 11, + NumMessages: 12, NumExtensions: 0, NumServices: 1, }, diff --git a/pkg/agent/tunnel/tunnel.proto b/pkg/agent/tunnel/tunnel.proto index c8442294b..ce0ef5755 100644 --- a/pkg/agent/tunnel/tunnel.proto +++ b/pkg/agent/tunnel/tunnel.proto @@ -8,6 +8,7 @@ package tunnel; service Tunnel { rpc Ping(Empty) returns (Empty) {} rpc Log(LogMessage) returns (Empty) {} + rpc Status(StatusUpdate) returns (Empty) {} rpc SendResult(Message) returns (Empty) {} rpc DockerCredentials(Message) returns (Message) {} @@ -77,6 +78,15 @@ message LogMessage { string message = 2; } +// StatusUpdate carries a structured up-pipeline phase transition, distinct +// from the freeform LogMessage stream. See docs/rfcs/async-workspace-up.md. +message StatusUpdate { + string phase = 1; + string step = 2; + bool started = 3; + string error = 4; +} + message Empty { } diff --git a/pkg/agent/tunnel/tunnel_grpc.pb.go b/pkg/agent/tunnel/tunnel_grpc.pb.go index ab5aac9e2..75f41efe5 100644 --- a/pkg/agent/tunnel/tunnel_grpc.pb.go +++ b/pkg/agent/tunnel/tunnel_grpc.pb.go @@ -2,8 +2,8 @@ // Code generated by protoc-gen-go-grpc. DO NOT EDIT. // versions: -// - protoc-gen-go-grpc v1.5.1 -// - protoc v7.34.1 +// - protoc-gen-go-grpc v1.6.2 +// - protoc v7.35.1 // source: tunnel.proto package tunnel @@ -23,6 +23,7 @@ const _ = grpc.SupportPackageIsVersion9 const ( Tunnel_Ping_FullMethodName = "/tunnel.Tunnel/Ping" Tunnel_Log_FullMethodName = "/tunnel.Tunnel/Log" + Tunnel_Status_FullMethodName = "/tunnel.Tunnel/Status" Tunnel_SendResult_FullMethodName = "/tunnel.Tunnel/SendResult" Tunnel_DockerCredentials_FullMethodName = "/tunnel.Tunnel/DockerCredentials" Tunnel_GitCredentials_FullMethodName = "/tunnel.Tunnel/GitCredentials" @@ -44,6 +45,7 @@ const ( type TunnelClient interface { Ping(ctx context.Context, in *Empty, opts ...grpc.CallOption) (*Empty, error) Log(ctx context.Context, in *LogMessage, opts ...grpc.CallOption) (*Empty, error) + Status(ctx context.Context, in *StatusUpdate, opts ...grpc.CallOption) (*Empty, error) SendResult(ctx context.Context, in *Message, opts ...grpc.CallOption) (*Empty, error) DockerCredentials(ctx context.Context, in *Message, opts ...grpc.CallOption) (*Message, error) GitCredentials(ctx context.Context, in *Message, opts ...grpc.CallOption) (*Message, error) @@ -87,6 +89,16 @@ func (c *tunnelClient) Log(ctx context.Context, in *LogMessage, opts ...grpc.Cal return out, nil } +func (c *tunnelClient) Status(ctx context.Context, in *StatusUpdate, opts ...grpc.CallOption) (*Empty, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(Empty) + err := c.cc.Invoke(ctx, Tunnel_Status_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + func (c *tunnelClient) SendResult(ctx context.Context, in *Message, opts ...grpc.CallOption) (*Empty, error) { cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) out := new(Empty) @@ -241,6 +253,7 @@ type Tunnel_StreamMountClient = grpc.ServerStreamingClient[Chunk] type TunnelServer interface { Ping(context.Context, *Empty) (*Empty, error) Log(context.Context, *LogMessage) (*Empty, error) + Status(context.Context, *StatusUpdate) (*Empty, error) SendResult(context.Context, *Message) (*Empty, error) DockerCredentials(context.Context, *Message) (*Message, error) GitCredentials(context.Context, *Message) (*Message, error) @@ -265,49 +278,52 @@ type TunnelServer interface { type UnimplementedTunnelServer struct{} func (UnimplementedTunnelServer) Ping(context.Context, *Empty) (*Empty, error) { - return nil, status.Errorf(codes.Unimplemented, "method Ping not implemented") + return nil, status.Error(codes.Unimplemented, "method Ping not implemented") } func (UnimplementedTunnelServer) Log(context.Context, *LogMessage) (*Empty, error) { - return nil, status.Errorf(codes.Unimplemented, "method Log not implemented") + return nil, status.Error(codes.Unimplemented, "method Log not implemented") +} +func (UnimplementedTunnelServer) Status(context.Context, *StatusUpdate) (*Empty, error) { + return nil, status.Error(codes.Unimplemented, "method Status not implemented") } func (UnimplementedTunnelServer) SendResult(context.Context, *Message) (*Empty, error) { - return nil, status.Errorf(codes.Unimplemented, "method SendResult not implemented") + return nil, status.Error(codes.Unimplemented, "method SendResult not implemented") } func (UnimplementedTunnelServer) DockerCredentials(context.Context, *Message) (*Message, error) { - return nil, status.Errorf(codes.Unimplemented, "method DockerCredentials not implemented") + return nil, status.Error(codes.Unimplemented, "method DockerCredentials not implemented") } func (UnimplementedTunnelServer) GitCredentials(context.Context, *Message) (*Message, error) { - return nil, status.Errorf(codes.Unimplemented, "method GitCredentials not implemented") + return nil, status.Error(codes.Unimplemented, "method GitCredentials not implemented") } func (UnimplementedTunnelServer) GitSSHSignature(context.Context, *Message) (*Message, error) { - return nil, status.Errorf(codes.Unimplemented, "method GitSSHSignature not implemented") + return nil, status.Error(codes.Unimplemented, "method GitSSHSignature not implemented") } func (UnimplementedTunnelServer) GitUser(context.Context, *Empty) (*Message, error) { - return nil, status.Errorf(codes.Unimplemented, "method GitUser not implemented") + return nil, status.Error(codes.Unimplemented, "method GitUser not implemented") } func (UnimplementedTunnelServer) DevsyConfig(context.Context, *Message) (*Message, error) { - return nil, status.Errorf(codes.Unimplemented, "method DevsyConfig not implemented") + return nil, status.Error(codes.Unimplemented, "method DevsyConfig not implemented") } func (UnimplementedTunnelServer) GPGPublicKeys(context.Context, *Message) (*Message, error) { - return nil, status.Errorf(codes.Unimplemented, "method GPGPublicKeys not implemented") + return nil, status.Error(codes.Unimplemented, "method GPGPublicKeys not implemented") } func (UnimplementedTunnelServer) KubeConfig(context.Context, *Message) (*Message, error) { - return nil, status.Errorf(codes.Unimplemented, "method KubeConfig not implemented") + return nil, status.Error(codes.Unimplemented, "method KubeConfig not implemented") } func (UnimplementedTunnelServer) Secrets(context.Context, *Empty) (*SecretsResponse, error) { - return nil, status.Errorf(codes.Unimplemented, "method Secrets not implemented") + return nil, status.Error(codes.Unimplemented, "method Secrets not implemented") } func (UnimplementedTunnelServer) ForwardPort(context.Context, *ForwardPortRequest) (*ForwardPortResponse, error) { - return nil, status.Errorf(codes.Unimplemented, "method ForwardPort not implemented") + return nil, status.Error(codes.Unimplemented, "method ForwardPort not implemented") } func (UnimplementedTunnelServer) StopForwardPort(context.Context, *StopForwardPortRequest) (*StopForwardPortResponse, error) { - return nil, status.Errorf(codes.Unimplemented, "method StopForwardPort not implemented") + return nil, status.Error(codes.Unimplemented, "method StopForwardPort not implemented") } func (UnimplementedTunnelServer) StreamWorkspace(*Empty, grpc.ServerStreamingServer[Chunk]) error { - return status.Errorf(codes.Unimplemented, "method StreamWorkspace not implemented") + return status.Error(codes.Unimplemented, "method StreamWorkspace not implemented") } func (UnimplementedTunnelServer) StreamMount(*StreamMountRequest, grpc.ServerStreamingServer[Chunk]) error { - return status.Errorf(codes.Unimplemented, "method StreamMount not implemented") + return status.Error(codes.Unimplemented, "method StreamMount not implemented") } func (UnimplementedTunnelServer) mustEmbedUnimplementedTunnelServer() {} func (UnimplementedTunnelServer) testEmbeddedByValue() {} @@ -320,7 +336,7 @@ type UnsafeTunnelServer interface { } func RegisterTunnelServer(s grpc.ServiceRegistrar, srv TunnelServer) { - // If the following call pancis, it indicates UnimplementedTunnelServer was + // If the following call panics, it indicates UnimplementedTunnelServer was // embedded by pointer and is nil. This will cause panics if an // unimplemented method is ever invoked, so we test this at initialization // time to prevent it from happening at runtime later due to I/O. @@ -366,6 +382,24 @@ func _Tunnel_Log_Handler(srv interface{}, ctx context.Context, dec func(interfac return interceptor(ctx, in, info, handler) } +func _Tunnel_Status_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(StatusUpdate) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(TunnelServer).Status(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: Tunnel_Status_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(TunnelServer).Status(ctx, req.(*StatusUpdate)) + } + return interceptor(ctx, in, info, handler) +} + func _Tunnel_SendResult_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { in := new(Message) if err := dec(in); err != nil { @@ -601,6 +635,10 @@ var Tunnel_ServiceDesc = grpc.ServiceDesc{ MethodName: "Log", Handler: _Tunnel_Log_Handler, }, + { + MethodName: "Status", + Handler: _Tunnel_Status_Handler, + }, { MethodName: "SendResult", Handler: _Tunnel_SendResult_Handler, diff --git a/pkg/agent/tunnelserver/options.go b/pkg/agent/tunnelserver/options.go index e3ce13527..8e1ce8760 100644 --- a/pkg/agent/tunnelserver/options.go +++ b/pkg/agent/tunnelserver/options.go @@ -6,6 +6,7 @@ import ( "github.com/devsy-org/api/pkg/devsy" "github.com/devsy-org/devsy/pkg/agent/tunnel" "github.com/devsy-org/devsy/pkg/devcontainer/config" + "github.com/devsy-org/devsy/pkg/devcontainer/status" "github.com/devsy-org/devsy/pkg/netstat" provider2 "github.com/devsy-org/devsy/pkg/provider" ) @@ -75,6 +76,14 @@ func WithGitToken(token *provider2.GitToken) Option { } } +// WithStatusReporter forwards inbound StatusUpdate RPCs to reporter. +func WithStatusReporter(reporter status.Reporter) Option { + return func(s *tunnelServer) *tunnelServer { + s.statusReporter = reporter + return s + } +} + func toSecrets(entries []string, mount bool) []*tunnel.Secret { secrets := make([]*tunnel.Secret, 0, len(entries)) for _, entry := range entries { diff --git a/pkg/agent/tunnelserver/status_sender.go b/pkg/agent/tunnelserver/status_sender.go new file mode 100644 index 000000000..acf911034 --- /dev/null +++ b/pkg/agent/tunnelserver/status_sender.go @@ -0,0 +1,53 @@ +package tunnelserver + +import ( + "context" + "time" + + "github.com/devsy-org/devsy/pkg/agent/tunnel" + "github.com/devsy-org/devsy/pkg/devcontainer/status" +) + +// NewTunnelStatusReporter returns a status.Reporter that forwards each event +// to client over the Status RPC, mirroring NewTunnelLogger's buffered-worker +// pattern so a slow/unavailable peer cannot stall the up pipeline. +func NewTunnelStatusReporter(ctx context.Context, client tunnel.TunnelClient) status.Reporter { + r := &tunnelStatusReporter{ + ctx: ctx, + client: client, + events: make(chan *tunnel.StatusUpdate, 1000), + } + go r.worker() + return r +} + +type tunnelStatusReporter struct { + ctx context.Context + client tunnel.TunnelClient + events chan *tunnel.StatusUpdate +} + +func (r *tunnelStatusReporter) worker() { + for { + select { + case update := <-r.events: + ctx, cancel := context.WithTimeout(r.ctx, 5*time.Second) + _, _ = r.client.Status(ctx, update) + cancel() + case <-r.ctx.Done(): + return + } + } +} + +func (r *tunnelStatusReporter) Report(e status.Event) { + select { + case r.events <- &tunnel.StatusUpdate{ + Phase: string(e.Phase), + Step: e.Step, + Started: e.Started, + Error: e.Err, + }: + case <-r.ctx.Done(): + } +} diff --git a/pkg/agent/tunnelserver/tunnelserver.go b/pkg/agent/tunnelserver/tunnelserver.go index ab0ef9ab1..2fc9ac7cd 100644 --- a/pkg/agent/tunnelserver/tunnelserver.go +++ b/pkg/agent/tunnelserver/tunnelserver.go @@ -16,6 +16,7 @@ import ( "github.com/devsy-org/devsy/pkg/agent/tunnel" pkgconfig "github.com/devsy-org/devsy/pkg/config" "github.com/devsy-org/devsy/pkg/devcontainer/config" + "github.com/devsy-org/devsy/pkg/devcontainer/status" "github.com/devsy-org/devsy/pkg/devsyconfig" "github.com/devsy-org/devsy/pkg/dockercredentials" "github.com/devsy-org/devsy/pkg/extract" @@ -91,7 +92,7 @@ func RunSetupServer( } func New(options ...Option) *tunnelServer { - s := &tunnelServer{} + s := &tunnelServer{statusReporter: status.Nop()} for _, o := range options { s = o(s) } @@ -116,6 +117,8 @@ type tunnelServer struct { platformOptions *devsy.PlatformOptions secrets []*tunnel.Secret gitToken *provider2.GitToken + + statusReporter status.Reporter } func (t *tunnelServer) RunWithResult( @@ -429,6 +432,19 @@ func (t *tunnelServer) Log(ctx context.Context, message *tunnel.LogMessage) (*tu return &tunnel.Empty{}, nil } +func (t *tunnelServer) Status( + ctx context.Context, + update *tunnel.StatusUpdate, +) (*tunnel.Empty, error) { + t.statusReporter.Report(status.Event{ + Phase: status.Phase(update.Phase), + Step: update.Step, + Started: update.Started, + Err: update.Error, + }) + return &tunnel.Empty{}, nil +} + func (t *tunnelServer) StreamWorkspace( message *tunnel.Empty, stream tunnel.Tunnel_StreamWorkspaceServer, diff --git a/pkg/client/client.go b/pkg/client/client.go index 55fdc8ce1..aafaa7e9a 100644 --- a/pkg/client/client.go +++ b/pkg/client/client.go @@ -8,6 +8,7 @@ import ( "github.com/devsy-org/api/pkg/devsy" "github.com/devsy-org/devsy/pkg/devcontainer/config" + "github.com/devsy-org/devsy/pkg/devcontainer/status" "github.com/devsy-org/devsy/pkg/provider" "golang.org/x/crypto/ssh" ) @@ -166,6 +167,10 @@ type UpOptions struct { Stdin io.Reader Stdout io.Writer + + // Reporter receives up-pipeline phase events when the implementation + // can observe them. May be nil. + Reporter status.Reporter } type SshOptions struct { @@ -182,6 +187,10 @@ const ( StatusBusy = "Busy" StatusStopped = "Stopped" StatusNotFound = "NotFound" + // No container yet, but a pkg/task `up --detach` task is building one. + StatusProvisioning = "Provisioning" + // No container, and the most recent `up --detach` task errored. + StatusFailed = "Failed" ) func ParseStatus(in string) (Status, error) { @@ -195,11 +204,18 @@ func ParseStatus(in string) (Status, error) { return StatusStopped, nil case "NOTFOUND": return StatusNotFound, nil + case "PROVISIONING": + return StatusProvisioning, nil + case "FAILED": + return StatusFailed, nil default: return StatusNotFound, fmt.Errorf( "error parsing status: %q unrecognized status, needs to be one of: %v", in, - []string{StatusRunning, StatusBusy, StatusStopped, StatusNotFound}, + []string{ + StatusRunning, StatusBusy, StatusStopped, StatusNotFound, + StatusProvisioning, StatusFailed, + }, ) } } diff --git a/pkg/client/clientimplementation/daemonclient/stop.go b/pkg/client/clientimplementation/daemonclient/stop.go index 595705bf7..c656184e7 100644 --- a/pkg/client/clientimplementation/daemonclient/stop.go +++ b/pkg/client/clientimplementation/daemonclient/stop.go @@ -7,6 +7,7 @@ import ( managementv1 "github.com/devsy-org/api/pkg/apis/management/v1" clientpkg "github.com/devsy-org/devsy/pkg/client" + "github.com/devsy-org/devsy/pkg/devcontainer/status" "github.com/devsy-org/devsy/pkg/platform" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) @@ -47,7 +48,7 @@ func (c *client) Stop(ctx context.Context, opt clientpkg.StopOptions) error { return fmt.Errorf("no stop task id returned from server") } - _, err = observeTask(ctx, managementClient, workspace, retStop.Status.TaskID) + _, err = observeTask(ctx, managementClient, workspace, retStop.Status.TaskID, status.Nop()) if err != nil { return fmt.Errorf("stop: %w", err) } diff --git a/pkg/client/clientimplementation/daemonclient/up.go b/pkg/client/clientimplementation/daemonclient/up.go index 0545a1710..3fe8cdbb8 100644 --- a/pkg/client/clientimplementation/daemonclient/up.go +++ b/pkg/client/clientimplementation/daemonclient/up.go @@ -2,6 +2,7 @@ package daemonclient import ( "bufio" + "bytes" "context" "encoding/json" "errors" @@ -17,6 +18,7 @@ import ( clientpkg "github.com/devsy-org/devsy/pkg/client" devsyconfig "github.com/devsy-org/devsy/pkg/config" "github.com/devsy-org/devsy/pkg/devcontainer/config" + "github.com/devsy-org/devsy/pkg/devcontainer/status" "github.com/devsy-org/devsy/pkg/log" "github.com/devsy-org/devsy/pkg/platform" platformclient "github.com/devsy-org/devsy/pkg/platform/client" @@ -65,7 +67,7 @@ func (c *client) Up(ctx context.Context, opt clientpkg.UpOptions) (*config.Resul return nil, err } - return waitTaskDone(ctx, managementClient, instance, taskID) + return waitTaskDone(ctx, managementClient, instance, taskID, reporterOrNop(opt.Reporter)) } func migratedRebuildError( @@ -152,6 +154,13 @@ func startUpTask( return managementClient, taskID, nil } +func reporterOrNop(r status.Reporter) status.Reporter { + if r == nil { + return status.Nop() + } + return r +} + type createUpTaskParams struct { managementClient kube.Interface instance *managementv1.DevsyWorkspaceInstance @@ -208,8 +217,9 @@ func waitTaskDone( managementClient kube.Interface, instance *managementv1.DevsyWorkspaceInstance, taskID string, + reporter status.Reporter, ) (*config.Result, error) { - exitCode, err := observeTask(ctx, managementClient, instance, taskID) + exitCode, err := observeTask(ctx, managementClient, instance, taskID, reporter) if err != nil { return nil, fmt.Errorf("up: %w", err) } else if exitCode != 0 { @@ -284,6 +294,7 @@ func observeTask( managementClient kube.Interface, instance *managementv1.DevsyWorkspaceInstance, taskID string, + reporter status.Reporter, ) (int, error) { var ( exitCode int @@ -318,7 +329,7 @@ func observeTask( } }() go func() { - exitCode, err = printLogs(printCtx, managementClient, instance, taskID) + exitCode, err = printLogs(printCtx, managementClient, instance, taskID, reporter) errChan <- err }() @@ -344,6 +355,7 @@ func printLogs( managementClient kube.Interface, workspace *managementv1.DevsyWorkspaceInstance, taskID string, + reporter status.Reporter, ) (int, error) { // get logs reader log.Debugf("printing logs of task: %s", taskID) @@ -384,6 +396,12 @@ func printLogs( <-stderrDone }() + // The remote task runs the same devsy CLI, so its stdout carries the + // same NDJSON status lines a local `up` does; sniff them out here. + statusWriter := newStatusSniffingWriter(stdoutStreamer, reporter) + defer statusWriter.Close() + stdout := io.Writer(statusWriter) + // loop over all lines for scanner.Scan() { line := scanner.Text() @@ -394,7 +412,7 @@ func printLogs( return -1, fmt.Errorf("error parsing JSON from logs reader: %w, line: %s", err, line) } - exitCode, done, err := writeMessage(stdoutStreamer, stderrStreamer, message) + exitCode, done, err := writeMessage(stdout, stderrStreamer, message) if done { return exitCode, err } @@ -429,6 +447,49 @@ func writeMessage(stdout, stderr io.Writer, message *Message) (int, bool, error) return 0, false, nil } +// statusSniffingWriter splits a byte stream into lines, forwards each +// status NDJSON envelope line to reporter, and passes every other line +// through to next unchanged. +type statusSniffingWriter struct { + next io.Writer + reporter status.Reporter + buf bytes.Buffer +} + +func newStatusSniffingWriter(next io.Writer, reporter status.Reporter) *statusSniffingWriter { + return &statusSniffingWriter{next: next, reporter: reporter} +} + +func (w *statusSniffingWriter) Write(p []byte) (int, error) { + w.buf.Write(p) + for { + line, err := w.buf.ReadString('\n') + if err != nil { + // Incomplete line: put it back for the next Write/Close. + w.buf.Reset() + w.buf.WriteString(line) + break + } + if event, ok := config.ParseStatusLine(line); ok { + w.reporter.Report(event) + continue + } + if _, err := w.next.Write([]byte(line)); err != nil { + return len(p), err + } + } + return len(p), nil +} + +func (w *statusSniffingWriter) Close() error { + if w.buf.Len() == 0 { + return nil + } + _, err := w.next.Write(w.buf.Bytes()) + w.buf.Reset() + return err +} + const ( TaskStatusRunning = "Running" TaskStatusSucceed = "Succeeded" diff --git a/pkg/client/clientimplementation/daemonclient/up_test.go b/pkg/client/clientimplementation/daemonclient/up_test.go new file mode 100644 index 000000000..21b35cfe4 --- /dev/null +++ b/pkg/client/clientimplementation/daemonclient/up_test.go @@ -0,0 +1,106 @@ +package daemonclient + +import ( + "bytes" + "testing" + + "github.com/devsy-org/devsy/pkg/devcontainer/status" +) + +type recordingReporter struct { + events []status.Event +} + +func (r *recordingReporter) Report(e status.Event) { + r.events = append(r.events, e) +} + +func TestStatusSniffingWriter_ForwardsPlainLogLines(t *testing.T) { + var next bytes.Buffer + reporter := &recordingReporter{} + w := newStatusSniffingWriter(&next, reporter) + + _, err := w.Write([]byte("hello\nworld\n")) + if err != nil { + t.Fatalf("write: %v", err) + } + if err := w.Close(); err != nil { + t.Fatalf("close: %v", err) + } + + if got := next.String(); got != "hello\nworld\n" { + t.Errorf("next = %q, want %q", got, "hello\nworld\n") + } + if len(reporter.events) != 0 { + t.Errorf("expected no status events, got %d", len(reporter.events)) + } +} + +func TestStatusSniffingWriter_ExtractsStatusLines(t *testing.T) { + var next bytes.Buffer + reporter := &recordingReporter{} + w := newStatusSniffingWriter(&next, reporter) + + input := `before +{"kind":"status","phase":"building_image","started":true} +after +` + if _, err := w.Write([]byte(input)); err != nil { + t.Fatalf("write: %v", err) + } + if err := w.Close(); err != nil { + t.Fatalf("close: %v", err) + } + + if got := next.String(); got != "before\nafter\n" { + t.Errorf("next = %q, want %q", got, "before\nafter\n") + } + if len(reporter.events) != 1 { + t.Fatalf("expected 1 status event, got %d", len(reporter.events)) + } + e := reporter.events[0] + if e.Phase != status.PhaseBuildingImage || !e.Started { + t.Errorf("unexpected event: %+v", e) + } +} + +func TestStatusSniffingWriter_FlushesPartialLineOnClose(t *testing.T) { + var next bytes.Buffer + w := newStatusSniffingWriter(&next, &recordingReporter{}) + + if _, err := w.Write([]byte("no newline yet")); err != nil { + t.Fatalf("write: %v", err) + } + if next.Len() != 0 { + t.Errorf("expected nothing forwarded before close, got %q", next.String()) + } + if err := w.Close(); err != nil { + t.Fatalf("close: %v", err) + } + if got := next.String(); got != "no newline yet" { + t.Errorf("next = %q, want %q", got, "no newline yet") + } +} + +func TestStatusSniffingWriter_SplitAcrossWrites(t *testing.T) { + var next bytes.Buffer + reporter := &recordingReporter{} + w := newStatusSniffingWriter(&next, reporter) + + if _, err := w.Write([]byte(`{"kind":"status","phase":"read`)); err != nil { + t.Fatalf("write: %v", err) + } + if _, err := w.Write([]byte(`y","started":false}` + "\n")); err != nil { + t.Fatalf("write: %v", err) + } + if err := w.Close(); err != nil { + t.Fatalf("close: %v", err) + } + + if next.Len() != 0 { + t.Errorf("expected nothing forwarded, got %q", next.String()) + } + if len(reporter.events) != 1 || reporter.events[0].Phase != status.PhaseReady { + t.Errorf("unexpected events: %+v", reporter.events) + } +} diff --git a/pkg/client/clientimplementation/workspace_client.go b/pkg/client/clientimplementation/workspace_client.go index 273a3d1c1..df96c8862 100644 --- a/pkg/client/clientimplementation/workspace_client.go +++ b/pkg/client/clientimplementation/workspace_client.go @@ -19,11 +19,13 @@ import ( "github.com/devsy-org/devsy/pkg/compress" "github.com/devsy-org/devsy/pkg/config" config2 "github.com/devsy-org/devsy/pkg/devcontainer/config" + "github.com/devsy-org/devsy/pkg/devcontainer/status" "github.com/devsy-org/devsy/pkg/log" "github.com/devsy-org/devsy/pkg/options" "github.com/devsy-org/devsy/pkg/provider" "github.com/devsy-org/devsy/pkg/shell" "github.com/devsy-org/devsy/pkg/ssh" + "github.com/devsy-org/devsy/pkg/task" "github.com/devsy-org/devsy/pkg/types" "github.com/gofrs/flock" ) @@ -290,15 +292,64 @@ func (s *workspaceClient) Status( s.m.Lock() defer s.m.Unlock() - if s.isMachineProvider() && len(s.config.Exec.Status) > 0 { - return s.machineStatus(ctx, opt) + var ( + result client.Status + err error + ) + switch { + case s.isMachineProvider() && len(s.config.Exec.Status) > 0: + result, err = s.machineStatus(ctx, opt) + case opt.ContainerStatus: + result, err = s.getContainerStatus(ctx) + default: + result, err = s.workspaceFolderStatus() + } + if err != nil || result != client.StatusNotFound { + return result, err } - if opt.ContainerStatus { - return s.getContainerStatus(ctx) + if override, ok := s.taskStatusOverride(); ok { + return override, nil + } + return result, nil +} + +// taskStatusOverride reports Provisioning or Failed based on the most +// recently started `up` task for this workspace, when more informative than +// a bare "no container". A succeeded task implies nothing: if the container +// is still missing after that, something else removed it. Best-effort: any +// error reading task state reports ok=false rather than failing Status. +func (s *workspaceClient) taskStatusOverride() (client.Status, bool) { + store, err := task.NewStore() + if err != nil { + return "", false + } + states, err := store.List() + if err != nil { + return "", false } - return s.workspaceFolderStatus() + var latest *task.State + for _, st := range states { + if st.WorkspaceID != s.workspace.ID || st.Command != "up" { + continue + } + if latest == nil || st.StartedAt.After(latest.StartedAt) { + latest = st + } + } + if latest == nil { + return "", false + } + + switch { + case !latest.Status.Terminal(): + return client.StatusProvisioning, true + case latest.Status == task.StatusFailed: + return client.StatusFailed, true + default: + return "", false + } } func (s *workspaceClient) Describe(ctx context.Context) (string, error) { @@ -1100,7 +1151,8 @@ func runTunnelServer( opts.WorkspaceClient.AgentInjectDockerCredentials(opts.CLIOptions), opts.WorkspaceClient.WorkspaceConfig(), append(opts.TunnelOptions, - tunnelserver.WithGitToken(opts.CLIOptions.GitToken))..., + tunnelserver.WithGitToken(opts.CLIOptions.GitToken), + tunnelserver.WithStatusReporter(status.NewLogReporter()))..., ) if err != nil { return nil, fmt.Errorf("run tunnel server: %w", err) diff --git a/pkg/client/clientimplementation/workspace_client_status_test.go b/pkg/client/clientimplementation/workspace_client_status_test.go new file mode 100644 index 000000000..3395bb1a7 --- /dev/null +++ b/pkg/client/clientimplementation/workspace_client_status_test.go @@ -0,0 +1,124 @@ +package clientimplementation + +import ( + "errors" + "testing" + "time" + + "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" +) + +// fakeTaskDirPathManager overrides only TaskDir so tests can point pkg/task +// at a temp directory without touching the real state dir. +type fakeTaskDirPathManager struct { + config.PathManager + dir string +} + +func (f fakeTaskDirPathManager) TaskDir() (string, error) { return f.dir, nil } + +func useTempTaskDir(t *testing.T) { + t.Helper() + config.SetPathManager(fakeTaskDirPathManager{ + PathManager: config.NewPathManager(), + dir: t.TempDir(), + }) + t.Cleanup(config.ResetPathManager) +} + +func assertOverride(t *testing.T, s *workspaceClient, wantOK bool, wantStatus client.Status) { + t.Helper() + got, ok := s.taskStatusOverride() + assert.Equal(t, wantOK, ok) + if wantOK { + assert.Equal(t, wantStatus, got) + } +} + +func TestTaskStatusOverride_NoTasks(t *testing.T) { + useTempTaskDir(t) + s := &workspaceClient{workspace: &provider.Workspace{ID: "my-ws"}} + assertOverride(t, s, false, "") +} + +func TestTaskStatusOverride_ActiveUpTaskReportsProvisioning(t *testing.T) { + useTempTaskDir(t) + store, err := task.NewStore() + require.NoError(t, err) + _, err = store.Create(task.CreateOptions{Command: "up", WorkspaceID: "my-ws"}) + require.NoError(t, err) + + s := &workspaceClient{workspace: &provider.Workspace{ID: "my-ws"}} + assertOverride(t, s, true, client.StatusProvisioning) +} + +func TestTaskStatusOverride_FailedTaskReportsFailed(t *testing.T) { + useTempTaskDir(t) + store, err := task.NewStore() + require.NoError(t, err) + tsk, err := store.Create(task.CreateOptions{Command: "up", WorkspaceID: "my-ws"}) + require.NoError(t, err) + require.NoError(t, tsk.Fail(errors.New("build failed"))) + + s := &workspaceClient{workspace: &provider.Workspace{ID: "my-ws"}} + assertOverride(t, s, true, client.StatusFailed) +} + +func TestTaskStatusOverride_SucceededTaskDefersToContainerStatus(t *testing.T) { + useTempTaskDir(t) + store, err := task.NewStore() + require.NoError(t, err) + tsk, err := store.Create(task.CreateOptions{Command: "up", WorkspaceID: "my-ws"}) + require.NoError(t, err) + require.NoError(t, tsk.Succeed(nil)) + + s := &workspaceClient{workspace: &provider.Workspace{ID: "my-ws"}} + assertOverride(t, s, false, "") +} + +func TestTaskStatusOverride_IgnoresOtherWorkspaces(t *testing.T) { + useTempTaskDir(t) + store, err := task.NewStore() + require.NoError(t, err) + _, err = store.Create(task.CreateOptions{Command: "up", WorkspaceID: "other-ws"}) + require.NoError(t, err) + + s := &workspaceClient{workspace: &provider.Workspace{ID: "my-ws"}} + assertOverride(t, s, false, "") +} + +func TestTaskStatusOverride_IgnoresNonUpCommands(t *testing.T) { + useTempTaskDir(t) + store, err := task.NewStore() + require.NoError(t, err) + _, err = store.Create(task.CreateOptions{Command: "delete", WorkspaceID: "my-ws"}) + require.NoError(t, err) + + s := &workspaceClient{workspace: &provider.Workspace{ID: "my-ws"}} + assertOverride(t, s, false, "") +} + +func TestTaskStatusOverride_MostRecentTaskWins(t *testing.T) { + useTempTaskDir(t) + store, err := task.NewStore() + require.NoError(t, err) + + older, err := store.Create(task.CreateOptions{Command: "up", WorkspaceID: "my-ws"}) + require.NoError(t, err) + require.NoError(t, older.Fail(errors.New("first attempt failed"))) + + // Real wall-clock gap so the second task is unambiguously "most recent". + time.Sleep(5 * time.Millisecond) + _, err = store.Create(task.CreateOptions{Command: "up", WorkspaceID: "my-ws"}) + require.NoError(t, err) + + s := &workspaceClient{workspace: &provider.Workspace{ID: "my-ws"}} + // The second (later) task is still in flight, so it wins over the + // earlier failure. + assertOverride(t, s, true, client.StatusProvisioning) +} diff --git a/pkg/command/process_supported.go b/pkg/command/process_supported.go index d35668b7f..0736cbf14 100644 --- a/pkg/command/process_supported.go +++ b/pkg/command/process_supported.go @@ -3,6 +3,7 @@ package command import ( + "errors" "os" "strconv" "syscall" @@ -34,8 +35,15 @@ func kill(pid string) error { return err } - _ = syscall.Kill(parsedPid, syscall.SIGTERM) + if err := syscall.Kill(parsedPid, syscall.SIGTERM); err != nil { + if errors.Is(err, syscall.ESRCH) { + return nil // already exited + } + return err + } time.Sleep(2 * time.Second) - _ = syscall.Kill(parsedPid, syscall.SIGKILL) + if err := syscall.Kill(parsedPid, syscall.SIGKILL); err != nil && !errors.Is(err, syscall.ESRCH) { + return err + } return nil } diff --git a/pkg/command/process_test.go b/pkg/command/process_test.go new file mode 100644 index 000000000..8e53bfccc --- /dev/null +++ b/pkg/command/process_test.go @@ -0,0 +1,49 @@ +package command + +import ( + "os/exec" + "strconv" + "testing" + "time" +) + +func TestKillTerminatesRunningProcess(t *testing.T) { + cmd := exec.Command("sleep", "30") + if err := cmd.Start(); err != nil { + t.Fatalf("start: %v", err) + } + pid := strconv.Itoa(cmd.Process.Pid) + + if err := Kill(pid); err != nil { + t.Fatalf("Kill: %v", err) + } + + done := make(chan struct{}) + go func() { + _ = cmd.Wait() + close(done) + }() + select { + case <-done: + case <-time.After(5 * time.Second): + t.Error("process still running after Kill") + } +} + +func TestKillOnAlreadyExitedProcessIsNoop(t *testing.T) { + cmd := exec.Command("true") + if err := cmd.Run(); err != nil { + t.Fatalf("run: %v", err) + } + pid := strconv.Itoa(cmd.Process.Pid) + + if err := Kill(pid); err != nil { + t.Errorf("Kill on exited process = %v, want nil", err) + } +} + +func TestKillInvalidPIDReturnsError(t *testing.T) { + if err := Kill("not-a-pid"); err == nil { + t.Error("Kill(\"not-a-pid\") = nil, want error") + } +} diff --git a/pkg/compose/helper_test.go b/pkg/compose/helper_test.go index e054e42a8..df365a07f 100644 --- a/pkg/compose/helper_test.go +++ b/pkg/compose/helper_test.go @@ -2,6 +2,7 @@ package compose import ( "context" + "os/exec" "strings" "testing" @@ -183,6 +184,17 @@ func (r stubRuntime) GPUAvailable(_ context.Context, _ *docker.DockerHelper) (bo } func (s *HelperTestSuite) TestNewComposeHelperPodmanRuntimeUsesDockerCommand() { + // NewComposeHelper's podman detector shells out to a live `podman + // compose`, which needs a running podman machine/socket (e.g. on macOS, + // `podman machine start`) even when the podman binary is installed. CI + // runners (Linux) run podman natively and don't hit this; a local dev + // machine without a started VM would otherwise see this test silently + // fall through to a different compose backend and fail on the assertion + // below rather than skip. + if exec.Command("podman", "compose", "version").Run() != nil { + s.T().Skip("podman compose not reachable in test environment (is podman machine running?)") + } + helper := &docker.DockerHelper{ DockerCommand: "podman", Runtime: stubRuntime{name: docker.RuntimePodman}, diff --git a/pkg/config/pathmanager.go b/pkg/config/pathmanager.go index 1301bab4a..28be99348 100644 --- a/pkg/config/pathmanager.go +++ b/pkg/config/pathmanager.go @@ -88,6 +88,7 @@ type PathManager interface { // State sub-paths. LogDir() (string, error) + TaskDir() (string, error) } // basePathManager implements every sub-path method by delegating the top-level @@ -384,6 +385,16 @@ func (b *basePathManager) LogDir() (string, error) { return filepath.Join(dir, "logs"), nil } +// TaskDir holds pkg/task's state files for detached background tasks. +func (b *basePathManager) TaskDir() (string, error) { + dir, err := b.pm.StateDir() + if err != nil { + return "", err + } + + return filepath.Join(dir, "tasks"), nil +} + // --- Singleton management --- var ( diff --git a/pkg/devcontainer/build.go b/pkg/devcontainer/build.go index 76989151e..0b5ac36cf 100644 --- a/pkg/devcontainer/build.go +++ b/pkg/devcontainer/build.go @@ -14,6 +14,7 @@ import ( "github.com/devsy-org/devsy/pkg/devcontainer/config" "github.com/devsy-org/devsy/pkg/devcontainer/feature" "github.com/devsy-org/devsy/pkg/devcontainer/metadata" + "github.com/devsy-org/devsy/pkg/devcontainer/status" "github.com/devsy-org/devsy/pkg/dockerfile" "github.com/devsy-org/devsy/pkg/driver" "github.com/devsy-org/devsy/pkg/image" @@ -460,7 +461,14 @@ func (r *runner) buildImage( } } - return r.executeBuild(ctx, params, prebuildHash, targetArch) + status.Enter(r.reporter, status.PhaseBuildingImage, "") + buildInfo, err := r.executeBuild(ctx, params, prebuildHash, targetArch) + if err != nil { + status.Fail(r.reporter, status.PhaseBuildingImage, err) + return nil, err + } + status.Leave(r.reporter, status.PhaseBuildingImage, "") + return buildInfo, nil } // executeBuild dispatches the actual image build to the appropriate backend: diff --git a/pkg/devcontainer/config/envelope.go b/pkg/devcontainer/config/envelope.go index ac604da7c..cce5dde6c 100644 --- a/pkg/devcontainer/config/envelope.go +++ b/pkg/devcontainer/config/envelope.go @@ -4,9 +4,23 @@ import ( "encoding/json" "fmt" "io" + "strings" + + "github.com/devsy-org/devsy/pkg/devcontainer/status" +) + +// Kind discriminates the NDJSON lines `up --result-format json` writes to +// stdout: a stream of "status" lines as phases complete, terminated by +// exactly one "result" or "error" line. +const ( + KindStatus = "status" + KindResult = "result" + KindError = "error" + KindTask = "task" ) type ResultEnvelope struct { + Kind string `json:"kind"` Outcome string `json:"outcome"` ContainerID string `json:"containerId"` RemoteUser string `json:"remoteUser"` @@ -17,14 +31,44 @@ type ResultEnvelope struct { } type ErrorEnvelope struct { + Kind string `json:"kind"` Outcome string `json:"outcome"` Message string `json:"message"` } +// StatusEnvelope is one NDJSON line reporting a phase transition of the up +// pipeline as it happens, ahead of the terminal ResultEnvelope/ErrorEnvelope. +type StatusEnvelope struct { + Kind string `json:"kind"` + Phase string `json:"phase"` + Step string `json:"step,omitempty"` + Started bool `json:"started"` + Error string `json:"error,omitempty"` +} + +// TaskEnvelope is the single line `up --detach` writes to stdout: the ID of +// the background task it submitted, so the caller can poll it later (e.g. +// `workspace task `) instead of waiting for it to finish. +type TaskEnvelope struct { + Kind string `json:"kind"` + ID string `json:"id"` +} + +// WriteTaskJSON serializes a submitted task's ID as an NDJSON line to w. +func WriteTaskJSON(w io.Writer, id string) error { + data, err := json.Marshal(TaskEnvelope{Kind: KindTask, ID: id}) + if err != nil { + return err + } + _, err = fmt.Fprintf(w, "%s\n", data) + return err +} + // WriteResultJSON serializes env as a success envelope to w. The caller // supplies the envelope fields; this function stamps Outcome="success" and // appends a trailing newline. func WriteResultJSON(w io.Writer, env ResultEnvelope) error { + env.Kind = KindResult env.Outcome = "success" data, err := json.Marshal(env) if err != nil { @@ -36,6 +80,7 @@ func WriteResultJSON(w io.Writer, env ResultEnvelope) error { func WriteErrorJSON(w io.Writer, msg string) error { env := ErrorEnvelope{ + Kind: KindError, Outcome: "error", Message: msg, } @@ -46,3 +91,40 @@ func WriteErrorJSON(w io.Writer, msg string) error { _, err = fmt.Fprintf(w, "%s\n", data) return err } + +// ParseStatusLine parses line as a status NDJSON envelope (see +// WriteStatusJSON), returning ok=false for anything else — plain log text, +// a zap record, or a terminal result/error envelope. +func ParseStatusLine(line string) (status.Event, bool) { + trimmed := strings.TrimSpace(line) + if !strings.HasPrefix(trimmed, "{") { + return status.Event{}, false + } + var env StatusEnvelope + if err := json.Unmarshal([]byte(trimmed), &env); err != nil || env.Kind != KindStatus { + return status.Event{}, false + } + return status.Event{ + Phase: status.Phase(env.Phase), + Step: env.Step, + Started: env.Started, + Err: env.Error, + }, true +} + +// WriteStatusJSON serializes a status.Event as an NDJSON status line to w. +func WriteStatusJSON(w io.Writer, e status.Event) error { + env := StatusEnvelope{ + Kind: KindStatus, + Phase: string(e.Phase), + Step: e.Step, + Started: e.Started, + Error: e.Err, + } + data, err := json.Marshal(env) + if err != nil { + return err + } + _, err = fmt.Fprintf(w, "%s\n", data) + return err +} diff --git a/pkg/devcontainer/feature/extend.go b/pkg/devcontainer/feature/extend.go index 3b6aa08d2..69084dee5 100644 --- a/pkg/devcontainer/feature/extend.go +++ b/pkg/devcontainer/feature/extend.go @@ -1,6 +1,7 @@ package feature import ( + "context" "fmt" "os" "path" @@ -15,6 +16,7 @@ import ( "github.com/devsy-org/devsy/pkg/devcontainer/metadata" "github.com/devsy-org/devsy/pkg/log" "github.com/google/go-containerregistry/pkg/name" + "golang.org/x/sync/errgroup" ) var ( @@ -368,18 +370,44 @@ func prepareLock( return lock, nil } +// getUserFeatures resolves every feature the user configured directly. +// Each resolution can involve a network pull (OCI) or tarball download, so +// they run concurrently rather than one at a time; featureProcessor and +// lockfileState are safe for concurrent use (see their doc comments). func getUserFeatures( processor *featureProcessor, devContainerConfig *config.DevContainerConfig, ) (map[string]*config.FeatureSet, error) { - userFeatures := map[string]*config.FeatureSet{} + type resolved struct { + key string + featureSet *config.FeatureSet + } + + g, _ := errgroup.WithContext(context.Background()) + results := make([]resolved, len(devContainerConfig.Features)) + i := 0 for featureID, featureOptions := range devContainerConfig.Features { - featureSet, err := processor.processFeature(featureID, featureOptions) - if err != nil { - return nil, fmt.Errorf("process feature %s: %w", featureID, err) - } - key := featureDeduplicationKey(featureSet.ConfigID, featureSet.Version) - userFeatures[key] = featureSet + idx := i + i++ + g.Go(func() error { + featureSet, err := processor.processFeature(featureID, featureOptions) + if err != nil { + return fmt.Errorf("process feature %s: %w", featureID, err) + } + results[idx] = resolved{ + key: featureDeduplicationKey(featureSet.ConfigID, featureSet.Version), + featureSet: featureSet, + } + return nil + }) + } + if err := g.Wait(); err != nil { + return nil, err + } + + userFeatures := make(map[string]*config.FeatureSet, len(results)) + for _, r := range results { + userFeatures[r.key] = r.featureSet } return userFeatures, nil } diff --git a/pkg/devcontainer/feature/lockfile.go b/pkg/devcontainer/feature/lockfile.go index 61483daf6..e60d888ee 100644 --- a/pkg/devcontainer/feature/lockfile.go +++ b/pkg/devcontainer/feature/lockfile.go @@ -8,6 +8,7 @@ import ( "os" "path/filepath" "strings" + "sync" "github.com/devsy-org/devsy/pkg/devcontainer/config" "github.com/devsy-org/devsy/pkg/log" @@ -150,9 +151,13 @@ type lockfileMode struct { } // lockfileState carries the lockfile loaded for pinning and collects the -// entries resolved during a fetch so they can be written afterwards. +// entries resolved during a fetch so they can be written afterwards. record +// is called concurrently when features are fetched in parallel, so writes to +// entries are guarded by mu; loaded is populated once before any fetch starts +// and only read afterward, so it needs no locking. type lockfileState struct { loaded *Lockfile + mu sync.Mutex entries map[string]LockedFeature } @@ -169,12 +174,15 @@ func (l *lockfileState) pin(featureID string) (resolved, integrity string, ok bo return entry.Resolved, entry.Integrity, true } -// record stores the resolved entry for a feature identifier. +// record stores the resolved entry for a feature identifier. Safe to call +// concurrently from multiple feature fetches. func (l *lockfileState) record(featureID string, entry LockedFeature) { if l == nil { return } + l.mu.Lock() l.entries[featureID] = entry + l.mu.Unlock() } // newLockfileState loads the lockfile for the given config to enable pinning. diff --git a/pkg/devcontainer/run.go b/pkg/devcontainer/run.go index ea678cb35..2b8ad4543 100644 --- a/pkg/devcontainer/run.go +++ b/pkg/devcontainer/run.go @@ -8,6 +8,7 @@ import ( "github.com/devsy-org/devsy/pkg/clierr" "github.com/devsy-org/devsy/pkg/devcontainer/config" + "github.com/devsy-org/devsy/pkg/devcontainer/status" "github.com/devsy-org/devsy/pkg/driver" "github.com/devsy-org/devsy/pkg/driver/drivercreate" "github.com/devsy-org/devsy/pkg/encoding" @@ -18,7 +19,13 @@ import ( // Runner drives the lifecycle of a single workspace's dev container. type Runner interface { - Up(ctx context.Context, options UpOptions, timeout time.Duration) (*config.Result, error) + // Up reports phase transitions to reporter; pass status.Nop() to skip. + Up( + ctx context.Context, + options UpOptions, + timeout time.Duration, + reporter status.Reporter, + ) (*config.Result, error) Build(ctx context.Context, options provider.BuildOptions) (string, error) Find(ctx context.Context) (*config.ContainerDetails, error) Command(ctx context.Context, params CommandParams) error @@ -83,6 +90,10 @@ type runner struct { idLabels []string recovering bool + + // Set at the start of Up; read by other runner methods via this field + // rather than threaded through every call site. + reporter status.Reporter } func NewRunner( @@ -110,6 +121,7 @@ func NewRunner( id: GetRunnerIDFromWorkspace(workspaceConfig.Workspace), idLabels: workspaceConfig.CLIOptions.IDLabels, workspaceConfig: workspaceConfig, + reporter: status.Nop(), }, nil } @@ -124,25 +136,38 @@ func (r *runner) Up( ctx context.Context, options UpOptions, timeout time.Duration, + reporter status.Reporter, ) (*config.Result, error) { + if reporter == nil { + reporter = status.Nop() + } + r.reporter = reporter + log.Debugf( "Up devcontainer for workspace %q with timeout %s", r.workspaceConfig.Workspace.ID, timeout, ) + status.Enter(reporter, status.PhaseResolvingConfig, "") substitutedConfig, substitutionContext, err := r.getSubstitutedConfig(options.CLIOptions) if err != nil { + status.Fail(reporter, status.PhaseResolvingConfig, err) return nil, err } + status.Leave(reporter, status.PhaseResolvingConfig, "") defer cleanupBuildInformation(substitutedConfig.Config) // Recovery skips initializeCommand: a failing host hook must not block the // recovery container. In normal mode its failure is recovery-eligible. if !options.Recovery { + status.Enter(reporter, status.PhaseInitializeCommand, "") if err := r.runInitializeCommand(ctx, substitutedConfig.Config, options); err != nil { - return nil, clierr.Recoverable(fmt.Errorf("initialize command: %w", err)) + err = clierr.Recoverable(fmt.Errorf("initialize command: %w", err)) + status.Fail(reporter, status.PhaseInitializeCommand, err) + return nil, err } + status.Leave(reporter, status.PhaseInitializeCommand, "") } params := &runContainerParams{ @@ -156,6 +181,11 @@ func (r *runner) Up( if result != nil { result.RecoveryContainer = r.recovering } + if err != nil { + status.Fail(reporter, status.PhaseReady, err) + return result, err + } + status.Leave(reporter, status.PhaseReady, "") return result, err } diff --git a/pkg/devcontainer/setup.go b/pkg/devcontainer/setup.go index d01c8ac5b..bc739d3b1 100644 --- a/pkg/devcontainer/setup.go +++ b/pkg/devcontainer/setup.go @@ -18,6 +18,7 @@ import ( "github.com/devsy-org/devsy/pkg/compress" pkgconfig "github.com/devsy-org/devsy/pkg/config" "github.com/devsy-org/devsy/pkg/devcontainer/config" + "github.com/devsy-org/devsy/pkg/devcontainer/status" "github.com/devsy-org/devsy/pkg/devcontainer/crane" "github.com/devsy-org/devsy/pkg/devcontainer/sshtunnel" "github.com/devsy-org/devsy/pkg/docker" @@ -72,9 +73,12 @@ func (r *runner) setupContainer( ctx context.Context, params *setupContainerParams, ) (*config.Result, error) { + status.Enter(r.reporter, status.PhaseInjectingAgent, "") if err := r.injectAgentIntoContainer(ctx, params.timeout); err != nil { + status.Fail(r.reporter, status.PhaseInjectingAgent, err) return nil, err } + status.Leave(r.reporter, status.PhaseInjectingAgent, "") log.Debugf("injected into container") defer log.Debugf("done setting up container") @@ -85,7 +89,14 @@ func (r *runner) setupContainer( setupCommand := r.buildSetupCommand(info.compressed, info.workspaceConfigCompressed) - return r.executeSetup(ctx, info.result, setupCommand) + status.Enter(r.reporter, status.PhaseRunningLifecycleHook, "") + result, err := r.executeSetup(ctx, info.result, setupCommand) + if err != nil { + status.Fail(r.reporter, status.PhaseRunningLifecycleHook, err) + return result, err + } + status.Leave(r.reporter, status.PhaseRunningLifecycleHook, "") + return result, nil } func (r *runner) injectAgentIntoContainer(ctx context.Context, timeout time.Duration) error { @@ -184,6 +195,26 @@ func (r *runner) deliverPostStart(ctx context.Context, strategy delivery.AgentDe return nil } +// prefetchAgentBinary warms the binary cache while the container builds. +// Best-effort: failures are ignored, and the real injection-path +// acquisition retries from scratch regardless. +func (r *runner) prefetchAgentBinary(ctx context.Context) { + arch, err := r.deliveryArch(ctx) + if err != nil { + return + } + binarySource, err := r.newBinarySource() + if err != nil { + return + } + rc, err := binarySource(ctx, arch) + if err != nil { + return + } + defer rc.Close() + _, _ = io.Copy(io.Discard, rc) +} + func (r *runner) newBinarySource() (delivery.BinarySourceFunc, error) { downloadURL := r.agentDownloadURL if downloadURL == "" { @@ -471,6 +502,7 @@ func (r *runner) executeSetup( r.workspaceConfig.CLIOptions.SecretsMount, ), tunnelserver.WithGitToken(r.workspaceConfig.CLIOptions.GitToken), + tunnelserver.WithStatusReporter(r.reporter), ) } diff --git a/pkg/devcontainer/single.go b/pkg/devcontainer/single.go index 00b606985..3a02d2ee2 100644 --- a/pkg/devcontainer/single.go +++ b/pkg/devcontainer/single.go @@ -14,6 +14,7 @@ import ( pkgconfig "github.com/devsy-org/devsy/pkg/config" "github.com/devsy-org/devsy/pkg/daemon/agent" "github.com/devsy-org/devsy/pkg/devcontainer/config" + "github.com/devsy-org/devsy/pkg/devcontainer/status" "github.com/devsy-org/devsy/pkg/devcontainer/metadata" "github.com/devsy-org/devsy/pkg/driver" "github.com/devsy-org/devsy/pkg/language" @@ -80,11 +81,17 @@ func (r *runner) runSingleContainer( options: options, } + // Overlaps with the container build/start below instead of waiting. + go r.prefetchAgentBinary(ctx) + // Resolve container: ensure we have a running container with merged config. + status.Enter(r.reporter, status.PhaseStartingContainer, "") resolved, err := r.resolveContainer(ctx, params, containerDetails) if err != nil { + status.Fail(r.reporter, status.PhaseStartingContainer, err) return nil, err } + status.Leave(r.reporter, status.PhaseStartingContainer, "") return r.setupContainer(ctx, &setupContainerParams{ rawConfig: parsedConfig.Raw, diff --git a/pkg/devcontainer/status/log.go b/pkg/devcontainer/status/log.go new file mode 100644 index 000000000..841c9f023 --- /dev/null +++ b/pkg/devcontainer/status/log.go @@ -0,0 +1,23 @@ +package status + +import "github.com/devsy-org/devsy/pkg/log" + +// logReporter renders events as debug log lines. It is an interim consumer +// until the CLI/desktop stream structured events directly (see +// docs/rfcs/async-workspace-up.md) — real callers should prefer that once it +// lands, but this keeps events observable everywhere in the meantime. +type logReporter struct{} + +// NewLogReporter returns a Reporter that logs each event at debug level. +func NewLogReporter() Reporter { return logReporter{} } + +func (logReporter) Report(e Event) { + switch { + case e.Phase == PhaseFailed: + log.Debugf("up: phase %q failed: %s", e.Step, e.Err) + case e.Started: + log.Debugf("up: entering phase %q", e.Phase) + default: + log.Debugf("up: completed phase %q", e.Phase) + } +} diff --git a/pkg/devcontainer/status/status.go b/pkg/devcontainer/status/status.go new file mode 100644 index 000000000..5a120ae5d --- /dev/null +++ b/pkg/devcontainer/status/status.go @@ -0,0 +1,71 @@ +// Package status defines the structured progress events emitted during +// workspace up, in place of relying on freeform log text for control flow. +package status + +// Phase identifies a step in the up pipeline. +type Phase string + +const ( + PhaseCloningRepository Phase = "cloning_repository" + PhaseResolvingConfig Phase = "resolving_config" + PhaseInitializeCommand Phase = "initialize_command" + PhaseBuildingImage Phase = "building_image" + PhaseStartingContainer Phase = "starting_container" + PhaseInjectingAgent Phase = "injecting_agent" + PhaseRunningLifecycleHook Phase = "running_lifecycle_hook" + PhaseWaitingFor Phase = "waiting_for" + PhaseReady Phase = "ready" + PhaseFailed Phase = "failed" +) + +// Event is one phase transition. Started distinguishes entering a phase +// from completing it; Step carries phase-specific detail (e.g. the +// lifecycle hook name) and is empty when not applicable. Err is set only +// when Phase is PhaseFailed, and names the phase that failed via Step. +type Event struct { + Phase Phase `json:"phase"` + Step string `json:"step,omitempty"` + Started bool `json:"started"` + Err string `json:"error,omitempty"` +} + +// Reporter receives status events as they occur. Implementations must be +// safe to call from multiple goroutines. +type Reporter interface { + Report(Event) +} + +func Enter(r Reporter, phase Phase, step string) { + r.Report(Event{Phase: phase, Step: step, Started: true}) +} + +func Leave(r Reporter, phase Phase, step string) { + r.Report(Event{Phase: phase, Step: step, Started: false}) +} + +func Fail(r Reporter, phase Phase, err error) { + if err == nil { + return + } + r.Report(Event{Phase: PhaseFailed, Step: string(phase), Err: err.Error()}) +} + +type nopReporter struct{} + +func (nopReporter) Report(Event) {} + +func Nop() Reporter { return nopReporter{} } + +type teeReporter []Reporter + +func (t teeReporter) Report(e Event) { + for _, r := range t { + r.Report(e) + } +} + +// Tee forwards every event to each reporter, e.g. both the CLI's own NDJSON +// output and a persisted task state. +func Tee(reporters ...Reporter) Reporter { + return teeReporter(reporters) +} diff --git a/pkg/devcontainer/status/status_test.go b/pkg/devcontainer/status/status_test.go new file mode 100644 index 000000000..dd3231b92 --- /dev/null +++ b/pkg/devcontainer/status/status_test.go @@ -0,0 +1,65 @@ +package status + +import ( + "errors" + "testing" +) + +type recordingReporter struct { + events []Event +} + +func (r *recordingReporter) Report(e Event) { + r.events = append(r.events, e) +} + +func TestEnterLeave(t *testing.T) { + r := &recordingReporter{} + Enter(r, PhaseBuildingImage, "") + Leave(r, PhaseBuildingImage, "") + + if len(r.events) != 2 { + t.Fatalf("expected 2 events, got %d", len(r.events)) + } + if !r.events[0].Started || r.events[0].Phase != PhaseBuildingImage { + t.Errorf("unexpected enter event: %+v", r.events[0]) + } + if r.events[1].Started || r.events[1].Phase != PhaseBuildingImage { + t.Errorf("unexpected leave event: %+v", r.events[1]) + } +} + +func TestFail(t *testing.T) { + r := &recordingReporter{} + Fail(r, PhaseRunningLifecycleHook, errors.New("boom")) + + if len(r.events) != 1 { + t.Fatalf("expected 1 event, got %d", len(r.events)) + } + got := r.events[0] + if got.Phase != PhaseFailed || got.Err != "boom" || got.Step != string(PhaseRunningLifecycleHook) { + t.Errorf("unexpected fail event: %+v", got) + } +} + +func TestFailNilErrorIsNoop(t *testing.T) { + r := &recordingReporter{} + Fail(r, PhaseBuildingImage, nil) + + if len(r.events) != 0 { + t.Errorf("expected no event for nil error, got %+v", r.events) + } +} + +func TestNopDiscardsEvents(t *testing.T) { + Enter(Nop(), PhaseReady, "") +} + +func TestTeeForwardsToEachReporter(t *testing.T) { + a, b := &recordingReporter{}, &recordingReporter{} + Enter(Tee(a, b), PhaseReady, "") + + if len(a.events) != 1 || len(b.events) != 1 { + t.Errorf("expected both reporters to receive the event: a=%+v b=%+v", a.events, b.events) + } +} diff --git a/pkg/flags/names/names.go b/pkg/flags/names/names.go index 1622e54c3..0931c88e9 100644 --- a/pkg/flags/names/names.go +++ b/pkg/flags/names/names.go @@ -18,6 +18,7 @@ const ( ContainerStatus = "container-status" ContainerUser = "container-user" Data = "data" + Detach = "detach" DevContainer = "devcontainer" DevContainerID = "devcontainer-id" DevContainerImage = "devcontainer-image" @@ -28,6 +29,7 @@ const ( FallbackImage = "fallback-image" Features = "features" FeatureSecretsFile = "feature-secrets-file" + Follow = "follow" Force = "force" GPUAvailability = "gpu-availability" GracePeriod = "grace-period" @@ -326,6 +328,7 @@ const ( SkipOnCreate = "skip-on-create" SkipUpdateContent = "skip-update-content" TargetURL = "target-url" + TaskID = "task-id" Workspace = "workspace" WorkspaceInfo = "workspace-info" WorkspaceProject = "workspace-project" diff --git a/pkg/task/store.go b/pkg/task/store.go new file mode 100644 index 000000000..976f8ec47 --- /dev/null +++ b/pkg/task/store.go @@ -0,0 +1,208 @@ +package task + +import ( + "context" + "encoding/json" + "fmt" + "os" + "path/filepath" + "sort" + "time" + + "github.com/devsy-org/devsy/pkg/config" + "github.com/devsy-org/devsy/pkg/random" + "github.com/gofrs/flock" +) + +// lockTimeout bounds how long update waits to acquire a task's file lock. +// Held only for the duration of a single read-modify-write, so a stuck lock +// past this means something is wrong rather than merely busy. +const lockTimeout = 5 * time.Second + +// Store persists task state as one JSON file per task under dir. update +// serializes read-modify-write across processes with a file lock, since a +// task's worker (reporting progress) and an external `task cancel` both +// write to the same file. +type Store struct { + dir string +} + +func NewStore() (*Store, error) { + dir, err := config.DefaultPathManager().TaskDir() + if err != nil { + return nil, fmt.Errorf("task dir: %w", err) + } + return NewStoreAt(dir) +} + +func NewStoreAt(dir string) (*Store, error) { + if err := os.MkdirAll(dir, 0o750); err != nil { + return nil, fmt.Errorf("create task dir: %w", err) + } + return &Store{dir: dir}, nil +} + +func (s *Store) Create(opts CreateOptions) (*Task, error) { + id := random.String(12) + now := time.Now() + state := &State{ + ID: id, + Command: opts.Command, + WorkspaceID: opts.WorkspaceID, + Status: StatusPending, + StartedAt: now, + UpdatedAt: now, + } + if err := s.write(state); err != nil { + return nil, err + } + return &Task{store: s, id: id}, nil +} + +// Open returns a handle without reading the task's state. +func (s *Store) Open(id string) *Task { + return &Task{store: s, id: id} +} + +// Delete errors if the task is still pending or running unless force is set. +func (s *Store) Delete(id string, force bool) error { + if !force { + state, err := s.Get(id) + if err != nil { + return err + } + if !state.Status.Terminal() { + return fmt.Errorf( + "task %s is still %s; cancel it first or delete with force", + id, state.Status, + ) + } + } + path, err := s.path(id) + if err != nil { + return err + } + if err := os.Remove(path); err != nil { + return fmt.Errorf("delete task %s: %w", id, err) + } + return nil +} + +func (s *Store) Get(id string) (*State, error) { + path, err := s.path(id) + if err != nil { + return nil, err + } + data, err := os.ReadFile(path) // #nosec G304 -- path validated by s.path + if err != nil { + return nil, fmt.Errorf("read task %s: %w", id, err) + } + state := &State{} + if err := json.Unmarshal(data, state); err != nil { + return nil, fmt.Errorf("parse task %s: %w", id, err) + } + return state, nil +} + +// List returns every known task, most recently started first. +func (s *Store) List() ([]*State, error) { + entries, err := os.ReadDir(s.dir) + if err != nil { + return nil, fmt.Errorf("read task dir: %w", err) + } + + states := make([]*State, 0, len(entries)) + for _, entry := range entries { + if entry.IsDir() || filepath.Ext(entry.Name()) != ".json" { + continue + } + id := entry.Name()[:len(entry.Name())-len(".json")] + state, err := s.Get(id) + if err != nil { + continue + } + states = append(states, state) + } + + sort.Slice(states, func(i, j int) bool { + return states[i].StartedAt.After(states[j].StartedAt) + }) + return states, nil +} + +func (s *Store) update(id string, mutate func(*State)) error { + return s.withLock(id, func() error { + state, err := s.Get(id) + if err != nil { + return err + } + mutate(state) + state.touch() + return s.write(state) + }) +} + +// withLock runs fn holding an OS file lock scoped to id, so a concurrent +// read-modify-write from another process (or another Store instance in this +// one) can't interleave with it. +func (s *Store) withLock(id string, fn func() error) error { + path, err := s.path(id) + if err != nil { + return err + } + + lock := flock.New(path + ".lock") + ctx, cancel := context.WithTimeout(context.Background(), lockTimeout) + defer cancel() + locked, err := lock.TryLockContext(ctx, 20*time.Millisecond) + if err != nil { + return fmt.Errorf("lock task %s: %w", id, err) + } + if !locked { + return fmt.Errorf("lock task %s: timed out", id) + } + defer func() { _ = lock.Unlock() }() + + return fn() +} + +// path rejects any id that isn't a single clean path component, so a +// caller-supplied id like "../../etc/passwd" can't escape s.dir. +func (s *Store) path(id string) (string, error) { + if id == "" || id == "." || id == ".." || filepath.Base(id) != id { + return "", fmt.Errorf("invalid task id %q", id) + } + return filepath.Join(s.dir, id+".json"), nil +} + +// write atomically replaces the task's state file so a concurrent Get never +// observes a partially written file. +func (s *Store) write(state *State) error { + data, err := marshalState(state) + if err != nil { + return err + } + + target, err := s.path(state.ID) + if err != nil { + return err + } + tmp, err := os.CreateTemp(s.dir, state.ID+".*.tmp") + if err != nil { + return fmt.Errorf("create temp task file: %w", err) + } + tmpPath := tmp.Name() + defer func() { _ = os.Remove(tmpPath) }() + + if _, err := tmp.Write(data); err != nil { + _ = tmp.Close() + return fmt.Errorf("write task state: %w", err) + } + if err := tmp.Close(); err != nil { + return fmt.Errorf("close temp task file: %w", err) + } + if err := os.Rename(tmpPath, target); err != nil { + return fmt.Errorf("commit task state: %w", err) + } + return nil +} diff --git a/pkg/task/task.go b/pkg/task/task.go new file mode 100644 index 000000000..45ad7d5e3 --- /dev/null +++ b/pkg/task/task.go @@ -0,0 +1,155 @@ +// Package task tracks detached background work (e.g. `workspace up +// --detach`) so its status can be polled independently of the CLI +// invocation that started it. +package task + +import ( + "encoding/json" + "errors" + "fmt" + "strconv" + "time" + + "github.com/devsy-org/devsy/pkg/command" + "github.com/devsy-org/devsy/pkg/devcontainer/config" + "github.com/devsy-org/devsy/pkg/devcontainer/status" +) + +var ErrCanceled = errors.New("canceled") + +type Status string + +const ( + StatusPending Status = "pending" + StatusRunning Status = "running" + StatusSucceeded Status = "succeeded" + StatusFailed Status = "failed" +) + +func (s Status) Terminal() bool { + return s == StatusSucceeded || s == StatusFailed +} + +// State is the JSON snapshot persisted for a task. PID names the OS process +// doing the work, distinct from whatever process is merely polling this +// state. Command/WorkspaceID are caller-supplied labels for `task list` and +// don't affect behavior. +type State struct { + ID string `json:"id"` + Command string `json:"command,omitempty"` + WorkspaceID string `json:"workspaceId,omitempty"` + Status Status `json:"status"` + Phase string `json:"phase,omitempty"` + Step string `json:"step,omitempty"` + Error string `json:"error,omitempty"` + Result *config.Result `json:"result,omitempty"` + PID int `json:"pid,omitempty"` + StartedAt time.Time `json:"startedAt"` + UpdatedAt time.Time `json:"updatedAt"` +} + +// CreateOptions labels a task at creation time for later listing. +type CreateOptions struct { + Command string + WorkspaceID string +} + +// Task is a handle to a single background task, bound to the Store it was +// created in. Report may be called from multiple goroutines; each call +// serializes its own read-modify-write of the state file. +type Task struct { + store *Store + id string +} + +func (t *Task) ID() string { return t.id } + +func (t *Task) SetPID(pid int) error { + return t.store.update(t.id, func(s *State) { + s.PID = pid + }) +} + +// SetWorkspaceID corrects the task's workspace label to the resolved ID, +// which may differ from whatever label it was created with (e.g. a raw +// source string guessed before workspace resolution ran). client.Status +// looks tasks up by this label, so it must end up accurate. +func (t *Task) SetWorkspaceID(id string) error { + return t.store.update(t.id, func(s *State) { + s.WorkspaceID = id + }) +} + +func (t *Task) Reporter() status.Reporter { + return taskReporter{task: t} +} + +func (t *Task) Succeed(result *config.Result) error { + return t.store.update(t.id, func(s *State) { + s.Status = StatusSucceeded + s.Result = result + s.Error = "" + }) +} + +// Cancel is safe to call even if the process already exited on its own. The +// terminal check and state transition happen atomically under the same +// lock, so a concurrent report from the task's own worker can't race with +// marking it canceled here. +func (t *Task) Cancel() error { + var pid int + err := t.store.update(t.id, func(s *State) { + if s.Status.Terminal() { + return + } + pid = s.PID + s.Status = StatusFailed + s.Error = ErrCanceled.Error() + }) + if err != nil { + return err + } + if pid == 0 { + return nil + } + return command.Kill(strconv.Itoa(pid)) +} + +func (t *Task) Fail(err error) error { + return t.store.update(t.id, func(s *State) { + s.Status = StatusFailed + if err != nil { + s.Error = err.Error() + } + }) +} + +type taskReporter struct { + task *Task +} + +func (r taskReporter) Report(e status.Event) { + _ = r.task.store.update(r.task.id, func(s *State) { + if e.Phase == status.PhaseFailed { + s.Error = e.Err + return + } + if s.Status == StatusPending { + s.Status = StatusRunning + } + s.Phase = string(e.Phase) + s.Step = e.Step + }) +} + +func (s *State) touch() { + s.UpdatedAt = time.Now() +} + +func marshalState(s *State) ([]byte, error) { + data, err := json.MarshalIndent(s, "", " ") + if err != nil { + return nil, fmt.Errorf("marshal task state: %w", err) + } + return data, nil +} diff --git a/pkg/task/task_test.go b/pkg/task/task_test.go new file mode 100644 index 000000000..11d7ab643 --- /dev/null +++ b/pkg/task/task_test.go @@ -0,0 +1,361 @@ +package task + +import ( + "errors" + "fmt" + "sync" + "testing" + "time" + + "github.com/devsy-org/devsy/pkg/devcontainer/config" + "github.com/devsy-org/devsy/pkg/devcontainer/status" +) + +func newTestStore(t *testing.T) *Store { + t.Helper() + store, err := NewStoreAt(t.TempDir()) + if err != nil { + t.Fatalf("NewStoreAt: %v", err) + } + return store +} + +func TestGetRejectsPathTraversal(t *testing.T) { + store := newTestStore(t) + for _, id := range []string{"../escape", "a/../../b", "/etc/passwd", ".", ".."} { + if _, err := store.Get(id); err == nil { + t.Errorf("Get(%q) = nil error, want rejection", id) + } + } +} + +func TestDeleteRejectsPathTraversal(t *testing.T) { + store := newTestStore(t) + if err := store.Delete("../escape", true); err == nil { + t.Error("Delete with traversal id = nil error, want rejection") + } +} + +func TestCreateStartsPending(t *testing.T) { + store := newTestStore(t) + task, err := store.Create(CreateOptions{}) + if err != nil { + t.Fatalf("Create: %v", err) + } + + state, err := store.Get(task.ID()) + if err != nil { + t.Fatalf("Get: %v", err) + } + if state.Status != StatusPending { + t.Errorf("status = %q, want %q", state.Status, StatusPending) + } +} + +func TestReporterTransitionsToRunning(t *testing.T) { + store := newTestStore(t) + task, err := store.Create(CreateOptions{}) + if err != nil { + t.Fatalf("Create: %v", err) + } + + reporter := task.Reporter() + status.Enter(reporter, status.PhaseBuildingImage, "") + + state, err := store.Get(task.ID()) + if err != nil { + t.Fatalf("Get: %v", err) + } + if state.Status != StatusRunning { + t.Errorf("status = %q, want %q", state.Status, StatusRunning) + } + if state.Phase != string(status.PhaseBuildingImage) { + t.Errorf("phase = %q, want %q", state.Phase, status.PhaseBuildingImage) + } +} + +func TestReporterRecordsFailure(t *testing.T) { + store := newTestStore(t) + task, err := store.Create(CreateOptions{}) + if err != nil { + t.Fatalf("Create: %v", err) + } + + status.Fail(task.Reporter(), status.PhaseRunningLifecycleHook, errors.New("boom")) + + state, err := store.Get(task.ID()) + if err != nil { + t.Fatalf("Get: %v", err) + } + if state.Error != "boom" { + t.Errorf("error = %q, want %q", state.Error, "boom") + } +} + +func TestSucceedRecordsResult(t *testing.T) { + store := newTestStore(t) + task, err := store.Create(CreateOptions{}) + if err != nil { + t.Fatalf("Create: %v", err) + } + + result := &config.Result{RecoveryContainer: true} + if err := task.Succeed(result); err != nil { + t.Fatalf("Succeed: %v", err) + } + + state, err := store.Get(task.ID()) + if err != nil { + t.Fatalf("Get: %v", err) + } + if state.Status != StatusSucceeded { + t.Errorf("status = %q, want %q", state.Status, StatusSucceeded) + } + if state.Result == nil || !state.Result.RecoveryContainer { + t.Errorf("result = %+v, want RecoveryContainer=true", state.Result) + } + if !state.Status.Terminal() { + t.Error("expected Succeeded to be terminal") + } +} + +func TestFailRecordsError(t *testing.T) { + store := newTestStore(t) + task, err := store.Create(CreateOptions{}) + if err != nil { + t.Fatalf("Create: %v", err) + } + + if err := task.Fail(errors.New("container build failed")); err != nil { + t.Fatalf("Fail: %v", err) + } + + state, err := store.Get(task.ID()) + if err != nil { + t.Fatalf("Get: %v", err) + } + if state.Status != StatusFailed { + t.Errorf("status = %q, want %q", state.Status, StatusFailed) + } + if state.Error != "container build failed" { + t.Errorf("error = %q", state.Error) + } +} + +func TestListOrdersMostRecentFirst(t *testing.T) { + store := newTestStore(t) + first, err := store.Create(CreateOptions{}) + if err != nil { + t.Fatalf("Create: %v", err) + } + // Force distinct StartedAt values without relying on real wall-clock gaps. + if err := store.update(first.ID(), func(s *State) { + s.StartedAt = s.StartedAt.Add(-time.Hour) + }); err != nil { + t.Fatalf("backdate first: %v", err) + } + second, err := store.Create(CreateOptions{}) + if err != nil { + t.Fatalf("Create: %v", err) + } + + states, err := store.List() + if err != nil { + t.Fatalf("List: %v", err) + } + if len(states) != 2 { + t.Fatalf("expected 2 tasks, got %d", len(states)) + } + if states[0].ID != second.ID() || states[1].ID != first.ID() { + t.Errorf("unexpected order: %+v", states) + } +} + +func TestSetPIDPersists(t *testing.T) { + store := newTestStore(t) + task, err := store.Create(CreateOptions{}) + if err != nil { + t.Fatalf("Create: %v", err) + } + + if err := task.SetPID(4242); err != nil { + t.Fatalf("SetPID: %v", err) + } + + state, err := store.Get(task.ID()) + if err != nil { + t.Fatalf("Get: %v", err) + } + if state.PID != 4242 { + t.Errorf("PID = %d, want 4242", state.PID) + } +} + +func TestCancelWithoutPIDMarksFailed(t *testing.T) { + store := newTestStore(t) + task, err := store.Create(CreateOptions{}) + if err != nil { + t.Fatalf("Create: %v", err) + } + + if err := task.Cancel(); err != nil { + t.Fatalf("Cancel: %v", err) + } + + state, err := store.Get(task.ID()) + if err != nil { + t.Fatalf("Get: %v", err) + } + if state.Status != StatusFailed { + t.Errorf("status = %q, want %q", state.Status, StatusFailed) + } + if state.Error != ErrCanceled.Error() { + t.Errorf("error = %q, want %q", state.Error, ErrCanceled.Error()) + } +} + +func TestCancelOnTerminalTaskIsNoop(t *testing.T) { + store := newTestStore(t) + task, err := store.Create(CreateOptions{}) + if err != nil { + t.Fatalf("Create: %v", err) + } + if err := task.Succeed(&config.Result{}); err != nil { + t.Fatalf("Succeed: %v", err) + } + + if err := task.Cancel(); err != nil { + t.Fatalf("Cancel: %v", err) + } + + state, err := store.Get(task.ID()) + if err != nil { + t.Fatalf("Get: %v", err) + } + if state.Status != StatusSucceeded { + t.Errorf("status = %q, want unchanged %q", state.Status, StatusSucceeded) + } +} + +func TestCreateStoresLabels(t *testing.T) { + store := newTestStore(t) + tsk, err := store.Create(CreateOptions{Command: "up", WorkspaceID: "my-ws"}) + if err != nil { + t.Fatalf("Create: %v", err) + } + + state, err := store.Get(tsk.ID()) + if err != nil { + t.Fatalf("Get: %v", err) + } + if state.Command != "up" || state.WorkspaceID != "my-ws" { + t.Errorf("unexpected labels: %+v", state) + } +} + +func TestDeleteRequiresTerminalUnlessForced(t *testing.T) { + store := newTestStore(t) + tsk, err := store.Create(CreateOptions{}) + if err != nil { + t.Fatalf("Create: %v", err) + } + + if err := store.Delete(tsk.ID(), false); err == nil { + t.Error("expected Delete to fail on a non-terminal task") + } + if err := store.Delete(tsk.ID(), true); err != nil { + t.Fatalf("force Delete: %v", err) + } + if _, err := store.Get(tsk.ID()); err == nil { + t.Error("expected Get to fail after Delete") + } +} + +func TestDeleteTerminalTask(t *testing.T) { + store := newTestStore(t) + tsk, err := store.Create(CreateOptions{}) + if err != nil { + t.Fatalf("Create: %v", err) + } + if err := tsk.Succeed(&config.Result{}); err != nil { + t.Fatalf("Succeed: %v", err) + } + + if err := store.Delete(tsk.ID(), false); err != nil { + t.Fatalf("Delete: %v", err) + } + if _, err := store.Get(tsk.ID()); err == nil { + t.Error("expected Get to fail after Delete") + } +} + +// TestConcurrentUpdatesAcrossStoreInstancesAreSerialized simulates two +// separate processes (each with its own Store instance, as they would be in +// practice) racing to update the same task: a worker reporting progress and +// a canceller. Every individual update must be applied atomically — no +// update should be silently lost or the file left corrupt. +func TestConcurrentUpdatesAcrossStoreInstancesAreSerialized(t *testing.T) { + dir := t.TempDir() + workerStore, err := NewStoreAt(dir) + if err != nil { + t.Fatalf("NewStoreAt: %v", err) + } + tsk, err := workerStore.Create(CreateOptions{Command: "up"}) + if err != nil { + t.Fatalf("Create: %v", err) + } + + const n = 20 + var wg sync.WaitGroup + for i := 0; i < n; i++ { + wg.Add(1) + go func(i int) { + defer wg.Done() + store, err := NewStoreAt(dir) + if err != nil { + t.Errorf("NewStoreAt: %v", err) + return + } + if err := store.update(tsk.ID(), func(s *State) { + s.Step = fmt.Sprintf("step-%d", i) + }); err != nil { + t.Errorf("update: %v", err) + } + }(i) + } + wg.Wait() + + // The file must still be valid, readable JSON — not truncated or + // interleaved — after N concurrent read-modify-writes. + final, err := workerStore.Get(tsk.ID()) + if err != nil { + t.Fatalf("Get after concurrent updates: %v", err) + } + if final.Step == "" { + t.Error("expected some update's Step to have won, got empty") + } +} + +func TestConcurrentReportsAreRaceSafe(t *testing.T) { + store := newTestStore(t) + task, err := store.Create(CreateOptions{}) + if err != nil { + t.Fatalf("Create: %v", err) + } + + reporter := task.Reporter() + var wg sync.WaitGroup + for i := 0; i < 20; i++ { + wg.Add(1) + go func() { + defer wg.Done() + status.Enter(reporter, status.PhaseBuildingImage, "") + status.Leave(reporter, status.PhaseBuildingImage, "") + }() + } + wg.Wait() + + if _, err := store.Get(task.ID()); err != nil { + t.Fatalf("Get after concurrent reports: %v", err) + } +} From 65ce40f1d733a79153001f3b449db73605a557dc Mon Sep 17 00:00:00 2001 From: Samuel K Date: Tue, 28 Jul 2026 16:53:21 -0500 Subject: [PATCH 02/21] fix(lint): satisfy golangci-lint on the async workspace-up changes Fixes the unused status import left over from the intermediate up.go split, plus cyclop, funcorder, errcheck, forbidigo, goconst, golines, modernize, and revive findings across the new task/status/detach code. --- cmd/internal/agentworkspace/up.go | 1 - cmd/internal/container_tunnel.go | 2 +- cmd/workspace/task.go | 35 ++++++---- cmd/workspace/task_test.go | 8 ++- cmd/workspace/up/detach_test.go | 17 +++-- cmd/workspace/up/status.go | 36 ++++------ cmd/workspace/up/up.go | 16 ++--- pkg/agent/tunnelserver/status_sender.go | 24 +++---- .../clientimplementation/daemonclient/up.go | 2 +- .../clientimplementation/workspace_client.go | 69 ++++++++++--------- .../workspace_client_status_test.go | 28 ++++---- pkg/command/process_supported.go | 3 +- pkg/devcontainer/setup.go | 4 +- pkg/devcontainer/single.go | 2 +- pkg/devcontainer/status/status_test.go | 3 +- pkg/task/task_test.go | 16 ++--- 16 files changed, 139 insertions(+), 127 deletions(-) diff --git a/cmd/internal/agentworkspace/up.go b/cmd/internal/agentworkspace/up.go index 438234428..09882255e 100644 --- a/cmd/internal/agentworkspace/up.go +++ b/cmd/internal/agentworkspace/up.go @@ -24,7 +24,6 @@ import ( "github.com/devsy-org/devsy/pkg/devcontainer" config2 "github.com/devsy-org/devsy/pkg/devcontainer/config" "github.com/devsy-org/devsy/pkg/devcontainer/crane" - "github.com/devsy-org/devsy/pkg/devcontainer/status" "github.com/devsy-org/devsy/pkg/dockercredentials" "github.com/devsy-org/devsy/pkg/dockerinstall" "github.com/devsy-org/devsy/pkg/extract" diff --git a/cmd/internal/container_tunnel.go b/cmd/internal/container_tunnel.go index d02fbbaf4..e6077d2e3 100644 --- a/cmd/internal/container_tunnel.go +++ b/cmd/internal/container_tunnel.go @@ -13,8 +13,8 @@ import ( "github.com/devsy-org/devsy/pkg/agent" pkgconfig "github.com/devsy-org/devsy/pkg/config" "github.com/devsy-org/devsy/pkg/devcontainer" - "github.com/devsy-org/devsy/pkg/devcontainer/status" "github.com/devsy-org/devsy/pkg/devcontainer/config" + "github.com/devsy-org/devsy/pkg/devcontainer/status" "github.com/devsy-org/devsy/pkg/encoding" cliflags "github.com/devsy-org/devsy/pkg/flags" "github.com/devsy-org/devsy/pkg/flags/names" diff --git a/cmd/workspace/task.go b/cmd/workspace/task.go index b455496a4..51df3b556 100644 --- a/cmd/workspace/task.go +++ b/cmd/workspace/task.go @@ -69,6 +69,7 @@ func (cmd *taskListCmd) run() error { return json.NewEncoder(os.Stdout).Encode(states) } for _, s := range states { + //nolint:forbidigo // CLI stdout output fmt.Printf("%s\t%s\t%s\t%s\n", s.ID, s.Status, s.Command, s.WorkspaceID) } return nil @@ -161,30 +162,34 @@ func (cmd *taskLogsCmd) run(id string) error { if err != nil { return fmt.Errorf("parse --interval: %w", err) } - return followTask(context.Background(), store, id, interval, emitJSON) + return followTask(context.Background(), store, followTaskOptions{ + id: id, + interval: interval, + emitJSON: emitJSON, + }) } -func followTask( - ctx context.Context, - store *task.Store, - id string, - interval time.Duration, - emitJSON bool, -) error { +type followTaskOptions struct { + id string + interval time.Duration + emitJSON bool +} + +func followTask(ctx context.Context, store *task.Store, opts followTaskOptions) error { var last *task.State - ticker := time.NewTicker(interval) + ticker := time.NewTicker(opts.interval) defer ticker.Stop() for { - state, err := store.Get(id) + state, err := store.Get(opts.id) if err != nil { return err } - emitTaskTransition(last, state, emitJSON) + emitTaskTransition(last, state, opts.emitJSON) last = state if state.Status.Terminal() { - return reportTaskState(state, emitJSON) + return reportTaskState(state, opts.emitJSON) } select { @@ -209,7 +214,7 @@ func emitTaskTransition(last, current *task.State, emitJSON bool) { _ = config2.WriteStatusJSON(os.Stdout, event) return } - fmt.Printf("task %s: %s\n", current.ID, current.Phase) + fmt.Printf("task %s: %s\n", current.ID, current.Phase) //nolint:forbidigo // CLI stdout output } // --- cancel --- @@ -254,7 +259,7 @@ func (cmd *taskCancelCmd) run(id string) error { if emitJSON { return json.NewEncoder(os.Stdout).Encode(state) } - fmt.Printf("task %s: canceled\n", state.ID) + fmt.Printf("task %s: canceled\n", state.ID) //nolint:forbidigo // CLI stdout output return nil } @@ -312,7 +317,7 @@ func reportTaskState(state *task.State, emitJSON bool) error { return reportTaskStateJSON(state) } - fmt.Printf("task %s: %s\n", state.ID, state.Status) + fmt.Printf("task %s: %s\n", state.ID, state.Status) //nolint:forbidigo // CLI stdout output if state.Status == task.StatusFailed { return fmt.Errorf("%s", state.Error) } diff --git a/cmd/workspace/task_test.go b/cmd/workspace/task_test.go index 1be2c956a..f59d304e4 100644 --- a/cmd/workspace/task_test.go +++ b/cmd/workspace/task_test.go @@ -7,6 +7,8 @@ import ( "github.com/devsy-org/devsy/pkg/task" ) +const testResultContainerID = "abc123" + func TestResultEnvelopeFrom_NilResult(t *testing.T) { got := resultEnvelopeFrom(&task.State{}) if got.ContainerID != "" || got.RemoteUser != "" || got.Recovery || len(got.Warnings) != 0 { @@ -20,14 +22,14 @@ func TestResultEnvelopeFrom_PopulatedResult(t *testing.T) { HostWarnings: []string{"warn"}, RecoveryContainer: true, ContainerDetails: &config.ContainerDetails{ - ID: "abc123", + ID: testResultContainerID, }, }, } got := resultEnvelopeFrom(state) - if got.ContainerID != "abc123" { - t.Errorf("ContainerID = %q, want %q", got.ContainerID, "abc123") + if got.ContainerID != testResultContainerID { + t.Errorf("ContainerID = %q, want %q", got.ContainerID, testResultContainerID) } if !got.Recovery { t.Error("expected Recovery = true") diff --git a/cmd/workspace/up/detach_test.go b/cmd/workspace/up/detach_test.go index e02d79721..965d7b555 100644 --- a/cmd/workspace/up/detach_test.go +++ b/cmd/workspace/up/detach_test.go @@ -5,25 +5,30 @@ import ( "testing" ) +const ( + testRepoArg = "myrepo" + testDebugFlag = "--debug" +) + func TestDetachedArgs_StripsDetachFlag(t *testing.T) { - got := detachedArgs([]string{"myrepo", "--detach", "--debug"}) - want := []string{"myrepo", "--debug"} + got := detachedArgs([]string{testRepoArg, "--detach", testDebugFlag}) + want := []string{testRepoArg, testDebugFlag} if !reflect.DeepEqual(got, want) { t.Errorf("detachedArgs() = %v, want %v", got, want) } } func TestDetachedArgs_StripsDetachEqualsValue(t *testing.T) { - got := detachedArgs([]string{"myrepo", "--detach=true", "--debug"}) - want := []string{"myrepo", "--debug"} + got := detachedArgs([]string{testRepoArg, "--detach=true", testDebugFlag}) + want := []string{testRepoArg, testDebugFlag} if !reflect.DeepEqual(got, want) { t.Errorf("detachedArgs() = %v, want %v", got, want) } } func TestDetachedArgs_NoDetachFlagIsUnchanged(t *testing.T) { - got := detachedArgs([]string{"myrepo", "--debug"}) - want := []string{"myrepo", "--debug"} + got := detachedArgs([]string{testRepoArg, testDebugFlag}) + want := []string{testRepoArg, testDebugFlag} if !reflect.DeepEqual(got, want) { t.Errorf("detachedArgs() = %v, want %v", got, want) } diff --git a/cmd/workspace/up/status.go b/cmd/workspace/up/status.go index d044cec9c..a6b16494d 100644 --- a/cmd/workspace/up/status.go +++ b/cmd/workspace/up/status.go @@ -38,27 +38,21 @@ func (plainStatusReporter) Report(e status.Event) { } } +var phaseLabels = map[status.Phase]string{ + status.PhaseCloningRepository: "cloning repository", + status.PhaseResolvingConfig: "resolving devcontainer config", + status.PhaseInitializeCommand: "running initializeCommand", + status.PhaseBuildingImage: "building image", + status.PhaseStartingContainer: "starting container", + status.PhaseInjectingAgent: "injecting agent", + status.PhaseRunningLifecycleHook: "running lifecycle hooks", + status.PhaseWaitingFor: "waiting for readiness", + status.PhaseReady: "ready", +} + func phaseLabel(p status.Phase) string { - switch p { - case status.PhaseCloningRepository: - return "cloning repository" - case status.PhaseResolvingConfig: - return "resolving devcontainer config" - case status.PhaseInitializeCommand: - return "running initializeCommand" - case status.PhaseBuildingImage: - return "building image" - case status.PhaseStartingContainer: - return "starting container" - case status.PhaseInjectingAgent: - return "injecting agent" - case status.PhaseRunningLifecycleHook: - return "running lifecycle hooks" - case status.PhaseWaitingFor: - return "waiting for readiness" - case status.PhaseReady: - return "ready" - default: - return string(p) + if label, ok := phaseLabels[p]; ok { + return label } + return string(p) } diff --git a/cmd/workspace/up/up.go b/cmd/workspace/up/up.go index 5882e6dca..352115d78 100644 --- a/cmd/workspace/up/up.go +++ b/cmd/workspace/up/up.go @@ -67,14 +67,6 @@ type UpCmd struct { statusReporter status.Reporter } -// reporter falls back to a no-op when Run hasn't set one yet. -func (cmd *UpCmd) reporter() status.Reporter { - if cmd.statusReporter == nil { - return status.Nop() - } - return cmd.statusReporter -} - // Options is the structured input form of the up command. type Options struct { Source string // git URL, local path, image, or workspace name @@ -273,6 +265,14 @@ func (cmd *UpCmd) Run( return nil } +// reporter falls back to a no-op when Run hasn't set one yet. +func (cmd *UpCmd) reporter() status.Reporter { + if cmd.statusReporter == nil { + return status.Nop() + } + return cmd.statusReporter +} + func (cmd *UpCmd) checkExtraDevContainerProvider(client client2.BaseWorkspaceClient) error { if cmd.ExtraDevContainerPath != "" && client.Provider() != "docker" { return fmt.Errorf("extra devcontainer file is only supported with local provider") diff --git a/pkg/agent/tunnelserver/status_sender.go b/pkg/agent/tunnelserver/status_sender.go index acf911034..d50b1a3c2 100644 --- a/pkg/agent/tunnelserver/status_sender.go +++ b/pkg/agent/tunnelserver/status_sender.go @@ -27,6 +27,18 @@ type tunnelStatusReporter struct { events chan *tunnel.StatusUpdate } +func (r *tunnelStatusReporter) Report(e status.Event) { + select { + case r.events <- &tunnel.StatusUpdate{ + Phase: string(e.Phase), + Step: e.Step, + Started: e.Started, + Error: e.Err, + }: + case <-r.ctx.Done(): + } +} + func (r *tunnelStatusReporter) worker() { for { select { @@ -39,15 +51,3 @@ func (r *tunnelStatusReporter) worker() { } } } - -func (r *tunnelStatusReporter) Report(e status.Event) { - select { - case r.events <- &tunnel.StatusUpdate{ - Phase: string(e.Phase), - Step: e.Step, - Started: e.Started, - Error: e.Err, - }: - case <-r.ctx.Done(): - } -} diff --git a/pkg/client/clientimplementation/daemonclient/up.go b/pkg/client/clientimplementation/daemonclient/up.go index 3fe8cdbb8..d26f90360 100644 --- a/pkg/client/clientimplementation/daemonclient/up.go +++ b/pkg/client/clientimplementation/daemonclient/up.go @@ -399,7 +399,7 @@ func printLogs( // The remote task runs the same devsy CLI, so its stdout carries the // same NDJSON status lines a local `up` does; sniff them out here. statusWriter := newStatusSniffingWriter(stdoutStreamer, reporter) - defer statusWriter.Close() + defer func() { _ = statusWriter.Close() }() stdout := io.Writer(statusWriter) // loop over all lines diff --git a/pkg/client/clientimplementation/workspace_client.go b/pkg/client/clientimplementation/workspace_client.go index df96c8862..dbd4ce344 100644 --- a/pkg/client/clientimplementation/workspace_client.go +++ b/pkg/client/clientimplementation/workspace_client.go @@ -314,30 +314,32 @@ func (s *workspaceClient) Status( return result, nil } +func (s *workspaceClient) Describe(ctx context.Context) (string, error) { + s.m.Lock() + defer s.m.Unlock() + + if !s.isMachineProvider() || len(s.config.Exec.Describe) == 0 { + return client.DescriptionNotFound, nil + } + if s.machine == nil { + return client.DescriptionNotFound, nil + } + + machineClient, err := NewMachineClient(s.devsyConfig, s.config, s.machine) + if err != nil { + return client.DescriptionNotFound, err + } + + return machineClient.Describe(ctx) +} + // taskStatusOverride reports Provisioning or Failed based on the most // recently started `up` task for this workspace, when more informative than // a bare "no container". A succeeded task implies nothing: if the container // is still missing after that, something else removed it. Best-effort: any // error reading task state reports ok=false rather than failing Status. func (s *workspaceClient) taskStatusOverride() (client.Status, bool) { - store, err := task.NewStore() - if err != nil { - return "", false - } - states, err := store.List() - if err != nil { - return "", false - } - - var latest *task.State - for _, st := range states { - if st.WorkspaceID != s.workspace.ID || st.Command != "up" { - continue - } - if latest == nil || st.StartedAt.After(latest.StartedAt) { - latest = st - } - } + latest := s.latestUpTask() if latest == nil { return "", false } @@ -352,23 +354,28 @@ func (s *workspaceClient) taskStatusOverride() (client.Status, bool) { } } -func (s *workspaceClient) Describe(ctx context.Context) (string, error) { - s.m.Lock() - defer s.m.Unlock() - - if !s.isMachineProvider() || len(s.config.Exec.Describe) == 0 { - return client.DescriptionNotFound, nil - } - if s.machine == nil { - return client.DescriptionNotFound, nil +// latestUpTask returns the most recently started `up` task recorded for this +// workspace, or nil if none exists or task state can't be read. +func (s *workspaceClient) latestUpTask() *task.State { + store, err := task.NewStore() + if err != nil { + return nil } - - machineClient, err := NewMachineClient(s.devsyConfig, s.config, s.machine) + states, err := store.List() if err != nil { - return client.DescriptionNotFound, err + return nil } - return machineClient.Describe(ctx) + var latest *task.State + for _, st := range states { + if st.WorkspaceID != s.workspace.ID || st.Command != "up" { + continue + } + if latest == nil || st.StartedAt.After(latest.StartedAt) { + latest = st + } + } + return latest } func (s *workspaceClient) agentConfig() provider.ProviderAgentConfig { diff --git a/pkg/client/clientimplementation/workspace_client_status_test.go b/pkg/client/clientimplementation/workspace_client_status_test.go index 3395bb1a7..58bbfd6be 100644 --- a/pkg/client/clientimplementation/workspace_client_status_test.go +++ b/pkg/client/clientimplementation/workspace_client_status_test.go @@ -13,6 +13,8 @@ import ( "github.com/stretchr/testify/require" ) +const testWorkspaceID = "my-ws" + // fakeTaskDirPathManager overrides only TaskDir so tests can point pkg/task // at a temp directory without touching the real state dir. type fakeTaskDirPathManager struct { @@ -42,7 +44,7 @@ func assertOverride(t *testing.T, s *workspaceClient, wantOK bool, wantStatus cl func TestTaskStatusOverride_NoTasks(t *testing.T) { useTempTaskDir(t) - s := &workspaceClient{workspace: &provider.Workspace{ID: "my-ws"}} + s := &workspaceClient{workspace: &provider.Workspace{ID: testWorkspaceID}} assertOverride(t, s, false, "") } @@ -50,10 +52,10 @@ func TestTaskStatusOverride_ActiveUpTaskReportsProvisioning(t *testing.T) { useTempTaskDir(t) store, err := task.NewStore() require.NoError(t, err) - _, err = store.Create(task.CreateOptions{Command: "up", WorkspaceID: "my-ws"}) + _, err = store.Create(task.CreateOptions{Command: "up", WorkspaceID: testWorkspaceID}) require.NoError(t, err) - s := &workspaceClient{workspace: &provider.Workspace{ID: "my-ws"}} + s := &workspaceClient{workspace: &provider.Workspace{ID: testWorkspaceID}} assertOverride(t, s, true, client.StatusProvisioning) } @@ -61,11 +63,11 @@ func TestTaskStatusOverride_FailedTaskReportsFailed(t *testing.T) { useTempTaskDir(t) store, err := task.NewStore() require.NoError(t, err) - tsk, err := store.Create(task.CreateOptions{Command: "up", WorkspaceID: "my-ws"}) + tsk, err := store.Create(task.CreateOptions{Command: "up", WorkspaceID: testWorkspaceID}) require.NoError(t, err) require.NoError(t, tsk.Fail(errors.New("build failed"))) - s := &workspaceClient{workspace: &provider.Workspace{ID: "my-ws"}} + s := &workspaceClient{workspace: &provider.Workspace{ID: testWorkspaceID}} assertOverride(t, s, true, client.StatusFailed) } @@ -73,11 +75,11 @@ func TestTaskStatusOverride_SucceededTaskDefersToContainerStatus(t *testing.T) { useTempTaskDir(t) store, err := task.NewStore() require.NoError(t, err) - tsk, err := store.Create(task.CreateOptions{Command: "up", WorkspaceID: "my-ws"}) + tsk, err := store.Create(task.CreateOptions{Command: "up", WorkspaceID: testWorkspaceID}) require.NoError(t, err) require.NoError(t, tsk.Succeed(nil)) - s := &workspaceClient{workspace: &provider.Workspace{ID: "my-ws"}} + s := &workspaceClient{workspace: &provider.Workspace{ID: testWorkspaceID}} assertOverride(t, s, false, "") } @@ -88,7 +90,7 @@ func TestTaskStatusOverride_IgnoresOtherWorkspaces(t *testing.T) { _, err = store.Create(task.CreateOptions{Command: "up", WorkspaceID: "other-ws"}) require.NoError(t, err) - s := &workspaceClient{workspace: &provider.Workspace{ID: "my-ws"}} + s := &workspaceClient{workspace: &provider.Workspace{ID: testWorkspaceID}} assertOverride(t, s, false, "") } @@ -96,10 +98,10 @@ func TestTaskStatusOverride_IgnoresNonUpCommands(t *testing.T) { useTempTaskDir(t) store, err := task.NewStore() require.NoError(t, err) - _, err = store.Create(task.CreateOptions{Command: "delete", WorkspaceID: "my-ws"}) + _, err = store.Create(task.CreateOptions{Command: "delete", WorkspaceID: testWorkspaceID}) require.NoError(t, err) - s := &workspaceClient{workspace: &provider.Workspace{ID: "my-ws"}} + s := &workspaceClient{workspace: &provider.Workspace{ID: testWorkspaceID}} assertOverride(t, s, false, "") } @@ -108,16 +110,16 @@ func TestTaskStatusOverride_MostRecentTaskWins(t *testing.T) { store, err := task.NewStore() require.NoError(t, err) - older, err := store.Create(task.CreateOptions{Command: "up", WorkspaceID: "my-ws"}) + older, err := store.Create(task.CreateOptions{Command: "up", WorkspaceID: testWorkspaceID}) require.NoError(t, err) require.NoError(t, older.Fail(errors.New("first attempt failed"))) // Real wall-clock gap so the second task is unambiguously "most recent". time.Sleep(5 * time.Millisecond) - _, err = store.Create(task.CreateOptions{Command: "up", WorkspaceID: "my-ws"}) + _, err = store.Create(task.CreateOptions{Command: "up", WorkspaceID: testWorkspaceID}) require.NoError(t, err) - s := &workspaceClient{workspace: &provider.Workspace{ID: "my-ws"}} + s := &workspaceClient{workspace: &provider.Workspace{ID: testWorkspaceID}} // The second (later) task is still in flight, so it wins over the // earlier failure. assertOverride(t, s, true, client.StatusProvisioning) diff --git a/pkg/command/process_supported.go b/pkg/command/process_supported.go index 0736cbf14..4ec57a98a 100644 --- a/pkg/command/process_supported.go +++ b/pkg/command/process_supported.go @@ -42,7 +42,8 @@ func kill(pid string) error { return err } time.Sleep(2 * time.Second) - if err := syscall.Kill(parsedPid, syscall.SIGKILL); err != nil && !errors.Is(err, syscall.ESRCH) { + err = syscall.Kill(parsedPid, syscall.SIGKILL) + if err != nil && !errors.Is(err, syscall.ESRCH) { return err } return nil diff --git a/pkg/devcontainer/setup.go b/pkg/devcontainer/setup.go index bc739d3b1..1bfb1c3e8 100644 --- a/pkg/devcontainer/setup.go +++ b/pkg/devcontainer/setup.go @@ -18,9 +18,9 @@ import ( "github.com/devsy-org/devsy/pkg/compress" pkgconfig "github.com/devsy-org/devsy/pkg/config" "github.com/devsy-org/devsy/pkg/devcontainer/config" - "github.com/devsy-org/devsy/pkg/devcontainer/status" "github.com/devsy-org/devsy/pkg/devcontainer/crane" "github.com/devsy-org/devsy/pkg/devcontainer/sshtunnel" + "github.com/devsy-org/devsy/pkg/devcontainer/status" "github.com/devsy-org/devsy/pkg/docker" "github.com/devsy-org/devsy/pkg/driver" "github.com/devsy-org/devsy/pkg/flags/names" @@ -211,7 +211,7 @@ func (r *runner) prefetchAgentBinary(ctx context.Context) { if err != nil { return } - defer rc.Close() + defer func() { _ = rc.Close() }() _, _ = io.Copy(io.Discard, rc) } diff --git a/pkg/devcontainer/single.go b/pkg/devcontainer/single.go index 3a02d2ee2..5974995c7 100644 --- a/pkg/devcontainer/single.go +++ b/pkg/devcontainer/single.go @@ -14,8 +14,8 @@ import ( pkgconfig "github.com/devsy-org/devsy/pkg/config" "github.com/devsy-org/devsy/pkg/daemon/agent" "github.com/devsy-org/devsy/pkg/devcontainer/config" - "github.com/devsy-org/devsy/pkg/devcontainer/status" "github.com/devsy-org/devsy/pkg/devcontainer/metadata" + "github.com/devsy-org/devsy/pkg/devcontainer/status" "github.com/devsy-org/devsy/pkg/driver" "github.com/devsy-org/devsy/pkg/language" "github.com/devsy-org/devsy/pkg/log" diff --git a/pkg/devcontainer/status/status_test.go b/pkg/devcontainer/status/status_test.go index dd3231b92..521f82a0a 100644 --- a/pkg/devcontainer/status/status_test.go +++ b/pkg/devcontainer/status/status_test.go @@ -37,7 +37,8 @@ func TestFail(t *testing.T) { t.Fatalf("expected 1 event, got %d", len(r.events)) } got := r.events[0] - if got.Phase != PhaseFailed || got.Err != "boom" || got.Step != string(PhaseRunningLifecycleHook) { + wantStep := string(PhaseRunningLifecycleHook) + if got.Phase != PhaseFailed || got.Err != "boom" || got.Step != wantStep { t.Errorf("unexpected fail event: %+v", got) } } diff --git a/pkg/task/task_test.go b/pkg/task/task_test.go index 11d7ab643..fbd9cc327 100644 --- a/pkg/task/task_test.go +++ b/pkg/task/task_test.go @@ -307,10 +307,8 @@ func TestConcurrentUpdatesAcrossStoreInstancesAreSerialized(t *testing.T) { const n = 20 var wg sync.WaitGroup - for i := 0; i < n; i++ { - wg.Add(1) - go func(i int) { - defer wg.Done() + for i := range n { + wg.Go(func() { store, err := NewStoreAt(dir) if err != nil { t.Errorf("NewStoreAt: %v", err) @@ -321,7 +319,7 @@ func TestConcurrentUpdatesAcrossStoreInstancesAreSerialized(t *testing.T) { }); err != nil { t.Errorf("update: %v", err) } - }(i) + }) } wg.Wait() @@ -345,13 +343,11 @@ func TestConcurrentReportsAreRaceSafe(t *testing.T) { reporter := task.Reporter() var wg sync.WaitGroup - for i := 0; i < 20; i++ { - wg.Add(1) - go func() { - defer wg.Done() + for range 20 { + wg.Go(func() { status.Enter(reporter, status.PhaseBuildingImage, "") status.Leave(reporter, status.PhaseBuildingImage, "") - }() + }) } wg.Wait() From 5f6e271871399d4e9ecc0a138c258f9f09674cd5 Mon Sep 17 00:00:00 2001 From: Samuel K Date: Tue, 28 Jul 2026 17:03:04 -0500 Subject: [PATCH 03/21] fix(lint): drop forbidigo nolint by writing to os.Stdout directly fmt.Printf is disallowed by the forbidigo rule; fmt.Fprintf(os.Stdout, ...) is the same output without needing a suppression comment. --- cmd/workspace/task.go | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/cmd/workspace/task.go b/cmd/workspace/task.go index 51df3b556..185af2a64 100644 --- a/cmd/workspace/task.go +++ b/cmd/workspace/task.go @@ -69,8 +69,7 @@ func (cmd *taskListCmd) run() error { return json.NewEncoder(os.Stdout).Encode(states) } for _, s := range states { - //nolint:forbidigo // CLI stdout output - fmt.Printf("%s\t%s\t%s\t%s\n", s.ID, s.Status, s.Command, s.WorkspaceID) + _, _ = fmt.Fprintf(os.Stdout, "%s\t%s\t%s\t%s\n", s.ID, s.Status, s.Command, s.WorkspaceID) } return nil } @@ -214,7 +213,7 @@ func emitTaskTransition(last, current *task.State, emitJSON bool) { _ = config2.WriteStatusJSON(os.Stdout, event) return } - fmt.Printf("task %s: %s\n", current.ID, current.Phase) //nolint:forbidigo // CLI stdout output + _, _ = fmt.Fprintf(os.Stdout, "task %s: %s\n", current.ID, current.Phase) } // --- cancel --- @@ -259,7 +258,7 @@ func (cmd *taskCancelCmd) run(id string) error { if emitJSON { return json.NewEncoder(os.Stdout).Encode(state) } - fmt.Printf("task %s: canceled\n", state.ID) //nolint:forbidigo // CLI stdout output + _, _ = fmt.Fprintf(os.Stdout, "task %s: canceled\n", state.ID) return nil } @@ -317,7 +316,7 @@ func reportTaskState(state *task.State, emitJSON bool) error { return reportTaskStateJSON(state) } - fmt.Printf("task %s: %s\n", state.ID, state.Status) //nolint:forbidigo // CLI stdout output + _, _ = fmt.Fprintf(os.Stdout, "task %s: %s\n", state.ID, state.Status) if state.Status == task.StatusFailed { return fmt.Errorf("%s", state.Error) } From 6078bae54004a51691c4197ae1602903676ad823 Mon Sep 17 00:00:00 2001 From: Samuel K Date: Tue, 28 Jul 2026 17:31:27 -0500 Subject: [PATCH 04/21] fix(e2e): implement the async task protocol in the mock devsy CLI workspace_up now submits `up --detach` and polls `workspace task logs --follow` instead of running `up` to completion. The e2e mock CLI still implemented the old synchronous `up` only: `--detach` was silently swallowed by parseArgs's generic flag skip, so mock-devsy wrote plain progress text instead of a {kind:"task", id} envelope. cli.run's JSON.parse then threw, the up handler took its catch path, and the wizard's success check never ran -- so the "Open Workspace" button never appeared, timing out the three affected e2e specs. Adds a `task` verb to the mock (list/get/logs/cancel/rm) backed by a `tasks` map in the persisted mock state, and makes `up --detach` write the {kind:"task", id} envelope and defer the actual workspace/progress simulation to `task logs --follow`, mirroring pkg/task and pkg/devcontainer/config/envelope.go. --- desktop/e2e/fixtures/mock-devsy.cjs | 136 +++++++++++++++++++++++++--- 1 file changed, 125 insertions(+), 11 deletions(-) diff --git a/desktop/e2e/fixtures/mock-devsy.cjs b/desktop/e2e/fixtures/mock-devsy.cjs index 4458c2905..c5b02a9b8 100755 --- a/desktop/e2e/fixtures/mock-devsy.cjs +++ b/desktop/e2e/fixtures/mock-devsy.cjs @@ -75,6 +75,8 @@ function defaultState() { context: "default", }, ], + // Background tasks submitted via `workspace up --detach`, keyed by id. + tasks: {}, } } @@ -92,6 +94,8 @@ function saveState(state) { } const state = loadState() +// Older persisted state files predate the task store. +state.tasks = state.tasks || {} const rawArgs = process.argv.slice(2) @@ -190,17 +194,8 @@ function handleSsh() { process.exit(0) } -function handleUp(args) { - const { positional, idFlag, providerFlag, ideFlag } = parseArgs(args) - const source = positional[0] - out("Resolving source...") - out("Pulling image...") - out("Starting workspace...") - out("Workspace ready.") - const wsId = - idFlag || - (source ? source.split("/").pop().replace(".git", "") : "") || - "workspace" +// Adds a completed workspace to state, mirroring what a real `up` produces. +function materializeWorkspace(wsId, source, providerFlag, ideFlag) { state.workspaces.push({ id: wsId, uid: `ws-${Date.now()}`, @@ -213,9 +208,127 @@ function handleUp(args) { context: "default", }) saveState(state) +} + +function handleUp(args) { + const { positional, idFlag, providerFlag, ideFlag } = parseArgs(args) + const source = positional[0] + const wsId = + idFlag || + (source ? source.split("/").pop().replace(".git", "") : "") || + "workspace" + + // `--detach` submits a background task instead of running to completion; + // the caller polls it via `workspace task logs --follow`. + if (args.includes("--detach")) { + const taskId = `task-${Date.now()}` + state.tasks[taskId] = { + id: taskId, + status: "pending", + source, + wsId, + providerFlag, + ideFlag, + } + saveState(state) + out({ kind: "task", id: taskId }) + process.exit(0) + return + } + + out("Resolving source...") + out("Pulling image...") + out("Starting workspace...") + out("Workspace ready.") + materializeWorkspace(wsId, source, providerFlag, ideFlag) process.exit(0) } +// Handlers for `workspace task `, backing the up --detach flow above. +function handleTaskList() { + out(Object.values(state.tasks)) +} + +function handleTaskGet(args) { + const t = state.tasks[args[0]] + if (!t) { + process.stderr.write(`mock-devsy: task not found: ${args[0]}\n`) + process.exit(1) + return + } + out(t) +} + +function handleTaskLogs(args) { + const t = state.tasks[args[0]] + if (!t) { + process.stderr.write(`mock-devsy: task not found: ${args[0]}\n`) + process.exit(1) + return + } + + out("Resolving source...") + out("Pulling image...") + out("Starting workspace...") + out("Workspace ready.") + materializeWorkspace(t.wsId, t.source, t.providerFlag, t.ideFlag) + t.status = "succeeded" + saveState(state) + + // Single-line NDJSON result envelope, matching pkg/devcontainer/config/envelope.go. + out( + JSON.stringify({ + kind: "result", + outcome: "success", + containerId: `mock-container-${t.wsId}`, + remoteUser: "vscode", + remoteWorkspaceFolder: `/workspaces/${t.wsId}`, + }), + ) + process.exit(0) +} + +function handleTaskCancel(args) { + const t = state.tasks[args[0]] + if (t) { + t.status = "canceled" + saveState(state) + } + out({}) +} + +function handleTaskRm(args) { + delete state.tasks[args[0]] + saveState(state) + out({}) +} + +function handleTask(args) { + const verb = args[0] + const rest = args.slice(1) + const handlers = { + list: () => handleTaskList(), + ls: () => handleTaskList(), + get: () => handleTaskGet(rest), + describe: () => handleTaskGet(rest), + show: () => handleTaskGet(rest), + logs: () => handleTaskLogs(rest), + attach: () => handleTaskLogs(rest), + cancel: () => handleTaskCancel(rest), + stop: () => handleTaskCancel(rest), + rm: () => handleTaskRm(rest), + delete: () => handleTaskRm(rest), + remove: () => handleTaskRm(rest), + } + const handler = handlers[verb] + if (!handler) { + process.stderr.write(`mock-devsy: unknown task subcommand '${verb}'\n`) + process.exit(2) + return + } + handler() +} + function handleStop(args) { const { positional } = parseArgs(args) const wsId = positional[0] @@ -267,6 +380,7 @@ const workspaceHandlers = { status: handleStatus, ssh: handleSsh, up: handleUp, + task: handleTask, stop: handleStop, delete: handleDelete, rename: handleRename, From d212862b02c096562b056975e5fef56c0334ff53 Mon Sep 17 00:00:00 2001 From: Samuel K Date: Wed, 29 Jul 2026 00:09:47 -0500 Subject: [PATCH 05/21] style: update comments --- cmd/workspace/task.go | 10 - cmd/workspace/up/up.go | 3 - desktop/e2e/fixtures/mock-devsy.cjs | 2 - desktop/src/main/ipc.ts | 4 - desktop/src/shared/cli-error.ts | 4 - docs/rfcs/async-workspace-up.md | 441 ---------------------------- 6 files changed, 464 deletions(-) delete mode 100644 docs/rfcs/async-workspace-up.md diff --git a/cmd/workspace/task.go b/cmd/workspace/task.go index 185af2a64..2acd00b1c 100644 --- a/cmd/workspace/task.go +++ b/cmd/workspace/task.go @@ -31,8 +31,6 @@ func NewTaskCmd(globalFlags *flags.GlobalFlags) *cobra.Command { return taskCmd } -// --- list --- - type taskListCmd struct { *flags.GlobalFlags } @@ -74,8 +72,6 @@ func (cmd *taskListCmd) run() error { return nil } -// --- get --- - type taskGetCmd struct { *flags.GlobalFlags } @@ -109,8 +105,6 @@ func (cmd *taskGetCmd) run(id string) error { return reportTaskState(state, emitJSON) } -// --- logs --- - type taskLogsCmd struct { *flags.GlobalFlags @@ -262,8 +256,6 @@ func (cmd *taskCancelCmd) run(id string) error { return nil } -// --- rm --- - type taskRmCmd struct { *flags.GlobalFlags @@ -301,8 +293,6 @@ func (cmd *taskRmCmd) run(id string) error { return store.Delete(id, cmd.Force) } -// --- shared --- - func resolveEmitJSON(g *flags.GlobalFlags) (bool, error) { mode, err := output.ResolveMode(g.ResultFormat) if err != nil { diff --git a/cmd/workspace/up/up.go b/cmd/workspace/up/up.go index 352115d78..948f452bb 100644 --- a/cmd/workspace/up/up.go +++ b/cmd/workspace/up/up.go @@ -54,15 +54,12 @@ type UpCmd struct { // Read via Changed() so unset is distinguishable from explicit false. pullFromInsideContainerFlag bool - // See cmd.runDetached. Detach bool // Set only on the detached child re-exec (--task-id). taskID string - // Out receives result/error JSON envelopes; nil falls back to os.Stdout. Out io.Writer - // Set at the start of Run; nil until then. statusReporter status.Reporter } diff --git a/desktop/e2e/fixtures/mock-devsy.cjs b/desktop/e2e/fixtures/mock-devsy.cjs index c5b02a9b8..4b0db6d43 100755 --- a/desktop/e2e/fixtures/mock-devsy.cjs +++ b/desktop/e2e/fixtures/mock-devsy.cjs @@ -75,7 +75,6 @@ function defaultState() { context: "default", }, ], - // Background tasks submitted via `workspace up --detach`, keyed by id. tasks: {}, } } @@ -94,7 +93,6 @@ function saveState(state) { } const state = loadState() -// Older persisted state files predate the task store. state.tasks = state.tasks || {} const rawArgs = process.argv.slice(2) diff --git a/desktop/src/main/ipc.ts b/desktop/src/main/ipc.ts index d028f0c2f..9d69fe800 100644 --- a/desktop/src/main/ipc.ts +++ b/desktop/src/main/ipc.ts @@ -167,8 +167,6 @@ export function registerIpcHandlers(deps: IpcDependencies): { import("node:child_process").ChildProcess >() // Maps workspaceId -> task id for still-running `up --detach` submissions. - // Stopping the tunnelProcesses entry (a poller) doesn't stop provisioning; - // only cancelling the task by id does. const activeUpTasks = new Map() /** @@ -840,8 +838,6 @@ export function registerIpcHandlers(deps: IpcDependencies): { }, wsId, ) - // Tracked so quiesceWorkspace can also stop the poller; the task - // cancellation above is what actually stops the provisioning itself. tunnelProcesses.set(wsId, child) return cmdId diff --git a/desktop/src/shared/cli-error.ts b/desktop/src/shared/cli-error.ts index b78af6033..f135cabad 100644 --- a/desktop/src/shared/cli-error.ts +++ b/desktop/src/shared/cli-error.ts @@ -11,10 +11,6 @@ export interface CliLogLine { [key: string]: unknown } -/** - * NDJSON envelope shapes written by `workspace up --result-format json`. - * Mirrors pkg/devcontainer/config/envelope.go — keep in sync. - */ export type CliEnvelopeKind = "status" | "result" | "error" export interface CliStatusEnvelope { diff --git a/docs/rfcs/async-workspace-up.md b/docs/rfcs/async-workspace-up.md deleted file mode 100644 index b47be9730..000000000 --- a/docs/rfcs/async-workspace-up.md +++ /dev/null @@ -1,441 +0,0 @@ -# RFC: Asynchronous `workspace up` and structured status - -## Status - -Draft. No backward compatibility is preserved by design — breaking changes to -the CLI JSON output contract, the `Runner` interface, the tunnel proto, and -the desktop IPC contract are in scope. - -## Problem - -`workspace up` is a single synchronous call chain from CLI flag parsing all -the way to IDE launch. Every layer blocks on the one below it and returns a -single terminal value: - -``` -cmd/workspace/up.Run - -> devsyUp -> dispatchClient (blocks on client.Up / tunnel RunUpServer) - -> devcontainer.Runner.Up(ctx, options, timeout) (*config.Result, error) - -> runSingleContainer / runDockerCompose - -> build (docker/buildkit exec, blocking) - -> driver.Run (blocking) - -> setupContainer -> inner tunnel -> RunPreAttachHooks (blocking up to waitFor) - -> finalizeUp (SSH config, tunnel, IDE open) -``` - -The only two structured signals that cross a process boundary are: - -- `tunnel.LogMessage` — a level (`DEBUG/INFO/DONE/WARNING/ERROR`) + freeform - string, sent continuously. -- `tunnel.SendResult` — the final `config.Result`, sent exactly once, at the - end. - -There is no phase, step, or percentage concept anywhere. The desktop app -compensates by spawning the CLI as a subprocess, parsing every stdout line as -a zap JSON log record, and detecting completion by -`line.includes('"outcome":"success"')` (`desktop/src/main/ipc.ts:755`) — a -string sniff on log text, not a contract. - -Consequences: - -- The CLI cannot report progress faster than log lines happen to convey it. -- The desktop app cannot show "building image" vs "running postCreate" vs - "waiting for port" as distinct states — only "still running" or "done". -- Nothing downstream of `up` (IDE open, SSH tunnel) can start until the - entire blocking chain returns, even when only the pieces it actually - depends on (e.g. container is running, `waitFor` phase reached) are ready. - -## Goals - -1. Every meaningful step of `up` (resolve config, initializeCommand, build - image, start container, inject agent, run each lifecycle hook, waitFor, - ready) emits a structured, typed status event as it starts/finishes — - not just a log line. -2. The event stream crosses every process boundary that today only carries - `LogMessage`/`Result` (the outer CLI<->agent tunnel and the inner - container-setup tunnel). -3. The CLI's `--result-format json` mode emits status as NDJSON as it - happens, not only a single envelope at the end. -4. The desktop app consumes the structured stream directly — no string - sniffing on log content for control flow. -5. Steps that don't depend on each other can run concurrently where the - underlying operation allows it (e.g. pulling/building images for - docker-compose services, or preparing SSH config while lifecycle hooks - past `waitFor` are still running in the background). -6. `up` can return control to its caller as soon as the work is submitted, - without waiting for the workspace to be ready — the caller (CLI user or - desktop UI) decides whether to wait or poll. This is the actual meaning of - "asynchronous" this RFC targets: not reordering the devcontainer-spec's - inherently sequential build/inject/hook chain (that chain has real data - dependencies and reordering it would be incorrect), but decoupling - *submission* from *completion* the way the platform/daemon path already - decouples them for remote workspaces. - -## Non-goals - -- Changing the devcontainer.json spec or lifecycle hook semantics themselves. -- A general pub/sub system for arbitrary future features — scope is the - `up`/`build`/`setup` path. -- Preserving the current single-shot JSON result envelope shape unchanged; - it is superseded by the event stream plus a final terminal event. - -## Design - -### 1. Status/phase model (`pkg/devcontainer/status`) - -A closed set of phases mirroring the real dependency chain of `up`: - -```go -type Phase string - -const ( - PhaseCloningRepository Phase = "cloning_repository" - PhaseResolvingConfig Phase = "resolving_config" - PhaseInitializeCommand Phase = "initialize_command" - PhaseBuildingImage Phase = "building_image" - PhaseStartingContainer Phase = "starting_container" - PhaseInjectingAgent Phase = "injecting_agent" - PhaseRunningLifecycleHook Phase = "running_lifecycle_hook" // + HookName field - PhaseWaitingFor Phase = "waiting_for" - PhaseReady Phase = "ready" - PhaseFailed Phase = "failed" -) - -type Event struct { - Phase Phase - Step string // e.g. hook name, service name; empty when not applicable - Started bool // true = entering phase, false = phase complete - Err string // set only when Phase == PhaseFailed -} - -type Reporter interface { - Report(Event) -} -``` - -A `NopReporter` and a `ChannelReporter` (buffered channel + drain goroutine, -same shape as the existing `tunnelLogger` pattern) are the two initial -implementations. - -### 2. `Runner` interface takes a `Reporter` - -```go -type Runner interface { - Up(ctx context.Context, options UpOptions, timeout time.Duration, r status.Reporter) (*config.Result, error) - // ... unchanged otherwise -} -``` - -Breaking change: every existing caller of `Up` must pass a reporter -(`status.Nop()` is fine where nobody listens yet). `run.go`, `single.go`, -`compose_up.go`, `build.go`, `setup.go`, `lifecyclehooks.go` each call -`r.Report(...)` at the start/end of their step instead of (or in addition to) -`log.Info`. - -### 3. Tunnel proto: `StatusUpdate` RPC - -```protobuf -message StatusUpdate { - string phase = 1; - string step = 2; - bool started = 3; - string error = 4; -} - -service Tunnel { - ... - rpc Status(StatusUpdate) returns (Empty) {} -} -``` - -Both tunnels that exist today (`RunUpServer` for the outer CLI<->agent -session, `RunSetupServer` for the inner container-setup session) forward -`status.Reporter` events over this RPC the same way `tunnelLogger` forwards -`LogMessage` today (buffered chan + worker goroutine, `pkg/agent/tunnelserver/logger.go` -is the template). The host-side tunnel client fans inbound `StatusUpdate` -messages into a `status.Reporter` the CLI process owns. - -### 4. CLI: NDJSON status stream - -`cmd/workspace/up` gets a `status.Reporter` that, in JSON result-format mode, -writes one compact JSON object per event to stdout immediately (not -buffered until exit), followed by the existing terminal `ResultEnvelope`/ -`ErrorEnvelope` as the final line. Plain-text mode renders the same events -as human-readable progress lines (this can replace a good number of today's -ad hoc `log.Info` progress messages in the up path). - -This is a breaking change to the CLI's JSON output contract: consumers that -assumed exactly one JSON object on stdout must now expect a stream and -identify the terminal one (e.g. by a `"kind":"result"` discriminator field -added to `ResultEnvelope`/`ErrorEnvelope`, vs `"kind":"status"` on events). - -### 5. Desktop: structured consumption - -`desktop/src/main/ipc.ts`'s `workspace_up` handler stops treating stdout as -opaque log text for control flow. It parses each NDJSON line, and: - -- `"kind":"status"` lines update a per-workspace phase state machine and are - forwarded to the renderer as a new `workspace-status` IPC event (structured - `{ commandId, phase, step, started }`), replacing the current - `command-progress` line-batching for control-flow purposes (raw log text - can still be forwarded for the log viewer, but is no longer load-bearing). -- `"kind":"result"` lines replace the `line.includes('"outcome":"success"')` - sniff. - -`desktop/src/shared/cli-error.ts` (or a new `cli-status.ts`) gains the shared -TypeScript types for `StatusEvent`/`ResultEnvelope` mirroring the Go JSON -shapes, generated or hand-kept in sync (no codegen exists today; hand-kept -with a comment pointing at the Go source of truth is acceptable for now). - -### 6. Two execution flows, and standard verbs for the durable one - -This is the piece that actually decouples "start provisioning" from "wait -for it to finish," per Goal 6. - -**Synchronous** (default, unchanged): `workspace up ` attaches and -waits, exactly as before — no reason to force a UX change on the common -interactive case. - -**Durable**: `workspace up --detach`/`-d` submits the same pipeline -as a background task and returns immediately, printing -`{"kind":"task","id":"..."}`. "Durable" here means the task's state -survives past the submitting process's exit — it's a file, not something -held in memory by a process someone has to keep watching. - -**`pkg/task`** is that file's home: one JSON file per task under -`PathManager.TaskDir()` (`/tasks/.json`), written atomically -(temp file + rename). `State` carries `Status` (`pending` / `running` / -`succeeded` / `failed`), the most recent `Phase`/`Step`, labels -(`Command`, `WorkspaceID`) for listing, a `PID` for cancellation, and — once -terminal — the final `Error` or `*config.Result`. `Task.Reporter()` returns -a `status.Reporter` that persists each event into the file. - -**`cmd/workspace/up/detach.go`** implements submission: create a task, then -re-exec the current binary with this invocation's original arguments — -`--detach` swapped for the hidden `--task-id ` — as a background process -(`pkg/command.StartBackground`, the same primitive already used for deferred -lifecycle hooks). The parent does no devcontainer work itself. **The -re-exec'd child** runs through the exact same `execute` → `Run` → -`executeDevsyUp` → `finalizeUp` path an attached `up` always has — nothing -about the devcontainer-spec-ordered pipeline changes; `Run` just tees its -`status.Reporter` (`status.Tee`) into both the normal NDJSON/log output *and* -the task file, and calls `Succeed`/`Fail` on the task at the same points it -would otherwise just return. The child *is* today's `up`, observed from two -places instead of one — which is why devcontainer-spec ordering needed no -verification beyond "did we change anything in `pkg/devcontainer`?" (no). - -**The task resource gets standard verbs** (`cmd/workspace/task.go`), rather -than one command overloaded with `--wait`/`--cancel` flags, so it reads like -every other resource-oriented CLI (kubectl, docker, gh): - -| Command | Aliases | Verb category | -|---|---|---| -| `workspace task list` | `ls` | List | -| `workspace task get ` | `describe`, `show` | Get/Show/Describe | -| `workspace task logs [-f\|--follow]` | `attach` | Logs/Tail (docker/kubectl `-f`) | -| `workspace task cancel ` | `stop` | Stop/Cancel | -| `workspace task rm [--force]` | `delete`, `remove` | Delete/Rm/Remove | - -`get`/`logs` without `-f` are the same operation (one snapshot); `logs -f` -polls on an interval (`--interval`, default 500ms), printing the phase -observed at each poll when it differs from the last, then the final -result/error envelope (exit code reflects success/failure). `State` stores -only the current phase/step, not a history — this is a latest-state -snapshot, not a replay log, so a burst of transitions between two polls can -be collapsed to just the last one observed. `cancel` sends the signal -recorded via `Task.SetPID` (set by the detached child on itself) and marks -the task failed with `task.ErrCanceled`; canceling successfully is reported -as success even though the task's own terminal state is "failed." `rm` -refuses to delete a non-terminal task unless `--force`, which cancels it -first (stopping the worker) before deleting — mirroring `kubectl delete ---force`/`docker rm -f`, both of which also stop the resource, not just -remove its record. - -This gives every caller — interactive CLI, script, or desktop — the choice -DevContainer-remote already had for platform workspaces: submit and don't -wait, or submit and wait, with status observable in both cases, using verbs -that don't need this doc to explain them. - -**Desktop** (`desktop/src/main/ipc.ts`): `workspace_up` now submits via -`up --detach` (`cli.run`, one-shot), then streams `workspace task logs ---follow` instead of streaming `up` directly — the NDJSON envelope parsing -is unchanged, since `logs` emits the same envelope shapes `up` does. A new -`activeUpTasks: Map` tracks the task backing the real -provisioning process; `quiesceWorkspace` (run before stop/delete) now issues -`workspace task cancel ` against it before touching any child process, -because the child it *can* see (the `logs --follow` poller) is not the -process doing the work — killing only the poller would silently leave -provisioning running. This also means the desktop app can now query a -task's status even if Electron itself was restarted while `up` was running, -which it could not do before (progress was tied to the lifetime of the -child process it spawned). - -### Concurrency - -Structured status is a prerequisite for observing overlap, not a substitute -for it — the point of this RFC is that steps of `up` which don't depend on -each other actually run at the same time. Implemented so far: - -- **Feature resolution** (`pkg/devcontainer/feature.getUserFeatures`): each - user-configured feature is fetched (OCI pull, tarball download, or local - read) concurrently via `errgroup`, instead of one at a time. `lockfileState` - gained a mutex around its `entries` map since `record` is now called from - multiple goroutines. Race-tested with `go test -race`. -- **Agent binary prefetch** (`runner.prefetchAgentBinary`, called from - `runSingleContainer`): the agent binary download/cache-read starts on a - background goroutine at the same time as container resolution - (`resolveContainer`, which builds and starts the container), instead of - starting only once the container is already running and injection is - ready. It's best-effort and silently discards its result on failure — the - real acquisition on the injection path re-runs from scratch and simply - hits the now-warm on-disk cache in the common case. -- **`daemonclient` (platform/pro) `Up` path** - (`pkg/client/clientimplementation/daemonclient/up.go`): `UpOptions` gained - a `Reporter status.Reporter` field, threaded through - `waitTaskDone`/`observeTask`/`printLogs`. The remote task the daemon - observes runs the same `devsy` CLI, so its forwarded log stream already - contains the same `kind":"status"` NDJSON lines a local `up` emits; a new - `statusSniffingWriter` splits that stream into lines, forwards recognized - status lines to the `Reporter` (via the new `config.ParseStatusLine` - helper), and passes every other line through to the existing zap-log - renderer unchanged. This does not make the call return before the remote - task finishes — that's a correctness requirement, not a gap: `Up` must - return the final `*config.Result`, which only exists once the remote task - completes — but it does mean this path now participates in the same - structured-status model as the local path instead of being informationally - disconnected from it (previously: opaque zap JSON only, `status.Reporter` - events did not exist for this path at all). Covered by - `statusSniffingWriter`'s dedicated unit tests (race-tested). - -Already-async before this RFC, noted for completeness: `DeferredHooks` (the -lifecycle hooks that run after `waitFor`) are launched as a detached -background OS process (`cmd/internal/agentcontainer/setup.go:startDeferredHooks`) -rather than awaited inline — `up` does not block on them today. Likewise, -docker-compose service builds are not a sequential loop in this codebase to -begin with: `runComposeBuild` shells out once to `docker compose ... build` -(`compose_build.go:runComposeBuild`), and the Compose CLI itself parallelizes -independent service builds — there's no Go-level per-service loop here to -convert. - -Remaining candidate, not yet converted: - -- Per-lifecycle-hook granularity: the inner container-setup tunnel currently - reports one `PhaseRunningLifecycleHook` span for the whole pre-`waitFor` - hook sequence rather than one per hook. - -This RFC does not mandate converting every step in one pass — each -conversion above was verified independently (build + `go test -race`) rather -than landed as one big-bang rewrite. - -## Breaking changes summary - -| Area | Before | After | -|---|---|---| -| `devcontainer.Runner.Up` | `(ctx, options, timeout) (*Result, error)` | `(ctx, options, timeout, status.Reporter) (*Result, error)` | -| Tunnel proto | `Log` + `SendResult` only | adds `Status` RPC | -| CLI JSON stdout | one `ResultEnvelope`/`ErrorEnvelope` object | NDJSON stream of status events + one terminal result/error object, each tagged with a `kind` discriminator | -| Desktop completion detection | `stdout.includes('"outcome":"success"')` | parse `"kind":"result"` NDJSON line | -| Desktop progress IPC | `command-progress` (raw log line batches) | new `workspace-status` (structured phase) + `command-progress` (raw log, display-only) | -| `client.UpOptions` | no status visibility | gains `Reporter status.Reporter` | -| `workspace up` | always attaches and waits | `--detach`/`-d` submits a `pkg/task` and returns immediately; default behavior unchanged | -| Local task observability | none — progress only exists on the invoking process's stdout | `workspace task {list,get,logs}` reads `pkg/task`'s on-disk state, independent of any live process | -| Canceling an in-progress `up` | kill the CLI process desktop happened to spawn | `workspace task cancel ` (PID-tracked, works regardless of who's watching) | -| Desktop `workspace_up` IPC | spawns `up` directly, streams its output | spawns `up --detach` (one-shot), then streams `workspace task logs --follow`; `quiesceWorkspace` cancels the task, not just its poller | -| `client.Status` | `Running`/`Busy`/`Stopped`/`NotFound` | adds `Provisioning` and `Failed`: `workspaceClient.Status()` looks at the most recently started `up` task on record for the workspace and reports one of them instead of `NotFound` when it's more informative — `Provisioning` while in flight, `Failed` if it errored — so `workspace status` doesn't claim a workspace was never started, or silently forget a failed attempt | -| `git clone` progress | raw `--progress` output logged as-is (unreadable: `\r`-delimited updates collapse into one giant line once captured as log lines) | new `status.PhaseCloningRepository`: `pkg/git`'s `progressWriter` parses the `\r`-delimited updates and reports them as `status.Event`s (thinned to every 10%) instead of logging them; plain, JSON, and desktop consumption all get clone progress through the same pipeline as every other phase, for free | -| `log.Writer` (subprocess Stdout/Stderr, ~65 call sites) | raw byte passthrough straight to the stderr sink, bypassing the configured encoder — every JSON-mode log call except this one produced `{"level":...}`, this one leaked unencoded text into the stream | line-buffers and calls `Info`/`Debug`/`Error` per complete line, so subprocess output goes through the same json/text/logfmt encoder as everything else; contradicted its own doc comment ("writes each line as a log entry") before this fix | - -## Rollout - -1. Land `pkg/devcontainer/status` (event model + Nop/Channel reporters) with - no behavior change — nothing calls `Report` with anything meaningful yet. -2. Thread `status.Reporter` through `Runner.Up` and its call chain, emitting - real events at each existing step boundary (no new proto yet — reporter is - in-process only for the direct/non-tunneled drivers first). -3. Add the `StatusUpdate` tunnel RPC and wire both tunnel sessions to forward - events across the process boundary. -4. Switch CLI JSON output to NDJSON + discriminator, update `pkg/output`. -5. Update desktop `ipc.ts`/`cli.ts`/shared types to consume structured events; - remove the string-sniff. -6. Opportunistically parallelize the concurrency candidates listed above, - one at a time, each covered by its own e2e test under `e2e/tests/up*`. -7. Land `pkg/task` and `workspace up --detach`, reusing the reporter - plumbing from steps 1-4 unchanged — the detached child is the same - attached-mode code path, just with its reporter teed into a task file. - No changes to `pkg/devcontainer` were needed for this step, which is - itself evidence the devcontainer-spec ordering wasn't touched. -8. Wire the desktop app onto the submit/attach model: - `desktop/src/main/ipc.ts`'s `workspace_up` handler now submits via - `up --detach` (`cli.run`, one-shot — returns as soon as the task exists), - then streams the task's status instead of streaming `up` directly. A new - `activeUpTasks: Map` tracks the task backing the real - (detached) provisioning process; `quiesceWorkspace` (called before - stop/delete) now cancels it before touching any live child process, - because the child it *can* see (the poller) is no longer the process - actually doing the work — killing only the poller would silently leave - provisioning running. This needed `pkg/task` to gain PID tracking - (`Task.SetPID`, set by the detached child on itself as soon as it opens - its task) and `Task.Cancel` (`pkg/command.Kill` on that PID, then - `Fail(ErrCanceled)`) — the "some other commands will be impacted" part - of the original ask materializing. -9. Replace the ad hoc `workspace task [--wait] [--cancel]` command with - standard verbs on the task resource — `list`/`get`/`logs [-f]`/`cancel`/ - `rm [--force]` — matching kubectl/docker/gh conventions instead of one - command overloaded with flags. `pkg/task` gained `CreateOptions` - (`Command`/`WorkspaceID` labels, so `list` is useful) and `Store.Delete` - for `rm`. Desktop's two call sites (poll, cancel) were updated to the new - verbs in the same change — see the table above. -10. Close the loop between `pkg/task` and container-level status: - `workspaceClient.Status()` (`pkg/client/clientimplementation`) now - reports `client.StatusProvisioning` or `client.StatusFailed` in place of - `StatusNotFound` based on the most recently started `up` task on record - for the workspace (`workspaceClient.taskStatusOverride()` lists - `pkg/task`, matches on `WorkspaceID`+`Command`, and picks the task with - the latest `StartedAt`) — `Provisioning` while it's non-terminal, - `Failed` if it ended in error, and no override at all if it succeeded - (a successful task implies nothing; if the container's still missing - after that, something else removed it, and `NotFound` is the honest - answer). The detached child corrects its task's `WorkspaceID` label to - the client's resolved ID once known (`Task.SetWorkspaceID`, called from - `Run`) rather than trusting the submitting process's guess (raw - `--id`/source string), since the lookup depends on that label being - accurate. -11. Route git clone progress through the same structured pipeline instead of - dropping it. First pass tried reformatting git's raw `--progress` text - for readability (a client-side writer splitting on `\r`); simpler and - more correct was noticing that no code anywhere parses that text for a - UI, so there was nothing to preserve about the raw format at all — the - real fix was giving clone progress the same treatment every other phase - already gets. `status` gains `PhaseCloningRepository`; `pkg/git` gains - `WithProgressReporter` (a `RepoOption`) and a `progressWriter` that - parses `\r`-delimited "label: NN% (x/y)" updates and reports them as - `status.Event`s (thinned to every 10%, exact-duplicate frames dropped) - instead of writing them to the log — informational lines like "Cloning - into '...'" still log normally. The reporter is threaded from the - agent's tunnel client (`tunnelserver.NewTunnelStatusReporter`, already - built in step 3) through `prepareWorkspace`/`prepareGitWorkspace` into - `agent.CloneWorkspaceParams`, so it crosses the same tunnel boundary as - every other phase and reaches the CLI's NDJSON/plain output and - desktop's `workspace-status` IPC event for free — no new plumbing - needed on either of those ends. - -Each phase should be independently mergeable and leaves the system in a -working (if not yet fully async) state — the plan is incremental even though -no compatibility shims are kept between phases. - -12. Harden `pkg/task` against the two ways it's actually multi-writer: a - detached worker reporting progress and a separate `task cancel`/`rm - --force` process both mutate the same state file, and `Get`/`Delete` - take a caller-supplied ID from argv. `Store.update` now holds an OS file - lock (`github.com/gofrs/flock`, one `.lock` file per task, 5s timeout) - around each read-modify-write, replacing a process-local `sync.Mutex` - that only ever protected against races within one process. `Cancel` - reads the terminal check and applies the state transition inside that - same locked update instead of as a separate Get-then-Fail, so a - concurrent worker report can't land between them. `Store.path` rejects - any ID that isn't a single clean path component, closing a path - traversal a raw `../../etc/passwd`-style ID could otherwise reach. - `pkg/command.kill` (Unix) also stopped discarding `syscall.Kill`'s - error — it now returns real failures and only treats "process already - gone" (`ESRCH`) as success, so `Cancel` can't silently claim a kill - succeeded when it didn't. From 5b95539e9d6ffb25a358695f82f680f9e51cb3a6 Mon Sep 17 00:00:00 2001 From: Samuel K Date: Wed, 29 Jul 2026 09:59:13 -0500 Subject: [PATCH 06/21] fix(task): preserve terminal task state and tighten status reporting A canceled task whose worker finished afterwards had its cancellation reason erased, because Succeed/Fail/Report mutated state without the Terminal() guard Cancel already used. Guard all three so the recorded outcome reflects why the task actually stopped. Also from review of the async workspace-up changes: - Delete holds the per-id flock across the status check and removal, so a concurrent update's atomic rename can't recreate the task. - Reject a non-positive --interval instead of panicking in NewTicker. - Serialize jsonStatusReporter writes; events arrive from both the local pipeline and the tunnel Status RPC goroutine, and interleaving would corrupt the NDJSON framing callers parse line-by-line. - Report PhaseReady completion in plain output, and route task-init failures through reportErr so JSON mode emits an error envelope. - Sniff trailing bytes left without a newline so a final status envelope is reported rather than leaking into the caller's stream as raw JSON. - Order the tunnel's default log reporter ahead of caller-supplied options so it no longer clobbers them, and forward up's reporter into the platform path. - Bound the podman probe with a context so an unreachable machine can't hang the test on the socket. - Await the prior up child's exit before overwriting its map entry, which otherwise orphaned it beyond the reach of any later kill. --- cmd/workspace/task.go | 3 ++ cmd/workspace/up/agent.go | 1 + cmd/workspace/up/status.go | 11 +++++ cmd/workspace/up/up.go | 4 +- desktop/src/main/ipc.ts | 37 ++++++++-------- .../clientimplementation/daemonclient/up.go | 10 ++++- .../clientimplementation/workspace_client.go | 13 ++++-- pkg/compose/helper_test.go | 7 ++- pkg/task/store.go | 36 +++++++++------- pkg/task/task.go | 15 +++++++ pkg/task/task_test.go | 43 +++++++++++++++++++ 11 files changed, 138 insertions(+), 42 deletions(-) diff --git a/cmd/workspace/task.go b/cmd/workspace/task.go index 2acd00b1c..08fa690b3 100644 --- a/cmd/workspace/task.go +++ b/cmd/workspace/task.go @@ -155,6 +155,9 @@ func (cmd *taskLogsCmd) run(id string) error { if err != nil { return fmt.Errorf("parse --interval: %w", err) } + if interval <= 0 { + return fmt.Errorf("--interval must be positive, got %q", cmd.Interval) + } return followTask(context.Background(), store, followTaskOptions{ id: id, interval: interval, diff --git a/cmd/workspace/up/agent.go b/cmd/workspace/up/agent.go index 801424d59..cbb603931 100644 --- a/cmd/workspace/up/agent.go +++ b/cmd/workspace/up/agent.go @@ -183,6 +183,7 @@ func (cmd *UpCmd) devsyUpMachine( AgentCommand: "up", TunnelOptions: []tunnelserver.Option{ tunnelserver.WithPlatformOptions(&cmd.Platform), + tunnelserver.WithStatusReporter(cmd.reporter()), }, }, ) diff --git a/cmd/workspace/up/status.go b/cmd/workspace/up/status.go index a6b16494d..595c39c0f 100644 --- a/cmd/workspace/up/status.go +++ b/cmd/workspace/up/status.go @@ -2,6 +2,7 @@ package up import ( "io" + "sync" config2 "github.com/devsy-org/devsy/pkg/devcontainer/config" "github.com/devsy-org/devsy/pkg/devcontainer/status" @@ -17,11 +18,17 @@ func newStatusReporter(emitJSON bool, out io.Writer) status.Reporter { return plainStatusReporter{} } +// jsonStatusReporter serializes writes: events arrive both from the local up +// pipeline and from the tunnel's Status RPC goroutine, and interleaved writes +// would corrupt the NDJSON framing callers parse line-by-line. type jsonStatusReporter struct { + mu sync.Mutex out io.Writer } func (r *jsonStatusReporter) Report(e status.Event) { + r.mu.Lock() + defer r.mu.Unlock() _ = config2.WriteStatusJSON(r.out, e) } @@ -35,6 +42,10 @@ func (plainStatusReporter) Report(e status.Event) { log.Infof("up: %s: %s", phaseLabel(e.Phase), e.Step) case e.Started: log.Infof("up: %s", phaseLabel(e.Phase)) + case e.Phase == status.PhaseReady: + // Only the terminal phase's completion is worth a line; logging every + // Leave would just double every phase already announced on entry. + log.Infof("up: %s", phaseLabel(e.Phase)) } } diff --git a/cmd/workspace/up/up.go b/cmd/workspace/up/up.go index 948f452bb..54005a36d 100644 --- a/cmd/workspace/up/up.go +++ b/cmd/workspace/up/up.go @@ -225,7 +225,7 @@ func (cmd *UpCmd) Run( t, err := cmd.openTask() if err != nil { - return err + return reportErr(err, emitJSON, out) } if t != nil { cmd.statusReporter = status.Tee(cmd.statusReporter, t.Reporter()) @@ -233,7 +233,7 @@ func (cmd *UpCmd) Run( // resolved ID; client.Status() looks tasks up by this label. if err := t.SetWorkspaceID(client.Workspace()); err != nil { failTask(t, err) - return err + return reportErr(err, emitJSON, out) } } diff --git a/desktop/src/main/ipc.ts b/desktop/src/main/ipc.ts index 9d69fe800..63774db98 100644 --- a/desktop/src/main/ipc.ts +++ b/desktop/src/main/ipc.ts @@ -170,12 +170,11 @@ export function registerIpcHandlers(deps: IpcDependencies): { const activeUpTasks = new Map() /** - * Terminate every desktop-spawned process tied to a workspace and wait for - * them to actually exit. Called before stop/delete so destructive CLI runs - * don't race with in-flight children that are still appending to the - * workspace's log directory. + * Cancel a workspace's in-flight `up` task and terminate its status-follow + * child, awaiting exit so the handle is never dropped while the process is + * still alive (which would orphan it beyond the reach of any later kill). */ - async function quiesceWorkspace(workspaceId: string): Promise { + async function cancelActiveUp(workspaceId: string): Promise { const taskId = activeUpTasks.get(workspaceId) if (taskId) { activeUpTasks.delete(workspaceId) @@ -197,6 +196,16 @@ export function registerIpcHandlers(deps: IpcDependencies): { tunnelProc.kill("SIGTERM") await tunnelExit } + } + + /** + * Terminate every desktop-spawned process tied to a workspace and wait for + * them to actually exit. Called before stop/delete so destructive CLI runs + * don't race with in-flight children that are still appending to the + * workspace's log directory. + */ + async function quiesceWorkspace(workspaceId: string): Promise { + await cancelActiveUp(workspaceId) await Promise.all([cli.cancelFor(workspaceId), pty.cancelFor(workspaceId)]) } @@ -749,20 +758,10 @@ export function registerIpcHandlers(deps: IpcDependencies): { () => logStore.closeLog(logPath), ) - // Kill any existing tunnel/poller process and cancel any still-running - // task for this workspace before starting a new one. - const existing = tunnelProcesses.get(wsId) - if (existing) { - existing.kill("SIGTERM") - tunnelProcesses.delete(wsId) - } - const existingTask = activeUpTasks.get(wsId) - if (existingTask) { - activeUpTasks.delete(wsId) - await cli - .run(["workspace", "task", "cancel", existingTask]) - .catch(() => undefined) - } + // Tear down any prior run for this workspace before starting a new one, + // awaiting exit so its handle isn't lost when the tunnelProcesses entry + // is overwritten below. + await cancelActiveUp(wsId) // Submit: returns almost immediately with the background task's id. let taskId: string diff --git a/pkg/client/clientimplementation/daemonclient/up.go b/pkg/client/clientimplementation/daemonclient/up.go index d26f90360..fb6ef0871 100644 --- a/pkg/client/clientimplementation/daemonclient/up.go +++ b/pkg/client/clientimplementation/daemonclient/up.go @@ -481,12 +481,20 @@ func (w *statusSniffingWriter) Write(p []byte) (int, error) { return len(p), nil } +// Close flushes any trailing bytes left without a newline, sniffing them the +// same way Write does so a final status envelope on an unterminated line is +// reported rather than leaking into the caller's stream as raw JSON. func (w *statusSniffingWriter) Close() error { if w.buf.Len() == 0 { return nil } - _, err := w.next.Write(w.buf.Bytes()) + trailing := w.buf.String() w.buf.Reset() + if event, ok := config.ParseStatusLine(trailing); ok { + w.reporter.Report(event) + return nil + } + _, err := w.next.Write([]byte(trailing)) return err } diff --git a/pkg/client/clientimplementation/workspace_client.go b/pkg/client/clientimplementation/workspace_client.go index dbd4ce344..10ab4eeed 100644 --- a/pkg/client/clientimplementation/workspace_client.go +++ b/pkg/client/clientimplementation/workspace_client.go @@ -1150,6 +1150,15 @@ func runTunnelServer( opts BuildAgentClientOptions, stdoutReader, stdinWriter *os.File, ) (*config2.Result, error) { + // The log reporter goes first so a caller-supplied reporter in + // TunnelOptions overrides it rather than being clobbered by it — + // tunnelserver applies options in order, each replacing the reporter. + tunnelOptions := []tunnelserver.Option{ + tunnelserver.WithStatusReporter(status.NewLogReporter()), + tunnelserver.WithGitToken(opts.CLIOptions.GitToken), + } + tunnelOptions = append(tunnelOptions, opts.TunnelOptions...) + result, err := tunnelserver.RunUpServer( ctx, stdoutReader, @@ -1157,9 +1166,7 @@ func runTunnelServer( opts.WorkspaceClient.AgentInjectGitCredentials(opts.CLIOptions), opts.WorkspaceClient.AgentInjectDockerCredentials(opts.CLIOptions), opts.WorkspaceClient.WorkspaceConfig(), - append(opts.TunnelOptions, - tunnelserver.WithGitToken(opts.CLIOptions.GitToken), - tunnelserver.WithStatusReporter(status.NewLogReporter()))..., + tunnelOptions..., ) if err != nil { return nil, fmt.Errorf("run tunnel server: %w", err) diff --git a/pkg/compose/helper_test.go b/pkg/compose/helper_test.go index df365a07f..eefff0fc7 100644 --- a/pkg/compose/helper_test.go +++ b/pkg/compose/helper_test.go @@ -5,6 +5,7 @@ import ( "os/exec" "strings" "testing" + "time" "github.com/devsy-org/devsy/pkg/docker" "github.com/stretchr/testify/suite" @@ -191,7 +192,11 @@ func (s *HelperTestSuite) TestNewComposeHelperPodmanRuntimeUsesDockerCommand() { // machine without a started VM would otherwise see this test silently // fall through to a different compose backend and fail on the assertion // below rather than skip. - if exec.Command("podman", "compose", "version").Run() != nil { + // Bounded: an unreachable podman machine can leave the probe hanging on + // the socket rather than failing fast. + probeCtx, cancelProbe := context.WithTimeout(context.Background(), 15*time.Second) + defer cancelProbe() + if exec.CommandContext(probeCtx, "podman", "compose", "version").Run() != nil { s.T().Skip("podman compose not reachable in test environment (is podman machine running?)") } diff --git a/pkg/task/store.go b/pkg/task/store.go index 976f8ec47..c6b73ecbf 100644 --- a/pkg/task/store.go +++ b/pkg/task/store.go @@ -66,26 +66,30 @@ func (s *Store) Open(id string) *Task { // Delete errors if the task is still pending or running unless force is set. func (s *Store) Delete(id string, force bool) error { - if !force { - state, err := s.Get(id) - if err != nil { - return err - } - if !state.Status.Terminal() { - return fmt.Errorf( - "task %s is still %s; cancel it first or delete with force", - id, state.Status, - ) - } - } path, err := s.path(id) if err != nil { return err } - if err := os.Remove(path); err != nil { - return fmt.Errorf("delete task %s: %w", id, err) - } - return nil + // Locked across the check and the removal so a concurrent update can't + // commit its atomic rename after the check and recreate the task. + return s.withLock(id, func() error { + if !force { + state, err := s.Get(id) + if err != nil { + return err + } + if !state.Status.Terminal() { + return fmt.Errorf( + "task %s is still %s; cancel it first or delete with force", + id, state.Status, + ) + } + } + if err := os.Remove(path); err != nil { + return fmt.Errorf("delete task %s: %w", id, err) + } + return nil + }) } func (s *Store) Get(id string) (*State, error) { diff --git a/pkg/task/task.go b/pkg/task/task.go index 45ad7d5e3..a5d6f311a 100644 --- a/pkg/task/task.go +++ b/pkg/task/task.go @@ -84,8 +84,13 @@ func (t *Task) Reporter() status.Reporter { return taskReporter{task: t} } +// Succeed is a no-op once the task is terminal, so a worker that finishes +// concurrently with a Cancel can't overwrite the canceled state with success. func (t *Task) Succeed(result *config.Result) error { return t.store.update(t.id, func(s *State) { + if s.Status.Terminal() { + return + } s.Status = StatusSucceeded s.Result = result s.Error = "" @@ -115,8 +120,13 @@ func (t *Task) Cancel() error { return command.Kill(strconv.Itoa(pid)) } +// Fail preserves an existing terminal state, so the error a canceled worker +// reports on its way out doesn't mask ErrCanceled as the reason it stopped. func (t *Task) Fail(err error) error { return t.store.update(t.id, func(s *State) { + if s.Status.Terminal() { + return + } s.Status = StatusFailed if err != nil { s.Error = err.Error() @@ -130,6 +140,11 @@ type taskReporter struct { func (r taskReporter) Report(e status.Event) { _ = r.task.store.update(r.task.id, func(s *State) { + // A terminal task is done being described; late events from a worker + // still unwinding must not resurrect it or rewrite its outcome. + if s.Status.Terminal() { + return + } if e.Phase == status.PhaseFailed { s.Error = e.Err return diff --git a/pkg/task/task_test.go b/pkg/task/task_test.go index fbd9cc327..beb12a033 100644 --- a/pkg/task/task_test.go +++ b/pkg/task/task_test.go @@ -237,6 +237,49 @@ func TestCancelOnTerminalTaskIsNoop(t *testing.T) { } } +// A canceled task's outcome must survive its worker finishing afterwards: +// the worker doesn't observe the cancel and reports its own result on the way +// out, which would otherwise erase the reason the task actually stopped. +func TestTerminalStateSurvivesLateWorkerReports(t *testing.T) { + for _, tc := range []struct { + name string + late func(*Task) error + }{ + {"succeed", func(tk *Task) error { return tk.Succeed(&config.Result{}) }}, + {"fail", func(tk *Task) error { return tk.Fail(errors.New("worker exited")) }}, + {"report", func(tk *Task) error { + tk.Reporter().Report(status.Event{Phase: status.PhaseReady, Started: true}) + return nil + }}, + } { + t.Run(tc.name, func(t *testing.T) { + store := newTestStore(t) + tsk, err := store.Create(CreateOptions{}) + if err != nil { + t.Fatalf("Create: %v", err) + } + if err := tsk.Cancel(); err != nil { + t.Fatalf("Cancel: %v", err) + } + + if err := tc.late(tsk); err != nil { + t.Fatalf("late report: %v", err) + } + + state, err := store.Get(tsk.ID()) + if err != nil { + t.Fatalf("Get: %v", err) + } + if state.Status != StatusFailed { + t.Errorf("status = %q, want %q", state.Status, StatusFailed) + } + if state.Error != ErrCanceled.Error() { + t.Errorf("error = %q, want %q", state.Error, ErrCanceled.Error()) + } + }) + } +} + func TestCreateStoresLabels(t *testing.T) { store := newTestStore(t) tsk, err := store.Create(CreateOptions{Command: "up", WorkspaceID: "my-ws"}) From 480427b07ade4cee1cb5b839d787f7764d0477c8 Mon Sep 17 00:00:00 2001 From: Samuel K Date: Wed, 29 Jul 2026 14:49:29 -0500 Subject: [PATCH 07/21] fix(desktop): keep detached up tasks cancellable Two ways a detached `up` could keep running with nothing left to stop it: cancelActiveUp dropped the workspace's task mapping before cancelling and swallowed the failure, so a failed cancel left the task running while stop/delete proceeded to tear the workspace down under it. Hold the entry until the cancel lands and let the failure propagate; the up handler reports it and skips submitting a replacement, and stop/delete callers already surface the rejection as a toast. Concurrent workspace_up calls for one workspace also both passed the cancel step before either registered its task, orphaning the first. Serialize the cancel/submit/register sequence per workspace, and scope the cleanup callbacks to the task they belong to so a finishing session can't clear a newer one's entry. --- .../src/main/__tests__/ipc-up-tasks.test.ts | 155 +++++++++++++ desktop/src/main/ipc.ts | 206 +++++++++++------- 2 files changed, 282 insertions(+), 79 deletions(-) create mode 100644 desktop/src/main/__tests__/ipc-up-tasks.test.ts diff --git a/desktop/src/main/__tests__/ipc-up-tasks.test.ts b/desktop/src/main/__tests__/ipc-up-tasks.test.ts new file mode 100644 index 000000000..2bb054e2e --- /dev/null +++ b/desktop/src/main/__tests__/ipc-up-tasks.test.ts @@ -0,0 +1,155 @@ +// @vitest-environment node +import { EventEmitter } from "node:events" +import { beforeEach, describe, expect, it, vi } from "vitest" + +const handlers = new Map unknown>() + +vi.mock("electron", () => ({ + app: { getPath: () => "/tmp", getVersion: () => "0.0.0" }, + dialog: {}, + ipcMain: { + handle: (channel: string, fn: (...args: unknown[]) => unknown) => { + handlers.set(channel, fn) + }, + on: () => undefined, + }, +})) + +vi.mock("../analytics.js", () => ({ + hashWorkspaceRef: (v: string) => v, + trackEvent: () => undefined, +})) + +const { registerIpcHandlers } = await import("../ipc.js") + +function invokeUp(workspaceId: string): Promise { + const handler = handlers.get("workspace_up") + if (!handler) throw new Error("workspace_up not registered") + return handler({}, { source: workspaceId, workspaceId }) as Promise +} + +function invokeStop(workspaceId: string): Promise { + const handler = handlers.get("workspace_stop") + if (!handler) throw new Error("workspace_stop not registered") + return handler({}, { workspaceId }) as Promise +} + +/** A child that reports itself still alive, so cancel must await its exit. */ +function fakeChild() { + const child = new EventEmitter() as EventEmitter & { + exitCode: number | null + signalCode: string | null + kill: (signal?: string) => void + } + child.exitCode = null + child.signalCode = null + child.kill = () => { + setTimeout(() => { + child.exitCode = 0 + child.emit("close") + }, 0) + } + return child +} + +function setup(overrides: { run?: (args: string[]) => Promise } = {}) { + const calls: string[][] = [] + const cli = { + run: vi.fn(async (args: string[]) => { + calls.push(args) + if (overrides.run) return overrides.run(args) + if (args.includes("--detach")) return { kind: "task", id: "task-1" } + return {} + }), + runStreaming: vi.fn(async () => fakeChild()), + cancelFor: vi.fn(async () => undefined), + } + const deps = { + cli, + state: { + workspaceContext: () => "ctx", + providerList: () => [], + }, + logStore: { + createLogFile: () => "/tmp/log.txt", + appendLog: () => true, + closeLog: async () => undefined, + onDrain: async () => undefined, + }, + pty: { cancelFor: vi.fn(async () => undefined) }, + getMainWindow: () => null, + } + // biome-ignore lint/suspicious/noExplicitAny: partial test doubles + const api = registerIpcHandlers(deps as any) + return { cli, calls, api } +} + +describe("workspace_up detached task tracking", () => { + beforeEach(() => { + handlers.clear() + vi.clearAllMocks() + }) + + it("cancels the prior task before submitting a replacement", async () => { + const { calls } = setup() + + await invokeUp("ws-1") + await invokeUp("ws-1") + + const cancels = calls.filter((a) => a.includes("cancel")) + expect(cancels).toEqual([["workspace", "task", "cancel", "task-1"]]) + }) + + it("serializes concurrent submissions so neither task is left orphaned", async () => { + let seq = 0 + const { calls } = setup({ + run: async (args) => { + if (args.includes("--detach")) { + // Yield, so an unserialized handler would interleave here and both + // submissions would pass the cancel step before either registered. + await new Promise((r) => setTimeout(r, 5)) + seq += 1 + return { kind: "task", id: `task-${seq}` } + } + return {} + }, + }) + + await Promise.all([invokeUp("ws-1"), invokeUp("ws-1")]) + + // The second submission must have observed and cancelled the first. + const cancels = calls.filter((a) => a.includes("cancel")) + expect(cancels).toEqual([["workspace", "task", "cancel", "task-1"]]) + }) + + it("keeps the task cancellable when cancellation fails", async () => { + let failCancel = true + const { calls } = setup({ + run: async (args) => { + if (args.includes("cancel")) { + if (failCancel) throw new Error("cancel boom") + return {} + } + if (args.includes("--detach")) return { kind: "task", id: "task-1" } + return {} + }, + }) + + await invokeUp("ws-1") + + // A failed cancel must abort the replacement rather than starting a + // second `up` alongside the task that is still running. + await invokeUp("ws-1") + expect(calls.filter((a) => a.includes("--detach"))).toHaveLength(1) + + // The mapping is retained, so a later attempt can still cancel task-1; + // dropping it would leave the task running with no handle to stop it. + failCancel = false + await invokeStop("ws-1") + const cancels = calls.filter((a) => a.includes("cancel")) + expect(cancels).toEqual([ + ["workspace", "task", "cancel", "task-1"], + ["workspace", "task", "cancel", "task-1"], + ]) + }) +}) diff --git a/desktop/src/main/ipc.ts b/desktop/src/main/ipc.ts index 63774db98..e2943b807 100644 --- a/desktop/src/main/ipc.ts +++ b/desktop/src/main/ipc.ts @@ -168,6 +168,34 @@ export function registerIpcHandlers(deps: IpcDependencies): { >() // Maps workspaceId -> task id for still-running `up --detach` submissions. const activeUpTasks = new Map() + // Serializes the cancel/submit/register sequence per workspace. + const upSubmitChains = new Map>() + + /** + * Run fn after any prior invocation for the same workspace has settled. + * Concurrent `up` submissions would otherwise both pass the cancel step + * before either registers its task, leaving the first one running with no + * map entry to cancel it by. + */ + function serializePerWorkspace( + workspaceId: string, + fn: () => Promise, + ): Promise { + // Chain entries never reject, so a failed run doesn't poison the queue. + const prev = upSubmitChains.get(workspaceId) ?? Promise.resolve() + const next = prev.then(fn) + const settled = next.then( + () => undefined, + () => undefined, + ) + upSubmitChains.set(workspaceId, settled) + void settled.then(() => { + if (upSubmitChains.get(workspaceId) === settled) { + upSubmitChains.delete(workspaceId) + } + }) + return next + } /** * Cancel a workspace's in-flight `up` task and terminate its status-follow @@ -177,10 +205,14 @@ export function registerIpcHandlers(deps: IpcDependencies): { async function cancelActiveUp(workspaceId: string): Promise { const taskId = activeUpTasks.get(workspaceId) if (taskId) { - activeUpTasks.delete(workspaceId) - await cli - .run(["workspace", "task", "cancel", taskId]) - .catch(() => undefined) + // Retain the mapping until the cancel lands, and let a failure reject: + // dropping it here would leave the detached task running with nothing + // left to cancel it by, and callers would destroy the workspace out + // from under a live `up`. + await cli.run(["workspace", "task", "cancel", taskId]) + if (activeUpTasks.get(workspaceId) === taskId) { + activeUpTasks.delete(workspaceId) + } } const tunnelProc = tunnelProcesses.get(workspaceId) @@ -758,88 +790,104 @@ export function registerIpcHandlers(deps: IpcDependencies): { () => logStore.closeLog(logPath), ) - // Tear down any prior run for this workspace before starting a new one, - // awaiting exit so its handle isn't lost when the tunnelProcesses entry - // is overwritten below. - await cancelActiveUp(wsId) + return serializePerWorkspace(wsId, async () => { + // Tear down any prior run for this workspace before starting a new + // one, awaiting exit so its handle isn't lost when the + // tunnelProcesses entry is overwritten below. + let taskId: string + try { + await cancelActiveUp(wsId) + // Submit: returns almost immediately with the background task's id. + const submitted = await cli.run<{ kind: string; id: string }>([ + ...cliArgs, + "--detach", + ]) + taskId = submitted.id + } catch (error) { + const err = error as Error & { cliError?: CLIError } + void sink.done(formatLogLine(err.message, "ERROR"), { + level: "error", + cliError: err.cliError ?? { + code: "up_failed", + message: err.message, + }, + }) + return cmdId + } + activeUpTasks.set(wsId, taskId) - // Submit: returns almost immediately with the background task's id. - let taskId: string - try { - const submitted = await cli.run<{ kind: string; id: string }>([ - ...cliArgs, - "--detach", - ]) - taskId = submitted.id - } catch (error) { - const err = error as Error & { cliError?: CLIError } - void sink.done(formatLogLine(err.message, "ERROR"), { - level: "error", - cliError: err.cliError ?? { code: "up_failed", message: err.message }, - }) - return cmdId - } - activeUpTasks.set(wsId, taskId) - - let signalledDone = false - const child = await cli.runStreaming( - ["workspace", "task", "logs", taskId, "--follow"], - (line, stream) => { - if (signalledDone) return - - // Structured NDJSON envelopes only ever appear on stdout; stderr - // carries freeform zap log lines. - const envelope = stream === "stdout" ? parseCliEnvelope(line) : undefined - - if (envelope?.kind === "status") { - deps.getMainWindow()?.webContents.send("workspace-status", { - commandId: cmdId, - workspaceId: wsId, - phase: envelope.phase, - step: envelope.step, - started: envelope.started, - error: envelope.error, - }) - return + // Only clear the entry while it still refers to this task; a newer + // submission may already own it and must stay cancellable. + const releaseTask = () => { + if (activeUpTasks.get(wsId) === taskId) { + activeUpTasks.delete(wsId) } + } - const formatted = formatLogLine(line) + let signalledDone = false + const child = await cli.runStreaming( + ["workspace", "task", "logs", taskId, "--follow"], + (line, stream) => { + if (signalledDone) return + + // Structured NDJSON envelopes only ever appear on stdout; stderr + // carries freeform zap log lines. + const envelope = + stream === "stdout" ? parseCliEnvelope(line) : undefined + + if (envelope?.kind === "status") { + deps.getMainWindow()?.webContents.send("workspace-status", { + commandId: cmdId, + workspaceId: wsId, + phase: envelope.phase, + step: envelope.step, + started: envelope.started, + error: envelope.error, + }) + return + } - if (envelope?.kind === "result") { - signalledDone = true - activeUpTasks.delete(wsId) - void sink.done(formatted) - return - } + const formatted = formatLogLine(line) - if (envelope?.kind === "error") { - signalledDone = true - activeUpTasks.delete(wsId) - void sink.done(formatted, { - level: "error", - cliError: { code: "up_failed", message: envelope.message }, - }) - return - } + if (envelope?.kind === "result") { + signalledDone = true + releaseTask() + void sink.done(formatted) + return + } - if (!sink.line(formatted)) return logStore.onDrain(logPath) - }, - (code, cliError) => { - activeUpTasks.delete(wsId) - if (tunnelProcesses.get(wsId) === child) { - tunnelProcesses.delete(wsId) - } - if (signalledDone) return - void sink.done( - formatLogLine(`Exit code: ${code}`, code === 0 ? "INFO" : "ERROR"), - code === 0 ? undefined : { level: "error", cliError }, - ) - }, - wsId, - ) - tunnelProcesses.set(wsId, child) + if (envelope?.kind === "error") { + signalledDone = true + releaseTask() + void sink.done(formatted, { + level: "error", + cliError: { code: "up_failed", message: envelope.message }, + }) + return + } - return cmdId + if (!sink.line(formatted)) return logStore.onDrain(logPath) + }, + (code, cliError) => { + releaseTask() + if (tunnelProcesses.get(wsId) === child) { + tunnelProcesses.delete(wsId) + } + if (signalledDone) return + void sink.done( + formatLogLine( + `Exit code: ${code}`, + code === 0 ? "INFO" : "ERROR", + ), + code === 0 ? undefined : { level: "error", cliError }, + ) + }, + wsId, + ) + tunnelProcesses.set(wsId, child) + + return cmdId + }) }, ) From 545ad00a39c876586bdee3b6ccff19abff44f8cc Mon Sep 17 00:00:00 2001 From: Samuel K Date: Wed, 29 Jul 2026 15:02:03 -0500 Subject: [PATCH 08/21] refactor(status): move status package out of devcontainer The Phase/Event/Reporter model isn't devcontainer-specific; provider install/init flows want the same structured progress events. Move it to pkg/status before another pipeline imports it under a name that would misdescribe it. Import-path churn only; no behavior change. --- cmd/internal/container_tunnel.go | 2 +- cmd/workspace/task.go | 2 +- cmd/workspace/up/status.go | 2 +- cmd/workspace/up/up.go | 2 +- pkg/agent/tunnelserver/options.go | 2 +- pkg/agent/tunnelserver/status_sender.go | 2 +- pkg/agent/tunnelserver/tunnelserver.go | 2 +- pkg/client/client.go | 2 +- pkg/client/clientimplementation/daemonclient/stop.go | 2 +- pkg/client/clientimplementation/daemonclient/up.go | 2 +- pkg/client/clientimplementation/daemonclient/up_test.go | 2 +- pkg/client/clientimplementation/workspace_client.go | 2 +- pkg/devcontainer/build.go | 2 +- pkg/devcontainer/config/envelope.go | 2 +- pkg/devcontainer/run.go | 2 +- pkg/devcontainer/setup.go | 2 +- pkg/devcontainer/single.go | 2 +- pkg/{devcontainer => }/status/log.go | 0 pkg/{devcontainer => }/status/status.go | 5 +++-- pkg/{devcontainer => }/status/status_test.go | 0 pkg/task/task.go | 2 +- pkg/task/task_test.go | 2 +- 22 files changed, 22 insertions(+), 21 deletions(-) rename pkg/{devcontainer => }/status/log.go (100%) rename pkg/{devcontainer => }/status/status.go (92%) rename pkg/{devcontainer => }/status/status_test.go (100%) diff --git a/cmd/internal/container_tunnel.go b/cmd/internal/container_tunnel.go index e6077d2e3..6553eabe3 100644 --- a/cmd/internal/container_tunnel.go +++ b/cmd/internal/container_tunnel.go @@ -14,12 +14,12 @@ import ( pkgconfig "github.com/devsy-org/devsy/pkg/config" "github.com/devsy-org/devsy/pkg/devcontainer" "github.com/devsy-org/devsy/pkg/devcontainer/config" - "github.com/devsy-org/devsy/pkg/devcontainer/status" "github.com/devsy-org/devsy/pkg/encoding" cliflags "github.com/devsy-org/devsy/pkg/flags" "github.com/devsy-org/devsy/pkg/flags/names" "github.com/devsy-org/devsy/pkg/log" provider2 "github.com/devsy-org/devsy/pkg/provider" + "github.com/devsy-org/devsy/pkg/status" "github.com/spf13/cobra" ) diff --git a/cmd/workspace/task.go b/cmd/workspace/task.go index 08fa690b3..0a1c7c018 100644 --- a/cmd/workspace/task.go +++ b/cmd/workspace/task.go @@ -9,10 +9,10 @@ import ( "github.com/devsy-org/devsy/cmd/flags" config2 "github.com/devsy-org/devsy/pkg/devcontainer/config" - "github.com/devsy-org/devsy/pkg/devcontainer/status" cliflags "github.com/devsy-org/devsy/pkg/flags" "github.com/devsy-org/devsy/pkg/flags/names" "github.com/devsy-org/devsy/pkg/output" + "github.com/devsy-org/devsy/pkg/status" "github.com/devsy-org/devsy/pkg/task" "github.com/spf13/cobra" ) diff --git a/cmd/workspace/up/status.go b/cmd/workspace/up/status.go index 595c39c0f..6e1a9ab26 100644 --- a/cmd/workspace/up/status.go +++ b/cmd/workspace/up/status.go @@ -5,8 +5,8 @@ import ( "sync" config2 "github.com/devsy-org/devsy/pkg/devcontainer/config" - "github.com/devsy-org/devsy/pkg/devcontainer/status" "github.com/devsy-org/devsy/pkg/log" + "github.com/devsy-org/devsy/pkg/status" ) // newStatusReporter drives `up`'s progress output: one NDJSON status line diff --git a/cmd/workspace/up/up.go b/cmd/workspace/up/up.go index 54005a36d..bb1d60bab 100644 --- a/cmd/workspace/up/up.go +++ b/cmd/workspace/up/up.go @@ -13,13 +13,13 @@ import ( client2 "github.com/devsy-org/devsy/pkg/client" "github.com/devsy-org/devsy/pkg/config" config2 "github.com/devsy-org/devsy/pkg/devcontainer/config" - "github.com/devsy-org/devsy/pkg/devcontainer/status" "github.com/devsy-org/devsy/pkg/flags/names" "github.com/devsy-org/devsy/pkg/ide" "github.com/devsy-org/devsy/pkg/ide/opener" "github.com/devsy-org/devsy/pkg/log" "github.com/devsy-org/devsy/pkg/output" provider2 "github.com/devsy-org/devsy/pkg/provider" + "github.com/devsy-org/devsy/pkg/status" "github.com/devsy-org/devsy/pkg/telemetry" "github.com/devsy-org/devsy/pkg/util" "github.com/devsy-org/devsy/pkg/workspace" diff --git a/pkg/agent/tunnelserver/options.go b/pkg/agent/tunnelserver/options.go index 8e1ce8760..7bb09c604 100644 --- a/pkg/agent/tunnelserver/options.go +++ b/pkg/agent/tunnelserver/options.go @@ -6,9 +6,9 @@ import ( "github.com/devsy-org/api/pkg/devsy" "github.com/devsy-org/devsy/pkg/agent/tunnel" "github.com/devsy-org/devsy/pkg/devcontainer/config" - "github.com/devsy-org/devsy/pkg/devcontainer/status" "github.com/devsy-org/devsy/pkg/netstat" provider2 "github.com/devsy-org/devsy/pkg/provider" + "github.com/devsy-org/devsy/pkg/status" ) type Option func(*tunnelServer) *tunnelServer diff --git a/pkg/agent/tunnelserver/status_sender.go b/pkg/agent/tunnelserver/status_sender.go index d50b1a3c2..0ec524b6a 100644 --- a/pkg/agent/tunnelserver/status_sender.go +++ b/pkg/agent/tunnelserver/status_sender.go @@ -5,7 +5,7 @@ import ( "time" "github.com/devsy-org/devsy/pkg/agent/tunnel" - "github.com/devsy-org/devsy/pkg/devcontainer/status" + "github.com/devsy-org/devsy/pkg/status" ) // NewTunnelStatusReporter returns a status.Reporter that forwards each event diff --git a/pkg/agent/tunnelserver/tunnelserver.go b/pkg/agent/tunnelserver/tunnelserver.go index 2fc9ac7cd..ccc898e2a 100644 --- a/pkg/agent/tunnelserver/tunnelserver.go +++ b/pkg/agent/tunnelserver/tunnelserver.go @@ -16,7 +16,6 @@ import ( "github.com/devsy-org/devsy/pkg/agent/tunnel" pkgconfig "github.com/devsy-org/devsy/pkg/config" "github.com/devsy-org/devsy/pkg/devcontainer/config" - "github.com/devsy-org/devsy/pkg/devcontainer/status" "github.com/devsy-org/devsy/pkg/devsyconfig" "github.com/devsy-org/devsy/pkg/dockercredentials" "github.com/devsy-org/devsy/pkg/extract" @@ -27,6 +26,7 @@ import ( "github.com/devsy-org/devsy/pkg/netstat" "github.com/devsy-org/devsy/pkg/platform" provider2 "github.com/devsy-org/devsy/pkg/provider" + "github.com/devsy-org/devsy/pkg/status" "github.com/devsy-org/devsy/pkg/stdio" "github.com/moby/patternmatcher/ignorefile" "google.golang.org/grpc" diff --git a/pkg/client/client.go b/pkg/client/client.go index aafaa7e9a..c8cfdc385 100644 --- a/pkg/client/client.go +++ b/pkg/client/client.go @@ -8,8 +8,8 @@ import ( "github.com/devsy-org/api/pkg/devsy" "github.com/devsy-org/devsy/pkg/devcontainer/config" - "github.com/devsy-org/devsy/pkg/devcontainer/status" "github.com/devsy-org/devsy/pkg/provider" + "github.com/devsy-org/devsy/pkg/status" "golang.org/x/crypto/ssh" ) diff --git a/pkg/client/clientimplementation/daemonclient/stop.go b/pkg/client/clientimplementation/daemonclient/stop.go index c656184e7..65faee2cb 100644 --- a/pkg/client/clientimplementation/daemonclient/stop.go +++ b/pkg/client/clientimplementation/daemonclient/stop.go @@ -7,8 +7,8 @@ import ( managementv1 "github.com/devsy-org/api/pkg/apis/management/v1" clientpkg "github.com/devsy-org/devsy/pkg/client" - "github.com/devsy-org/devsy/pkg/devcontainer/status" "github.com/devsy-org/devsy/pkg/platform" + "github.com/devsy-org/devsy/pkg/status" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) diff --git a/pkg/client/clientimplementation/daemonclient/up.go b/pkg/client/clientimplementation/daemonclient/up.go index fb6ef0871..14bd8572a 100644 --- a/pkg/client/clientimplementation/daemonclient/up.go +++ b/pkg/client/clientimplementation/daemonclient/up.go @@ -18,11 +18,11 @@ import ( clientpkg "github.com/devsy-org/devsy/pkg/client" devsyconfig "github.com/devsy-org/devsy/pkg/config" "github.com/devsy-org/devsy/pkg/devcontainer/config" - "github.com/devsy-org/devsy/pkg/devcontainer/status" "github.com/devsy-org/devsy/pkg/log" "github.com/devsy-org/devsy/pkg/platform" platformclient "github.com/devsy-org/devsy/pkg/platform/client" "github.com/devsy-org/devsy/pkg/platform/kube" + "github.com/devsy-org/devsy/pkg/status" corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) diff --git a/pkg/client/clientimplementation/daemonclient/up_test.go b/pkg/client/clientimplementation/daemonclient/up_test.go index 21b35cfe4..69299bcc7 100644 --- a/pkg/client/clientimplementation/daemonclient/up_test.go +++ b/pkg/client/clientimplementation/daemonclient/up_test.go @@ -4,7 +4,7 @@ import ( "bytes" "testing" - "github.com/devsy-org/devsy/pkg/devcontainer/status" + "github.com/devsy-org/devsy/pkg/status" ) type recordingReporter struct { diff --git a/pkg/client/clientimplementation/workspace_client.go b/pkg/client/clientimplementation/workspace_client.go index 10ab4eeed..804031b15 100644 --- a/pkg/client/clientimplementation/workspace_client.go +++ b/pkg/client/clientimplementation/workspace_client.go @@ -19,12 +19,12 @@ import ( "github.com/devsy-org/devsy/pkg/compress" "github.com/devsy-org/devsy/pkg/config" config2 "github.com/devsy-org/devsy/pkg/devcontainer/config" - "github.com/devsy-org/devsy/pkg/devcontainer/status" "github.com/devsy-org/devsy/pkg/log" "github.com/devsy-org/devsy/pkg/options" "github.com/devsy-org/devsy/pkg/provider" "github.com/devsy-org/devsy/pkg/shell" "github.com/devsy-org/devsy/pkg/ssh" + "github.com/devsy-org/devsy/pkg/status" "github.com/devsy-org/devsy/pkg/task" "github.com/devsy-org/devsy/pkg/types" "github.com/gofrs/flock" diff --git a/pkg/devcontainer/build.go b/pkg/devcontainer/build.go index 0b5ac36cf..8e3506386 100644 --- a/pkg/devcontainer/build.go +++ b/pkg/devcontainer/build.go @@ -14,12 +14,12 @@ import ( "github.com/devsy-org/devsy/pkg/devcontainer/config" "github.com/devsy-org/devsy/pkg/devcontainer/feature" "github.com/devsy-org/devsy/pkg/devcontainer/metadata" - "github.com/devsy-org/devsy/pkg/devcontainer/status" "github.com/devsy-org/devsy/pkg/dockerfile" "github.com/devsy-org/devsy/pkg/driver" "github.com/devsy-org/devsy/pkg/image" "github.com/devsy-org/devsy/pkg/log" "github.com/devsy-org/devsy/pkg/provider" + "github.com/devsy-org/devsy/pkg/status" ) func (r *runner) build( diff --git a/pkg/devcontainer/config/envelope.go b/pkg/devcontainer/config/envelope.go index cce5dde6c..12a03328e 100644 --- a/pkg/devcontainer/config/envelope.go +++ b/pkg/devcontainer/config/envelope.go @@ -6,7 +6,7 @@ import ( "io" "strings" - "github.com/devsy-org/devsy/pkg/devcontainer/status" + "github.com/devsy-org/devsy/pkg/status" ) // Kind discriminates the NDJSON lines `up --result-format json` writes to diff --git a/pkg/devcontainer/run.go b/pkg/devcontainer/run.go index 2b8ad4543..ba91fdb10 100644 --- a/pkg/devcontainer/run.go +++ b/pkg/devcontainer/run.go @@ -8,13 +8,13 @@ import ( "github.com/devsy-org/devsy/pkg/clierr" "github.com/devsy-org/devsy/pkg/devcontainer/config" - "github.com/devsy-org/devsy/pkg/devcontainer/status" "github.com/devsy-org/devsy/pkg/driver" "github.com/devsy-org/devsy/pkg/driver/drivercreate" "github.com/devsy-org/devsy/pkg/encoding" "github.com/devsy-org/devsy/pkg/language" "github.com/devsy-org/devsy/pkg/log" "github.com/devsy-org/devsy/pkg/provider" + "github.com/devsy-org/devsy/pkg/status" ) // Runner drives the lifecycle of a single workspace's dev container. diff --git a/pkg/devcontainer/setup.go b/pkg/devcontainer/setup.go index 1bfb1c3e8..601bfb0e3 100644 --- a/pkg/devcontainer/setup.go +++ b/pkg/devcontainer/setup.go @@ -20,13 +20,13 @@ import ( "github.com/devsy-org/devsy/pkg/devcontainer/config" "github.com/devsy-org/devsy/pkg/devcontainer/crane" "github.com/devsy-org/devsy/pkg/devcontainer/sshtunnel" - "github.com/devsy-org/devsy/pkg/devcontainer/status" "github.com/devsy-org/devsy/pkg/docker" "github.com/devsy-org/devsy/pkg/driver" "github.com/devsy-org/devsy/pkg/flags/names" "github.com/devsy-org/devsy/pkg/ide" "github.com/devsy-org/devsy/pkg/log" provider2 "github.com/devsy-org/devsy/pkg/provider" + "github.com/devsy-org/devsy/pkg/status" "github.com/devsy-org/devsy/pkg/types" ) diff --git a/pkg/devcontainer/single.go b/pkg/devcontainer/single.go index 5974995c7..ce1767bde 100644 --- a/pkg/devcontainer/single.go +++ b/pkg/devcontainer/single.go @@ -15,10 +15,10 @@ import ( "github.com/devsy-org/devsy/pkg/daemon/agent" "github.com/devsy-org/devsy/pkg/devcontainer/config" "github.com/devsy-org/devsy/pkg/devcontainer/metadata" - "github.com/devsy-org/devsy/pkg/devcontainer/status" "github.com/devsy-org/devsy/pkg/driver" "github.com/devsy-org/devsy/pkg/language" "github.com/devsy-org/devsy/pkg/log" + "github.com/devsy-org/devsy/pkg/status" "github.com/devsy-org/devsy/pkg/telemetry/distinctid" ) diff --git a/pkg/devcontainer/status/log.go b/pkg/status/log.go similarity index 100% rename from pkg/devcontainer/status/log.go rename to pkg/status/log.go diff --git a/pkg/devcontainer/status/status.go b/pkg/status/status.go similarity index 92% rename from pkg/devcontainer/status/status.go rename to pkg/status/status.go index 5a120ae5d..11b24d7b9 100644 --- a/pkg/devcontainer/status/status.go +++ b/pkg/status/status.go @@ -1,5 +1,6 @@ -// Package status defines the structured progress events emitted during -// workspace up, in place of relying on freeform log text for control flow. +// Package status defines the structured progress events emitted by +// long-running commands, in place of relying on freeform log text for control +// flow. Phases are named per pipeline; workspace up is the first consumer. package status // Phase identifies a step in the up pipeline. diff --git a/pkg/devcontainer/status/status_test.go b/pkg/status/status_test.go similarity index 100% rename from pkg/devcontainer/status/status_test.go rename to pkg/status/status_test.go diff --git a/pkg/task/task.go b/pkg/task/task.go index a5d6f311a..f533295fc 100644 --- a/pkg/task/task.go +++ b/pkg/task/task.go @@ -12,7 +12,7 @@ import ( "github.com/devsy-org/devsy/pkg/command" "github.com/devsy-org/devsy/pkg/devcontainer/config" - "github.com/devsy-org/devsy/pkg/devcontainer/status" + "github.com/devsy-org/devsy/pkg/status" ) var ErrCanceled = errors.New("canceled") diff --git a/pkg/task/task_test.go b/pkg/task/task_test.go index beb12a033..32f6d99e7 100644 --- a/pkg/task/task_test.go +++ b/pkg/task/task_test.go @@ -8,7 +8,7 @@ import ( "time" "github.com/devsy-org/devsy/pkg/devcontainer/config" - "github.com/devsy-org/devsy/pkg/devcontainer/status" + "github.com/devsy-org/devsy/pkg/status" ) func newTestStore(t *testing.T) *Store { From c7952dc05fe81a3a59af5a39cf3bc763b5d34944 Mon Sep 17 00:00:00 2001 From: Samuel K Date: Wed, 29 Jul 2026 18:36:04 -0500 Subject: [PATCH 09/21] fix(task): reconcile abandoned tasks and close up/stop races MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four findings from review of the async workspace-up changes: A detached worker killed before recording a result left its task non-terminal forever, so client.Status reported Provisioning indefinitely for a workspace with no container. Reconcile against the worker's recorded PID: a non-terminal task whose process is gone is marked failed. Tasks with no PID yet are still starting, and Fail preserves an existing terminal state so a cancellation reason isn't overwritten. quiesceWorkspace cancelled directly instead of joining the per-workspace chain workspace_up uses, so a stop landing between an up's submit and its activeUpTasks.set saw no mapping to cancel — the task then registered and outlived the teardown. Enqueue the quiesce on the same chain. WithStatusReporter installed a nil reporter over the Nop default, which would panic on the next status event. Ignore nil. workspace_up trusted the submit response's id; a missing one was stored and later used to cancel a task that doesn't exist, orphaning the real one. Also covers the streamed-envelope path in the workspace_up tests, which had no coverage: status envelopes emitting workspace-status, stderr lines not being parsed as envelopes, and result/error envelopes releasing the task. --- .../src/main/__tests__/ipc-up-tasks.test.ts | 98 ++++++++++++++++- desktop/src/main/ipc.ts | 18 +++- pkg/agent/tunnelserver/options.go | 8 +- .../clientimplementation/workspace_client.go | 15 ++- .../workspace_client_status_test.go | 33 ++++++ pkg/task/store.go | 36 +++++++ pkg/task/task.go | 5 +- pkg/task/task_test.go | 102 ++++++++++++++++++ 8 files changed, 305 insertions(+), 10 deletions(-) diff --git a/desktop/src/main/__tests__/ipc-up-tasks.test.ts b/desktop/src/main/__tests__/ipc-up-tasks.test.ts index 2bb054e2e..5a55b3cd7 100644 --- a/desktop/src/main/__tests__/ipc-up-tasks.test.ts +++ b/desktop/src/main/__tests__/ipc-up-tasks.test.ts @@ -61,9 +61,35 @@ function setup(overrides: { run?: (args: string[]) => Promise } = {}) { if (args.includes("--detach")) return { kind: "task", id: "task-1" } return {} }), - runStreaming: vi.fn(async () => fakeChild()), + // Captures the follower's callbacks so tests can drive NDJSON lines and + // the exit through the same path the real child would. + runStreaming: vi.fn( + async ( + _args: string[], + onLine: (line: string, stream: "stdout" | "stderr") => void, + onExit: (code: number, cliError?: unknown) => void, + ) => { + stream = { onLine, onExit } + return fakeChild() + }, + ), cancelFor: vi.fn(async () => undefined), } + let stream: { + onLine: (line: string, s: "stdout" | "stderr") => void + onExit: (code: number, cliError?: unknown) => void + } | null = null + // A real window so the workspace-status send path is exercised; returning + // null (as before) meant that branch never ran. + const sent: Array<{ channel: string; payload: Record }> = [] + const win = { + webContents: { + send: (channel: string, payload: Record) => { + sent.push({ channel, payload }) + }, + }, + isDestroyed: () => false, + } const deps = { cli, state: { @@ -77,11 +103,21 @@ function setup(overrides: { run?: (args: string[]) => Promise } = {}) { onDrain: async () => undefined, }, pty: { cancelFor: vi.fn(async () => undefined) }, - getMainWindow: () => null, + getMainWindow: () => win, } // biome-ignore lint/suspicious/noExplicitAny: partial test doubles const api = registerIpcHandlers(deps as any) - return { cli, calls, api } + return { cli, calls, api, sent, stream: () => stream } +} + +function statusEnvelope(phase: string, step?: string) { + return JSON.stringify({ + kind: "status", + pipeline: "workspace_up", + phase, + ...(step ? { step } : {}), + started: true, + }) } describe("workspace_up detached task tracking", () => { @@ -152,4 +188,60 @@ describe("workspace_up detached task tracking", () => { ["workspace", "task", "cancel", "task-1"], ]) }) + + it("forwards status envelopes as workspace-status events", async () => { + const { sent, stream } = setup() + await invokeUp("ws-1") + + stream()?.onLine(statusEnvelope("building_image"), "stdout") + + const statuses = sent.filter((s) => s.channel === "workspace-status") + expect(statuses).toHaveLength(1) + expect(statuses[0].payload).toMatchObject({ + workspaceId: "ws-1", + phase: "building_image", + started: true, + }) + }) + + it("does not treat stderr lines as envelopes", async () => { + // Structured envelopes only ever appear on stdout; stderr carries zap logs + // that may happen to be JSON. + const { sent, stream } = setup() + await invokeUp("ws-1") + + stream()?.onLine(statusEnvelope("building_image"), "stderr") + + expect(sent.filter((s) => s.channel === "workspace-status")).toHaveLength(0) + }) + + it("releases the task on a result envelope, before the exit callback", async () => { + const { calls, stream } = setup() + await invokeUp("ws-1") + + stream()?.onLine( + JSON.stringify({ kind: "result", outcome: "success" }), + "stdout", + ) + // The follower exits afterwards; the task must already be released, so a + // subsequent up has nothing to cancel. + stream()?.onExit(0) + await invokeUp("ws-1") + + expect(calls.filter((a) => a.includes("cancel"))).toEqual([]) + }) + + it("releases the task on an error envelope", async () => { + const { calls, stream } = setup() + await invokeUp("ws-1") + + stream()?.onLine( + JSON.stringify({ kind: "error", outcome: "error", message: "boom" }), + "stdout", + ) + stream()?.onExit(1) + await invokeUp("ws-1") + + expect(calls.filter((a) => a.includes("cancel"))).toEqual([]) + }) }) diff --git a/desktop/src/main/ipc.ts b/desktop/src/main/ipc.ts index e2943b807..37efc0b1b 100644 --- a/desktop/src/main/ipc.ts +++ b/desktop/src/main/ipc.ts @@ -237,8 +237,16 @@ export function registerIpcHandlers(deps: IpcDependencies): { * workspace's log directory. */ async function quiesceWorkspace(workspaceId: string): Promise { - await cancelActiveUp(workspaceId) - await Promise.all([cli.cancelFor(workspaceId), pty.cancelFor(workspaceId)]) + // On the same chain as workspace_up: a stop landing between an up's submit + // and its activeUpTasks.set would see no mapping to cancel, and the task + // would then register and outlive the teardown. + await serializePerWorkspace(workspaceId, async () => { + await cancelActiveUp(workspaceId) + await Promise.all([ + cli.cancelFor(workspaceId), + pty.cancelFor(workspaceId), + ]) + }) } /** @@ -802,6 +810,12 @@ export function registerIpcHandlers(deps: IpcDependencies): { ...cliArgs, "--detach", ]) + // A missing id would be stored and later used to cancel a task that + // does not exist, silently orphaning the real one. Fail loudly here + // so the catch below reports it like any other submit failure. + if (!submitted?.id) { + throw new Error("workspace up --detach returned no task id") + } taskId = submitted.id } catch (error) { const err = error as Error & { cliError?: CLIError } diff --git a/pkg/agent/tunnelserver/options.go b/pkg/agent/tunnelserver/options.go index 7bb09c604..fcaafbcf1 100644 --- a/pkg/agent/tunnelserver/options.go +++ b/pkg/agent/tunnelserver/options.go @@ -76,10 +76,14 @@ func WithGitToken(token *provider2.GitToken) Option { } } -// WithStatusReporter forwards inbound StatusUpdate RPCs to reporter. +// WithStatusReporter forwards inbound StatusUpdate RPCs to reporter. A nil +// reporter is ignored rather than installed: it would replace the Nop default +// and panic the next time a status event was reported. func WithStatusReporter(reporter status.Reporter) Option { return func(s *tunnelServer) *tunnelServer { - s.statusReporter = reporter + if reporter != nil { + s.statusReporter = reporter + } return s } } diff --git a/pkg/client/clientimplementation/workspace_client.go b/pkg/client/clientimplementation/workspace_client.go index 804031b15..b18cb49fd 100644 --- a/pkg/client/clientimplementation/workspace_client.go +++ b/pkg/client/clientimplementation/workspace_client.go @@ -355,7 +355,9 @@ func (s *workspaceClient) taskStatusOverride() (client.Status, bool) { } // latestUpTask returns the most recently started `up` task recorded for this -// workspace, or nil if none exists or task state can't be read. +// workspace, or nil if none exists or task state can't be read. The result is +// reconciled first: a task whose detached worker died without recording a +// result would otherwise report Provisioning forever. func (s *workspaceClient) latestUpTask() *task.State { store, err := task.NewStore() if err != nil { @@ -366,9 +368,18 @@ func (s *workspaceClient) latestUpTask() *task.State { return nil } + latest := newestUpTask(states, s.workspace.ID) + if latest == nil { + return nil + } + return store.Reconcile(latest) +} + +// newestUpTask picks the most recently started `up` task for workspaceID. +func newestUpTask(states []*task.State, workspaceID string) *task.State { var latest *task.State for _, st := range states { - if st.WorkspaceID != s.workspace.ID || st.Command != "up" { + if st.WorkspaceID != workspaceID || st.Command != "up" { continue } if latest == nil || st.StartedAt.After(latest.StartedAt) { diff --git a/pkg/client/clientimplementation/workspace_client_status_test.go b/pkg/client/clientimplementation/workspace_client_status_test.go index 58bbfd6be..ed76e298e 100644 --- a/pkg/client/clientimplementation/workspace_client_status_test.go +++ b/pkg/client/clientimplementation/workspace_client_status_test.go @@ -2,6 +2,7 @@ package clientimplementation import ( "errors" + "os" "testing" "time" @@ -124,3 +125,35 @@ func TestTaskStatusOverride_MostRecentTaskWins(t *testing.T) { // earlier failure. assertOverride(t, s, true, client.StatusProvisioning) } + +func TestTaskStatusOverride_AbandonedTaskReportsFailed(t *testing.T) { + // The reported symptom: a detached worker killed before recording a result + // left Status reporting Provisioning forever for a workspace with no + // container. Reconciliation turns that into an honest failure. + useTempTaskDir(t) + store, err := task.NewStore() + require.NoError(t, err) + tk, err := store.Create(task.CreateOptions{Command: "up", WorkspaceID: testWorkspaceID}) + require.NoError(t, err) + require.NoError(t, tk.SetPID(999999)) + + s := &workspaceClient{workspace: &provider.Workspace{ID: testWorkspaceID}} + assertOverride(t, s, true, client.StatusFailed) + + // Persisted, so `task list` agrees with what Status just reported. + state, err := store.Get(tk.ID()) + require.NoError(t, err) + assert.Equal(t, task.StatusFailed, state.Status) +} + +func TestTaskStatusOverride_LiveWorkerStillReportsProvisioning(t *testing.T) { + useTempTaskDir(t) + store, err := task.NewStore() + require.NoError(t, err) + tk, err := store.Create(task.CreateOptions{Command: "up", WorkspaceID: testWorkspaceID}) + require.NoError(t, err) + require.NoError(t, tk.SetPID(os.Getpid())) + + s := &workspaceClient{workspace: &provider.Workspace{ID: testWorkspaceID}} + assertOverride(t, s, true, client.StatusProvisioning) +} diff --git a/pkg/task/store.go b/pkg/task/store.go index c6b73ecbf..544eb6119 100644 --- a/pkg/task/store.go +++ b/pkg/task/store.go @@ -7,8 +7,10 @@ import ( "os" "path/filepath" "sort" + "strconv" "time" + "github.com/devsy-org/devsy/pkg/command" "github.com/devsy-org/devsy/pkg/config" "github.com/devsy-org/devsy/pkg/random" "github.com/gofrs/flock" @@ -108,6 +110,40 @@ func (s *Store) Get(id string) (*State, error) { return state, nil } +// Abandoned reports whether a non-terminal task's worker is gone. A detached +// worker killed before it recorded a result leaves the task looking live +// forever, so callers that treat non-terminal as "in progress" must check this +// first. A task whose PID isn't recorded yet is still starting up, not +// abandoned. +func Abandoned(state *State) bool { + if state == nil || state.Status.Terminal() || state.PID == 0 { + return false + } + running, err := command.IsRunning(strconv.Itoa(state.PID)) + if err != nil { + // Can't tell; assume alive rather than failing a healthy task. + return false + } + return !running +} + +// Reconcile marks a task failed when its worker died without recording a +// result, so it stops being reported as still running. Returns the effective +// state, which is unchanged for live and already-terminal tasks. +func (s *Store) Reconcile(state *State) *State { + if !Abandoned(state) { + return state + } + if err := s.Open(state.ID).Fail(ErrAbandoned); err != nil { + return state + } + reconciled, err := s.Get(state.ID) + if err != nil { + return state + } + return reconciled +} + // List returns every known task, most recently started first. func (s *Store) List() ([]*State, error) { entries, err := os.ReadDir(s.dir) diff --git a/pkg/task/task.go b/pkg/task/task.go index f533295fc..c8656ae28 100644 --- a/pkg/task/task.go +++ b/pkg/task/task.go @@ -15,7 +15,10 @@ import ( "github.com/devsy-org/devsy/pkg/status" ) -var ErrCanceled = errors.New("canceled") +var ( + ErrCanceled = errors.New("canceled") + ErrAbandoned = errors.New("worker exited without recording a result") +) type Status string diff --git a/pkg/task/task_test.go b/pkg/task/task_test.go index 32f6d99e7..51f4475a3 100644 --- a/pkg/task/task_test.go +++ b/pkg/task/task_test.go @@ -3,6 +3,7 @@ package task import ( "errors" "fmt" + "os" "sync" "testing" "time" @@ -398,3 +399,104 @@ func TestConcurrentReportsAreRaceSafe(t *testing.T) { t.Fatalf("Get after concurrent reports: %v", err) } } + +func TestAbandonedIgnoresTasksWithoutPID(t *testing.T) { + // A task that hasn't recorded its PID yet is still starting, not abandoned. + if Abandoned(&State{Status: StatusPending}) { + t.Error("Abandoned(no PID) = true, want false") + } +} + +func TestAbandonedIgnoresTerminalTasks(t *testing.T) { + // PID 1 is alive, but a terminal task's worker is expected to be gone. + for _, st := range []Status{StatusSucceeded, StatusFailed} { + if Abandoned(&State{Status: st, PID: 999999}) { + t.Errorf("Abandoned(%s) = true, want false", st) + } + } +} + +func TestAbandonedDetectsDeadWorker(t *testing.T) { + // A PID that cannot be running: the worker died before recording a result. + if !Abandoned(&State{Status: StatusRunning, PID: 999999}) { + t.Error("Abandoned(dead PID) = false, want true") + } +} + +func TestAbandonedTreatsLiveWorkerAsRunning(t *testing.T) { + if Abandoned(&State{Status: StatusRunning, PID: os.Getpid()}) { + t.Error("Abandoned(this process) = true, want false") + } +} + +func TestReconcileFailsAbandonedTask(t *testing.T) { + store := newTestStore(t) + task, err := store.Create(CreateOptions{Command: "up"}) + if err != nil { + t.Fatalf("Create: %v", err) + } + if err := task.SetPID(999999); err != nil { + t.Fatalf("SetPID: %v", err) + } + state, err := store.Get(task.ID()) + if err != nil { + t.Fatalf("Get: %v", err) + } + + reconciled := store.Reconcile(state) + + if reconciled.Status != StatusFailed { + t.Errorf("status = %s, want %s", reconciled.Status, StatusFailed) + } + if reconciled.Error != ErrAbandoned.Error() { + t.Errorf("error = %q, want %q", reconciled.Error, ErrAbandoned.Error()) + } + // Persisted, so a later poll doesn't re-derive it. + persisted, err := store.Get(task.ID()) + if err != nil { + t.Fatalf("Get after reconcile: %v", err) + } + if persisted.Status != StatusFailed { + t.Errorf("persisted status = %s, want %s", persisted.Status, StatusFailed) + } +} + +func TestReconcileLeavesLiveTaskAlone(t *testing.T) { + store := newTestStore(t) + task, err := store.Create(CreateOptions{Command: "up"}) + if err != nil { + t.Fatalf("Create: %v", err) + } + if err := task.SetPID(os.Getpid()); err != nil { + t.Fatalf("SetPID: %v", err) + } + state, err := store.Get(task.ID()) + if err != nil { + t.Fatalf("Get: %v", err) + } + + if got := store.Reconcile(state).Status; got != StatusPending { + t.Errorf("status = %s, want %s (unchanged)", got, StatusPending) + } +} + +func TestReconcilePreservesCancellationReason(t *testing.T) { + // A canceled task's worker is dead by design; reconciling must not + // overwrite ErrCanceled with the abandoned message. + store := newTestStore(t) + task, err := store.Create(CreateOptions{Command: "up"}) + if err != nil { + t.Fatalf("Create: %v", err) + } + if err := task.Fail(ErrCanceled); err != nil { + t.Fatalf("Fail: %v", err) + } + state, err := store.Get(task.ID()) + if err != nil { + t.Fatalf("Get: %v", err) + } + + if got := store.Reconcile(state).Error; got != ErrCanceled.Error() { + t.Errorf("error = %q, want %q", got, ErrCanceled.Error()) + } +} From e742a9d2d7143dca8ecdd4656fa00049a7cb8afa Mon Sep 17 00:00:00 2001 From: Samuel K Date: Wed, 29 Jul 2026 19:23:48 -0500 Subject: [PATCH 10/21] style: cleanup comments --- cmd/workspace/task.go | 10 ++------ cmd/workspace/up/detach.go | 9 +++----- cmd/workspace/up/status.go | 9 ++------ cmd/workspace/up/up.go | 2 -- desktop/e2e/fixtures/mock-devsy.cjs | 23 +++++++++---------- desktop/e2e/workspaces.e2e.ts | 4 ++-- .../src/main/__tests__/ipc-up-tasks.test.ts | 3 +-- desktop/src/main/ipc.ts | 16 ++++--------- desktop/src/renderer/src/lib/types/index.ts | 2 +- pkg/agent/tunnel/tunnel.pb.go | 2 -- pkg/agent/tunnel/tunnel.proto | 2 -- pkg/client/client.go | 12 +++------- .../clientimplementation/workspace_client.go | 12 ++-------- .../workspace_client_status_test.go | 6 ----- pkg/compose/helper_test.go | 17 -------------- pkg/config/pathmanager.go | 2 -- pkg/devcontainer/config/envelope.go | 19 +++++---------- pkg/devcontainer/feature/extend.go | 3 +-- pkg/devcontainer/feature/lockfile.go | 8 ++----- pkg/devcontainer/run.go | 5 +--- pkg/devcontainer/setup.go | 2 -- pkg/status/log.go | 5 +--- pkg/status/status.go | 13 ++++------- pkg/task/store.go | 7 +----- pkg/task/task.go | 8 +++---- 25 files changed, 50 insertions(+), 151 deletions(-) diff --git a/cmd/workspace/task.go b/cmd/workspace/task.go index 0a1c7c018..2ccf2c53c 100644 --- a/cmd/workspace/task.go +++ b/cmd/workspace/task.go @@ -17,7 +17,7 @@ import ( "github.com/spf13/cobra" ) -// NewTaskCmd builds the 'devsy workspace task' parent command. +// NewTaskCmd builds the workspace task parent command. func NewTaskCmd(globalFlags *flags.GlobalFlags) *cobra.Command { taskCmd := &cobra.Command{ Use: "task", @@ -196,8 +196,6 @@ func followTask(ctx context.Context, store *task.Store, opts followTaskOptions) } } -// emitTaskTransition prints a line only when phase/step changed since the -// last poll. func emitTaskTransition(last, current *task.State, emitJSON bool) { if last != nil && last.Phase == current.Phase && last.Step == current.Step { return @@ -213,8 +211,6 @@ func emitTaskTransition(last, current *task.State, emitJSON bool) { _, _ = fmt.Fprintf(os.Stdout, "task %s: %s\n", current.ID, current.Phase) } -// --- cancel --- - type taskCancelCmd struct { *flags.GlobalFlags } @@ -249,9 +245,7 @@ func (cmd *taskCancelCmd) run(id string) error { return err } - // Report the raw state rather than routing through reportTaskState, - // which would turn state.Error (the task is now Failed) into this - // command's exit code — canceling successfully isn't itself a failure. + // Report the raw state. Canceling is not itself a failure. if emitJSON { return json.NewEncoder(os.Stdout).Encode(state) } diff --git a/cmd/workspace/up/detach.go b/cmd/workspace/up/detach.go index 3692892e1..d7bcc3b14 100644 --- a/cmd/workspace/up/detach.go +++ b/cmd/workspace/up/detach.go @@ -14,9 +14,7 @@ import ( ) // runDetached submits this invocation as a background task and returns -// immediately instead of waiting for the workspace to be ready. The task is -// a re-exec of this same command with --detach swapped for --task-id, so it -// runs the same pipeline an attached `up` would. +// immediately. func (cmd *UpCmd) runDetached(args []string) error { store, err := task.NewStore() if err != nil { @@ -78,7 +76,7 @@ func detachedArgs(args []string) []string { return out } -// detachWorkspaceLabel is a best-effort label for `workspace task list`. +// detachWorkspaceLabel is a best-effort label for task list. func (cmd *UpCmd) detachWorkspaceLabel(args []string) string { if cmd.ID != "" { return cmd.ID @@ -97,8 +95,7 @@ func wd() string { return dir } -// openTask returns nil when this invocation wasn't launched via --detach. -// Records this process's PID so a caller can later cancel the actual work. +// openTask returns nil when this invocation was not launched via detach. func (cmd *UpCmd) openTask() (*task.Task, error) { if cmd.taskID == "" { return nil, nil diff --git a/cmd/workspace/up/status.go b/cmd/workspace/up/status.go index 6e1a9ab26..c325aa70a 100644 --- a/cmd/workspace/up/status.go +++ b/cmd/workspace/up/status.go @@ -9,8 +9,7 @@ import ( "github.com/devsy-org/devsy/pkg/status" ) -// newStatusReporter drives `up`'s progress output: one NDJSON status line -// per phase transition in JSON mode, human-readable info lines otherwise. +// newStatusReporter drives `up`'s progress output. func newStatusReporter(emitJSON bool, out io.Writer) status.Reporter { if emitJSON { return &jsonStatusReporter{out: out} @@ -18,9 +17,7 @@ func newStatusReporter(emitJSON bool, out io.Writer) status.Reporter { return plainStatusReporter{} } -// jsonStatusReporter serializes writes: events arrive both from the local up -// pipeline and from the tunnel's Status RPC goroutine, and interleaved writes -// would corrupt the NDJSON framing callers parse line-by-line. +// jsonStatusReporter serializes write events. type jsonStatusReporter struct { mu sync.Mutex out io.Writer @@ -43,8 +40,6 @@ func (plainStatusReporter) Report(e status.Event) { case e.Started: log.Infof("up: %s", phaseLabel(e.Phase)) case e.Phase == status.PhaseReady: - // Only the terminal phase's completion is worth a line; logging every - // Leave would just double every phase already announced on entry. log.Infof("up: %s", phaseLabel(e.Phase)) } } diff --git a/cmd/workspace/up/up.go b/cmd/workspace/up/up.go index bb1d60bab..3664a56ff 100644 --- a/cmd/workspace/up/up.go +++ b/cmd/workspace/up/up.go @@ -229,8 +229,6 @@ func (cmd *UpCmd) Run( } if t != nil { cmd.statusReporter = status.Tee(cmd.statusReporter, t.Reporter()) - // The submitting process's guessed label may not match the - // resolved ID; client.Status() looks tasks up by this label. if err := t.SetWorkspaceID(client.Workspace()); err != nil { failTask(t, err) return reportErr(err, emitJSON, out) diff --git a/desktop/e2e/fixtures/mock-devsy.cjs b/desktop/e2e/fixtures/mock-devsy.cjs index 4b0db6d43..67d13feda 100755 --- a/desktop/e2e/fixtures/mock-devsy.cjs +++ b/desktop/e2e/fixtures/mock-devsy.cjs @@ -192,7 +192,7 @@ function handleSsh() { process.exit(0) } -// Adds a completed workspace to state, mirroring what a real `up` produces. +// Adds a completed workspace to state. function materializeWorkspace(wsId, source, providerFlag, ideFlag) { state.workspaces.push({ id: wsId, @@ -216,8 +216,7 @@ function handleUp(args) { (source ? source.split("/").pop().replace(".git", "") : "") || "workspace" - // `--detach` submits a background task instead of running to completion; - // the caller polls it via `workspace task logs --follow`. + // submits a background task instead of running to completion. if (args.includes("--detach")) { const taskId = `task-${Date.now()}` state.tasks[taskId] = { @@ -234,10 +233,10 @@ function handleUp(args) { return } - out("Resolving source...") - out("Pulling image...") - out("Starting workspace...") - out("Workspace ready.") + out("Resolving source") + out("Pulling image") + out("Starting workspace") + out("Workspace ready") materializeWorkspace(wsId, source, providerFlag, ideFlag) process.exit(0) } @@ -265,15 +264,15 @@ function handleTaskLogs(args) { return } - out("Resolving source...") - out("Pulling image...") - out("Starting workspace...") - out("Workspace ready.") + out("Resolving source") + out("Pulling image") + out("Starting workspace") + out("Workspace ready") materializeWorkspace(t.wsId, t.source, t.providerFlag, t.ideFlag) t.status = "succeeded" saveState(state) - // Single-line NDJSON result envelope, matching pkg/devcontainer/config/envelope.go. + // Single-line NDJSON result envelope. out( JSON.stringify({ kind: "result", diff --git a/desktop/e2e/workspaces.e2e.ts b/desktop/e2e/workspaces.e2e.ts index c223c6ea9..f8198f4d4 100644 --- a/desktop/e2e/workspaces.e2e.ts +++ b/desktop/e2e/workspaces.e2e.ts @@ -155,8 +155,8 @@ test.describe.serial("Create Workspace Wizard", () => { // The review step's primary button is labeled "Launch" await dialog.getByRole("button", { name: /^launch$/i }).click() - // Mock CLI streams: "Resolving source...", "Pulling image...", - // "Starting workspace...", "Workspace ready." + // Mock CLI streams: "Resolving source", "Pulling image", + // "Starting workspace", "Workspace ready." await expect(dialog).toContainText(/resolving|pulling|starting|ready/i, { timeout: 10000, }) diff --git a/desktop/src/main/__tests__/ipc-up-tasks.test.ts b/desktop/src/main/__tests__/ipc-up-tasks.test.ts index 5a55b3cd7..cfb351bd8 100644 --- a/desktop/src/main/__tests__/ipc-up-tasks.test.ts +++ b/desktop/src/main/__tests__/ipc-up-tasks.test.ts @@ -61,8 +61,7 @@ function setup(overrides: { run?: (args: string[]) => Promise } = {}) { if (args.includes("--detach")) return { kind: "task", id: "task-1" } return {} }), - // Captures the follower's callbacks so tests can drive NDJSON lines and - // the exit through the same path the real child would. + // Captures the follower's callbacks so tests can drive NDJSON lines. runStreaming: vi.fn( async ( _args: string[], diff --git a/desktop/src/main/ipc.ts b/desktop/src/main/ipc.ts index 37efc0b1b..24581621d 100644 --- a/desktop/src/main/ipc.ts +++ b/desktop/src/main/ipc.ts @@ -205,10 +205,7 @@ export function registerIpcHandlers(deps: IpcDependencies): { async function cancelActiveUp(workspaceId: string): Promise { const taskId = activeUpTasks.get(workspaceId) if (taskId) { - // Retain the mapping until the cancel lands, and let a failure reject: - // dropping it here would leave the detached task running with nothing - // left to cancel it by, and callers would destroy the workspace out - // from under a live `up`. + // Retain the mapping until the cancel lands, and let a failure reject. await cli.run(["workspace", "task", "cancel", taskId]) if (activeUpTasks.get(workspaceId) === taskId) { activeUpTasks.delete(workspaceId) @@ -237,9 +234,6 @@ export function registerIpcHandlers(deps: IpcDependencies): { * workspace's log directory. */ async function quiesceWorkspace(workspaceId: string): Promise { - // On the same chain as workspace_up: a stop landing between an up's submit - // and its activeUpTasks.set would see no mapping to cancel, and the task - // would then register and outlive the teardown. await serializePerWorkspace(workspaceId, async () => { await cancelActiveUp(workspaceId) await Promise.all([ @@ -800,19 +794,17 @@ export function registerIpcHandlers(deps: IpcDependencies): { return serializePerWorkspace(wsId, async () => { // Tear down any prior run for this workspace before starting a new - // one, awaiting exit so its handle isn't lost when the - // tunnelProcesses entry is overwritten below. + // one. let taskId: string try { await cancelActiveUp(wsId) - // Submit: returns almost immediately with the background task's id. + // Submit: returns immediately with the background task's id. const submitted = await cli.run<{ kind: string; id: string }>([ ...cliArgs, "--detach", ]) // A missing id would be stored and later used to cancel a task that - // does not exist, silently orphaning the real one. Fail loudly here - // so the catch below reports it like any other submit failure. + // does not exist. if (!submitted?.id) { throw new Error("workspace up --detach returned no task id") } diff --git a/desktop/src/renderer/src/lib/types/index.ts b/desktop/src/renderer/src/lib/types/index.ts index d8008d5c4..9808c1046 100644 --- a/desktop/src/renderer/src/lib/types/index.ts +++ b/desktop/src/renderer/src/lib/types/index.ts @@ -148,7 +148,7 @@ export interface CommandProgress { cliError?: import("../../../../shared/cli-error.js").CLIError } -/** A `workspace up` phase transition, pushed as it happens. */ +/** A workspace phase transition, pushed as it happens. */ export interface WorkspaceStatus { commandId: string workspaceId: string diff --git a/pkg/agent/tunnel/tunnel.pb.go b/pkg/agent/tunnel/tunnel.pb.go index 92a13a112..3e66fda3a 100644 --- a/pkg/agent/tunnel/tunnel.pb.go +++ b/pkg/agent/tunnel/tunnel.pb.go @@ -526,8 +526,6 @@ func (x *LogMessage) GetMessage() string { return "" } -// StatusUpdate carries a structured up-pipeline phase transition, distinct -// from the freeform LogMessage stream. See docs/rfcs/async-workspace-up.md. type StatusUpdate struct { state protoimpl.MessageState `protogen:"open.v1"` Phase string `protobuf:"bytes,1,opt,name=phase,proto3" json:"phase,omitempty"` diff --git a/pkg/agent/tunnel/tunnel.proto b/pkg/agent/tunnel/tunnel.proto index ce0ef5755..4493ee924 100644 --- a/pkg/agent/tunnel/tunnel.proto +++ b/pkg/agent/tunnel/tunnel.proto @@ -78,8 +78,6 @@ message LogMessage { string message = 2; } -// StatusUpdate carries a structured up-pipeline phase transition, distinct -// from the freeform LogMessage stream. See docs/rfcs/async-workspace-up.md. message StatusUpdate { string phase = 1; string step = 2; diff --git a/pkg/client/client.go b/pkg/client/client.go index c8cfdc385..4e04645f8 100644 --- a/pkg/client/client.go +++ b/pkg/client/client.go @@ -163,13 +163,9 @@ type CommandOptions struct { type UpOptions struct { provider.CLIOptions - Debug bool - - Stdin io.Reader - Stdout io.Writer - - // Reporter receives up-pipeline phase events when the implementation - // can observe them. May be nil. + Debug bool + Stdin io.Reader + Stdout io.Writer Reporter status.Reporter } @@ -187,9 +183,7 @@ const ( StatusBusy = "Busy" StatusStopped = "Stopped" StatusNotFound = "NotFound" - // No container yet, but a pkg/task `up --detach` task is building one. StatusProvisioning = "Provisioning" - // No container, and the most recent `up --detach` task errored. StatusFailed = "Failed" ) diff --git a/pkg/client/clientimplementation/workspace_client.go b/pkg/client/clientimplementation/workspace_client.go index b18cb49fd..4525a4f02 100644 --- a/pkg/client/clientimplementation/workspace_client.go +++ b/pkg/client/clientimplementation/workspace_client.go @@ -334,10 +334,7 @@ func (s *workspaceClient) Describe(ctx context.Context) (string, error) { } // taskStatusOverride reports Provisioning or Failed based on the most -// recently started `up` task for this workspace, when more informative than -// a bare "no container". A succeeded task implies nothing: if the container -// is still missing after that, something else removed it. Best-effort: any -// error reading task state reports ok=false rather than failing Status. +// recently started `up` task for this workspace. func (s *workspaceClient) taskStatusOverride() (client.Status, bool) { latest := s.latestUpTask() if latest == nil { @@ -355,9 +352,7 @@ func (s *workspaceClient) taskStatusOverride() (client.Status, bool) { } // latestUpTask returns the most recently started `up` task recorded for this -// workspace, or nil if none exists or task state can't be read. The result is -// reconciled first: a task whose detached worker died without recording a -// result would otherwise report Provisioning forever. +// workspace, or nil if none exists or task state can't be read. func (s *workspaceClient) latestUpTask() *task.State { store, err := task.NewStore() if err != nil { @@ -1161,9 +1156,6 @@ func runTunnelServer( opts BuildAgentClientOptions, stdoutReader, stdinWriter *os.File, ) (*config2.Result, error) { - // The log reporter goes first so a caller-supplied reporter in - // TunnelOptions overrides it rather than being clobbered by it — - // tunnelserver applies options in order, each replacing the reporter. tunnelOptions := []tunnelserver.Option{ tunnelserver.WithStatusReporter(status.NewLogReporter()), tunnelserver.WithGitToken(opts.CLIOptions.GitToken), diff --git a/pkg/client/clientimplementation/workspace_client_status_test.go b/pkg/client/clientimplementation/workspace_client_status_test.go index ed76e298e..b4a85a5ac 100644 --- a/pkg/client/clientimplementation/workspace_client_status_test.go +++ b/pkg/client/clientimplementation/workspace_client_status_test.go @@ -115,21 +115,15 @@ func TestTaskStatusOverride_MostRecentTaskWins(t *testing.T) { require.NoError(t, err) require.NoError(t, older.Fail(errors.New("first attempt failed"))) - // Real wall-clock gap so the second task is unambiguously "most recent". time.Sleep(5 * time.Millisecond) _, err = store.Create(task.CreateOptions{Command: "up", WorkspaceID: testWorkspaceID}) require.NoError(t, err) s := &workspaceClient{workspace: &provider.Workspace{ID: testWorkspaceID}} - // The second (later) task is still in flight, so it wins over the - // earlier failure. assertOverride(t, s, true, client.StatusProvisioning) } func TestTaskStatusOverride_AbandonedTaskReportsFailed(t *testing.T) { - // The reported symptom: a detached worker killed before recording a result - // left Status reporting Provisioning forever for a workspace with no - // container. Reconciliation turns that into an honest failure. useTempTaskDir(t) store, err := task.NewStore() require.NoError(t, err) diff --git a/pkg/compose/helper_test.go b/pkg/compose/helper_test.go index eefff0fc7..e054e42a8 100644 --- a/pkg/compose/helper_test.go +++ b/pkg/compose/helper_test.go @@ -2,10 +2,8 @@ package compose import ( "context" - "os/exec" "strings" "testing" - "time" "github.com/devsy-org/devsy/pkg/docker" "github.com/stretchr/testify/suite" @@ -185,21 +183,6 @@ func (r stubRuntime) GPUAvailable(_ context.Context, _ *docker.DockerHelper) (bo } func (s *HelperTestSuite) TestNewComposeHelperPodmanRuntimeUsesDockerCommand() { - // NewComposeHelper's podman detector shells out to a live `podman - // compose`, which needs a running podman machine/socket (e.g. on macOS, - // `podman machine start`) even when the podman binary is installed. CI - // runners (Linux) run podman natively and don't hit this; a local dev - // machine without a started VM would otherwise see this test silently - // fall through to a different compose backend and fail on the assertion - // below rather than skip. - // Bounded: an unreachable podman machine can leave the probe hanging on - // the socket rather than failing fast. - probeCtx, cancelProbe := context.WithTimeout(context.Background(), 15*time.Second) - defer cancelProbe() - if exec.CommandContext(probeCtx, "podman", "compose", "version").Run() != nil { - s.T().Skip("podman compose not reachable in test environment (is podman machine running?)") - } - helper := &docker.DockerHelper{ DockerCommand: "podman", Runtime: stubRuntime{name: docker.RuntimePodman}, diff --git a/pkg/config/pathmanager.go b/pkg/config/pathmanager.go index 28be99348..26cf64163 100644 --- a/pkg/config/pathmanager.go +++ b/pkg/config/pathmanager.go @@ -395,8 +395,6 @@ func (b *basePathManager) TaskDir() (string, error) { return filepath.Join(dir, "tasks"), nil } -// --- Singleton management --- - var ( defaultPM PathManager defaultPMOnce sync.Once diff --git a/pkg/devcontainer/config/envelope.go b/pkg/devcontainer/config/envelope.go index 12a03328e..ffbb13424 100644 --- a/pkg/devcontainer/config/envelope.go +++ b/pkg/devcontainer/config/envelope.go @@ -10,8 +10,7 @@ import ( ) // Kind discriminates the NDJSON lines `up --result-format json` writes to -// stdout: a stream of "status" lines as phases complete, terminated by -// exactly one "result" or "error" line. +// stdout. const ( KindStatus = "status" KindResult = "result" @@ -37,7 +36,7 @@ type ErrorEnvelope struct { } // StatusEnvelope is one NDJSON line reporting a phase transition of the up -// pipeline as it happens, ahead of the terminal ResultEnvelope/ErrorEnvelope. +// pipeline. type StatusEnvelope struct { Kind string `json:"kind"` Phase string `json:"phase"` @@ -46,9 +45,7 @@ type StatusEnvelope struct { Error string `json:"error,omitempty"` } -// TaskEnvelope is the single line `up --detach` writes to stdout: the ID of -// the background task it submitted, so the caller can poll it later (e.g. -// `workspace task `) instead of waiting for it to finish. +// TaskEnvelope is the single line `up --detach` writes to stdout. type TaskEnvelope struct { Kind string `json:"kind"` ID string `json:"id"` @@ -64,9 +61,7 @@ func WriteTaskJSON(w io.Writer, id string) error { return err } -// WriteResultJSON serializes env as a success envelope to w. The caller -// supplies the envelope fields; this function stamps Outcome="success" and -// appends a trailing newline. +// WriteResultJSON serializes env as a success envelope. func WriteResultJSON(w io.Writer, env ResultEnvelope) error { env.Kind = KindResult env.Outcome = "success" @@ -92,9 +87,7 @@ func WriteErrorJSON(w io.Writer, msg string) error { return err } -// ParseStatusLine parses line as a status NDJSON envelope (see -// WriteStatusJSON), returning ok=false for anything else — plain log text, -// a zap record, or a terminal result/error envelope. +// ParseStatusLine parses line as a status NDJSON envelope. func ParseStatusLine(line string) (status.Event, bool) { trimmed := strings.TrimSpace(line) if !strings.HasPrefix(trimmed, "{") { @@ -112,7 +105,7 @@ func ParseStatusLine(line string) (status.Event, bool) { }, true } -// WriteStatusJSON serializes a status.Event as an NDJSON status line to w. +// WriteStatusJSON serializes a status.Event as an NDJSON status line. func WriteStatusJSON(w io.Writer, e status.Event) error { env := StatusEnvelope{ Kind: KindStatus, diff --git a/pkg/devcontainer/feature/extend.go b/pkg/devcontainer/feature/extend.go index 69084dee5..5adafce31 100644 --- a/pkg/devcontainer/feature/extend.go +++ b/pkg/devcontainer/feature/extend.go @@ -372,8 +372,7 @@ func prepareLock( // getUserFeatures resolves every feature the user configured directly. // Each resolution can involve a network pull (OCI) or tarball download, so -// they run concurrently rather than one at a time; featureProcessor and -// lockfileState are safe for concurrent use (see their doc comments). +// they run concurrently rather than one at a time. func getUserFeatures( processor *featureProcessor, devContainerConfig *config.DevContainerConfig, diff --git a/pkg/devcontainer/feature/lockfile.go b/pkg/devcontainer/feature/lockfile.go index e60d888ee..767d3be2e 100644 --- a/pkg/devcontainer/feature/lockfile.go +++ b/pkg/devcontainer/feature/lockfile.go @@ -151,10 +151,7 @@ type lockfileMode struct { } // lockfileState carries the lockfile loaded for pinning and collects the -// entries resolved during a fetch so they can be written afterwards. record -// is called concurrently when features are fetched in parallel, so writes to -// entries are guarded by mu; loaded is populated once before any fetch starts -// and only read afterward, so it needs no locking. +// entries resolved during a fetch so they can be written afterwards. type lockfileState struct { loaded *Lockfile mu sync.Mutex @@ -174,8 +171,7 @@ func (l *lockfileState) pin(featureID string) (resolved, integrity string, ok bo return entry.Resolved, entry.Integrity, true } -// record stores the resolved entry for a feature identifier. Safe to call -// concurrently from multiple feature fetches. +// record stores the resolved entry for a feature identifier. func (l *lockfileState) record(featureID string, entry LockedFeature) { if l == nil { return diff --git a/pkg/devcontainer/run.go b/pkg/devcontainer/run.go index ba91fdb10..cec1a8680 100644 --- a/pkg/devcontainer/run.go +++ b/pkg/devcontainer/run.go @@ -19,7 +19,6 @@ import ( // Runner drives the lifecycle of a single workspace's dev container. type Runner interface { - // Up reports phase transitions to reporter; pass status.Nop() to skip. Up( ctx context.Context, options UpOptions, @@ -91,8 +90,6 @@ type runner struct { recovering bool - // Set at the start of Up; read by other runner methods via this field - // rather than threaded through every call site. reporter status.Reporter } @@ -159,7 +156,7 @@ func (r *runner) Up( defer cleanupBuildInformation(substitutedConfig.Config) // Recovery skips initializeCommand: a failing host hook must not block the - // recovery container. In normal mode its failure is recovery-eligible. + // recovery container. if !options.Recovery { status.Enter(reporter, status.PhaseInitializeCommand, "") if err := r.runInitializeCommand(ctx, substitutedConfig.Config, options); err != nil { diff --git a/pkg/devcontainer/setup.go b/pkg/devcontainer/setup.go index 601bfb0e3..9a96102f7 100644 --- a/pkg/devcontainer/setup.go +++ b/pkg/devcontainer/setup.go @@ -196,8 +196,6 @@ func (r *runner) deliverPostStart(ctx context.Context, strategy delivery.AgentDe } // prefetchAgentBinary warms the binary cache while the container builds. -// Best-effort: failures are ignored, and the real injection-path -// acquisition retries from scratch regardless. func (r *runner) prefetchAgentBinary(ctx context.Context) { arch, err := r.deliveryArch(ctx) if err != nil { diff --git a/pkg/status/log.go b/pkg/status/log.go index 841c9f023..9bbcba023 100644 --- a/pkg/status/log.go +++ b/pkg/status/log.go @@ -2,10 +2,7 @@ package status import "github.com/devsy-org/devsy/pkg/log" -// logReporter renders events as debug log lines. It is an interim consumer -// until the CLI/desktop stream structured events directly (see -// docs/rfcs/async-workspace-up.md) — real callers should prefer that once it -// lands, but this keeps events observable everywhere in the meantime. +// logReporter renders events as debug log lines. type logReporter struct{} // NewLogReporter returns a Reporter that logs each event at debug level. diff --git a/pkg/status/status.go b/pkg/status/status.go index 11b24d7b9..4b6ceaa77 100644 --- a/pkg/status/status.go +++ b/pkg/status/status.go @@ -1,6 +1,5 @@ // Package status defines the structured progress events emitted by -// long-running commands, in place of relying on freeform log text for control -// flow. Phases are named per pipeline; workspace up is the first consumer. +// long-running commands. package status // Phase identifies a step in the up pipeline. @@ -19,10 +18,7 @@ const ( PhaseFailed Phase = "failed" ) -// Event is one phase transition. Started distinguishes entering a phase -// from completing it; Step carries phase-specific detail (e.g. the -// lifecycle hook name) and is empty when not applicable. Err is set only -// when Phase is PhaseFailed, and names the phase that failed via Step. +// Event is one phase transition. type Event struct { Phase Phase `json:"phase"` Step string `json:"step,omitempty"` @@ -31,7 +27,7 @@ type Event struct { } // Reporter receives status events as they occur. Implementations must be -// safe to call from multiple goroutines. +// safe to call from goroutines. type Reporter interface { Report(Event) } @@ -65,8 +61,7 @@ func (t teeReporter) Report(e Event) { } } -// Tee forwards every event to each reporter, e.g. both the CLI's own NDJSON -// output and a persisted task state. +// Tee forwards every event to each reporter. func Tee(reporters ...Reporter) Reporter { return teeReporter(reporters) } diff --git a/pkg/task/store.go b/pkg/task/store.go index 544eb6119..e24207058 100644 --- a/pkg/task/store.go +++ b/pkg/task/store.go @@ -17,14 +17,9 @@ import ( ) // lockTimeout bounds how long update waits to acquire a task's file lock. -// Held only for the duration of a single read-modify-write, so a stuck lock -// past this means something is wrong rather than merely busy. const lockTimeout = 5 * time.Second -// Store persists task state as one JSON file per task under dir. update -// serializes read-modify-write across processes with a file lock, since a -// task's worker (reporting progress) and an external `task cancel` both -// write to the same file. +// Store persists task state as one JSON file per task under dir. type Store struct { dir string } diff --git a/pkg/task/task.go b/pkg/task/task.go index c8656ae28..5d198f506 100644 --- a/pkg/task/task.go +++ b/pkg/task/task.go @@ -1,6 +1,5 @@ -// Package task tracks detached background work (e.g. `workspace up -// --detach`) so its status can be polled independently of the CLI -// invocation that started it. +// Package task tracks detached background work so its status can +// be polled independently of the CLI invocation that started it. package task import ( @@ -35,8 +34,7 @@ func (s Status) Terminal() bool { // State is the JSON snapshot persisted for a task. PID names the OS process // doing the work, distinct from whatever process is merely polling this -// state. Command/WorkspaceID are caller-supplied labels for `task list` and -// don't affect behavior. +// state. type State struct { ID string `json:"id"` Command string `json:"command,omitempty"` From eda77d1314419e68376df88db6b9dda83a95aa11 Mon Sep 17 00:00:00 2001 From: Samuel K Date: Thu, 30 Jul 2026 00:39:11 +0000 Subject: [PATCH 11/21] style: fix formatting Signed-off-by: Samuel K --- pkg/client/client.go | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/pkg/client/client.go b/pkg/client/client.go index 4e04645f8..e44775719 100644 --- a/pkg/client/client.go +++ b/pkg/client/client.go @@ -179,12 +179,12 @@ type SshOptions struct { type Status string const ( - StatusRunning = "Running" - StatusBusy = "Busy" - StatusStopped = "Stopped" - StatusNotFound = "NotFound" + StatusRunning = "Running" + StatusBusy = "Busy" + StatusStopped = "Stopped" + StatusNotFound = "NotFound" StatusProvisioning = "Provisioning" - StatusFailed = "Failed" + StatusFailed = "Failed" ) func ParseStatus(in string) (Status, error) { From deaeb7c9bf8b182a815ad00daef25a7bdfd69cf8 Mon Sep 17 00:00:00 2001 From: Samuel K Date: Wed, 29 Jul 2026 20:36:02 -0500 Subject: [PATCH 12/21] fix(task): use a worker-held lock for task liveness MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PID-based liveness had two problems: a recycled PID makes a dead worker look alive, so an abandoned task could still be reported as running; and command.IsRunning panics on Windows, so Abandoned would crash there. Have the worker hold an exclusive flock on .json.worker.lock for its lifetime instead. The kernel releases it however the process ends, so liveness becomes "is the lock acquirable" — no PID to be reused, and it works on every platform. Separate from the read-modify-write lock in withLock, which is held only per update. HoldWorkerLock also rejects a second worker for the same task. Also from review: the log follower exiting no longer releases the task. The follower dying says nothing about the detached worker, and releasing there left a live task with no id to cancel it by. Restores the podman reachability probe in pkg/compose, dropped by an earlier comment cleanup; without it the test asserts against whichever compose backend the helper falls back to instead of skipping. --- cmd/workspace/up/detach.go | 5 + .../src/main/__tests__/ipc-up-tasks.test.ts | 14 ++ desktop/src/main/ipc.ts | 4 +- .../workspace_client_status_test.go | 8 +- pkg/compose/helper_test.go | 13 ++ pkg/task/export_test_helpers.go | 11 ++ pkg/task/store.go | 45 +++++-- pkg/task/task.go | 31 +++++ pkg/task/task_test.go | 121 ++++++++++++++---- 9 files changed, 216 insertions(+), 36 deletions(-) create mode 100644 pkg/task/export_test_helpers.go diff --git a/cmd/workspace/up/detach.go b/cmd/workspace/up/detach.go index d7bcc3b14..37736da0f 100644 --- a/cmd/workspace/up/detach.go +++ b/cmd/workspace/up/detach.go @@ -105,6 +105,11 @@ func (cmd *UpCmd) openTask() (*task.Task, error) { return nil, err } t := store.Open(cmd.taskID) + // Claim the worker lock before anything else: it's how a poller tells this + // task is still being worked on rather than abandoned by a dead process. + if err := t.HoldWorkerLock(); err != nil { + return nil, err + } if err := t.SetPID(os.Getpid()); err != nil { return nil, err } diff --git a/desktop/src/main/__tests__/ipc-up-tasks.test.ts b/desktop/src/main/__tests__/ipc-up-tasks.test.ts index cfb351bd8..1b482d8c0 100644 --- a/desktop/src/main/__tests__/ipc-up-tasks.test.ts +++ b/desktop/src/main/__tests__/ipc-up-tasks.test.ts @@ -243,4 +243,18 @@ describe("workspace_up detached task tracking", () => { expect(calls.filter((a) => a.includes("cancel"))).toEqual([]) }) + + it("keeps the task registered when the follower exits without an envelope", async () => { + // The follower dying says nothing about the detached worker. Releasing + // here would orphan a still-running task with no id left to cancel it by. + const { calls, stream } = setup() + await invokeUp("ws-1") + + stream()?.onExit(1, { code: "boom", message: "follower died" }) + await invokeUp("ws-1") + + expect(calls.filter((a) => a.includes("cancel"))).toEqual([ + ["workspace", "task", "cancel", "task-1"], + ]) + }) }) diff --git a/desktop/src/main/ipc.ts b/desktop/src/main/ipc.ts index 24581621d..1c023fb79 100644 --- a/desktop/src/main/ipc.ts +++ b/desktop/src/main/ipc.ts @@ -875,7 +875,9 @@ export function registerIpcHandlers(deps: IpcDependencies): { if (!sink.line(formatted)) return logStore.onDrain(logPath) }, (code, cliError) => { - releaseTask() + // Only the terminal envelopes above release the task. The follower + // dying says nothing about the detached worker, and unregistering + // here would leave a live task with no id to cancel it by. if (tunnelProcesses.get(wsId) === child) { tunnelProcesses.delete(wsId) } diff --git a/pkg/client/clientimplementation/workspace_client_status_test.go b/pkg/client/clientimplementation/workspace_client_status_test.go index b4a85a5ac..677adb890 100644 --- a/pkg/client/clientimplementation/workspace_client_status_test.go +++ b/pkg/client/clientimplementation/workspace_client_status_test.go @@ -2,7 +2,6 @@ package clientimplementation import ( "errors" - "os" "testing" "time" @@ -129,7 +128,10 @@ func TestTaskStatusOverride_AbandonedTaskReportsFailed(t *testing.T) { require.NoError(t, err) tk, err := store.Create(task.CreateOptions{Command: "up", WorkspaceID: testWorkspaceID}) require.NoError(t, err) - require.NoError(t, tk.SetPID(999999)) + // Claim then release the worker lock: the kernel frees a dead worker's + // lock the same way, which is what marks the task abandoned. + require.NoError(t, tk.HoldWorkerLock()) + require.NoError(t, tk.ReleaseWorkerLockForTest()) s := &workspaceClient{workspace: &provider.Workspace{ID: testWorkspaceID}} assertOverride(t, s, true, client.StatusFailed) @@ -146,7 +148,7 @@ func TestTaskStatusOverride_LiveWorkerStillReportsProvisioning(t *testing.T) { require.NoError(t, err) tk, err := store.Create(task.CreateOptions{Command: "up", WorkspaceID: testWorkspaceID}) require.NoError(t, err) - require.NoError(t, tk.SetPID(os.Getpid())) + require.NoError(t, tk.HoldWorkerLock()) s := &workspaceClient{workspace: &provider.Workspace{ID: testWorkspaceID}} assertOverride(t, s, true, client.StatusProvisioning) diff --git a/pkg/compose/helper_test.go b/pkg/compose/helper_test.go index e054e42a8..0a84caf7f 100644 --- a/pkg/compose/helper_test.go +++ b/pkg/compose/helper_test.go @@ -2,8 +2,10 @@ package compose import ( "context" + "os/exec" "strings" "testing" + "time" "github.com/devsy-org/devsy/pkg/docker" "github.com/stretchr/testify/suite" @@ -183,6 +185,17 @@ func (r stubRuntime) GPUAvailable(_ context.Context, _ *docker.DockerHelper) (bo } func (s *HelperTestSuite) TestNewComposeHelperPodmanRuntimeUsesDockerCommand() { + // The podman detector shells out to a live `podman compose`, which needs a + // running machine/socket even when the binary is installed. Without this + // probe the helper silently falls through to another compose backend and + // the assertion below fails instead of skipping. Bounded because an + // unreachable machine leaves the probe hanging on the socket. + probeCtx, cancelProbe := context.WithTimeout(context.Background(), 15*time.Second) + defer cancelProbe() + if exec.CommandContext(probeCtx, "podman", "compose", "version").Run() != nil { + s.T().Skip("podman compose not reachable (is podman machine running?)") + } + helper := &docker.DockerHelper{ DockerCommand: "podman", Runtime: stubRuntime{name: docker.RuntimePodman}, diff --git a/pkg/task/export_test_helpers.go b/pkg/task/export_test_helpers.go new file mode 100644 index 000000000..f0e257916 --- /dev/null +++ b/pkg/task/export_test_helpers.go @@ -0,0 +1,11 @@ +package task + +// ReleaseWorkerLockForTest drops the worker lock so a test can simulate the +// worker dying. Production code never releases it explicitly — the kernel does +// that on process exit, which is the whole point of using a lock for liveness. +func (t *Task) ReleaseWorkerLockForTest() error { + if t.workerLock == nil { + return nil + } + return t.workerLock.Unlock() +} diff --git a/pkg/task/store.go b/pkg/task/store.go index e24207058..7e513dc03 100644 --- a/pkg/task/store.go +++ b/pkg/task/store.go @@ -7,10 +7,8 @@ import ( "os" "path/filepath" "sort" - "strconv" "time" - "github.com/devsy-org/devsy/pkg/command" "github.com/devsy-org/devsy/pkg/config" "github.com/devsy-org/devsy/pkg/random" "github.com/gofrs/flock" @@ -108,25 +106,43 @@ func (s *Store) Get(id string) (*State, error) { // Abandoned reports whether a non-terminal task's worker is gone. A detached // worker killed before it recorded a result leaves the task looking live // forever, so callers that treat non-terminal as "in progress" must check this -// first. A task whose PID isn't recorded yet is still starting up, not -// abandoned. -func Abandoned(state *State) bool { - if state == nil || state.Status.Terminal() || state.PID == 0 { +// first. +// +// Liveness is the worker's lock (see Task.HoldWorkerLock), not its PID: the +// kernel drops the lock however the process dies, and a recycled PID can't +// make a dead worker look alive. A task holding no lock yet is still starting +// up, not abandoned. +func (s *Store) Abandoned(state *State) bool { + if state == nil || state.Status.Terminal() { return false } - running, err := command.IsRunning(strconv.Itoa(state.PID)) + path, err := s.workerLockPath(state.ID) + if err != nil { + return false + } + // No lock file means the worker never got far enough to claim one. + if _, statErr := os.Stat(path); statErr != nil { + return false + } + + lock := flock.New(path) + locked, err := lock.TryLock() if err != nil { // Can't tell; assume alive rather than failing a healthy task. return false } - return !running + if !locked { + return false + } + _ = lock.Unlock() + return true } // Reconcile marks a task failed when its worker died without recording a // result, so it stops being reported as still running. Returns the effective // state, which is unchanged for live and already-terminal tasks. func (s *Store) Reconcile(state *State) *State { - if !Abandoned(state) { + if !s.Abandoned(state) { return state } if err := s.Open(state.ID).Fail(ErrAbandoned); err != nil { @@ -165,6 +181,17 @@ func (s *Store) List() ([]*State, error) { return states, nil } +// workerLockPath is distinct from the read-modify-write lock in withLock: this +// one is held for the worker's entire lifetime, so sharing it would block +// every state update. +func (s *Store) workerLockPath(id string) (string, error) { + path, err := s.path(id) + if err != nil { + return "", err + } + return path + ".worker.lock", nil +} + func (s *Store) update(id string, mutate func(*State)) error { return s.withLock(id, func() error { state, err := s.Get(id) diff --git a/pkg/task/task.go b/pkg/task/task.go index 5d198f506..6a2845212 100644 --- a/pkg/task/task.go +++ b/pkg/task/task.go @@ -12,6 +12,7 @@ import ( "github.com/devsy-org/devsy/pkg/command" "github.com/devsy-org/devsy/pkg/devcontainer/config" "github.com/devsy-org/devsy/pkg/status" + "github.com/gofrs/flock" ) var ( @@ -61,6 +62,8 @@ type CreateOptions struct { type Task struct { store *Store id string + // Held for the worker process's lifetime; see HoldWorkerLock. + workerLock *flock.Flock } func (t *Task) ID() string { return t.id } @@ -71,6 +74,34 @@ func (t *Task) SetPID(pid int) error { }) } +// HoldWorkerLock claims this task's worker lock for the rest of the process's +// life, marking it as actively being worked on. Callers must not release it: +// the kernel does so when the process exits, crashes, or is killed, which is +// exactly what lets Store.Abandoned distinguish a live worker from one that +// died without recording a result. +// +// Returns an error if another process already holds the lock, since that means +// a worker for this task is already running. +func (t *Task) HoldWorkerLock() error { + path, err := t.store.workerLockPath(t.id) + if err != nil { + return err + } + + lock := flock.New(path) + locked, err := lock.TryLock() + if err != nil { + return fmt.Errorf("lock task %s worker: %w", t.id, err) + } + if !locked { + return fmt.Errorf("task %s already has a running worker", t.id) + } + // Deliberately retained, never unlocked: the handle must outlive this call + // so the lock is held for the process's lifetime. + t.workerLock = lock + return nil +} + // SetWorkspaceID corrects the task's workspace label to the resolved ID, // which may differ from whatever label it was created with (e.g. a raw // source string guessed before workspace resolution ran). client.Status diff --git a/pkg/task/task_test.go b/pkg/task/task_test.go index 51f4475a3..1a0497b18 100644 --- a/pkg/task/task_test.go +++ b/pkg/task/task_test.go @@ -3,7 +3,6 @@ package task import ( "errors" "fmt" - "os" "sync" "testing" "time" @@ -400,48 +399,116 @@ func TestConcurrentReportsAreRaceSafe(t *testing.T) { } } -func TestAbandonedIgnoresTasksWithoutPID(t *testing.T) { - // A task that hasn't recorded its PID yet is still starting, not abandoned. - if Abandoned(&State{Status: StatusPending}) { - t.Error("Abandoned(no PID) = true, want false") +func TestAbandonedIgnoresTaskWithNoWorkerLock(t *testing.T) { + // No lock file yet: the worker hasn't claimed the task, not abandoned. + store := newTestStore(t) + tk, err := store.Create(CreateOptions{Command: "up"}) + if err != nil { + t.Fatalf("Create: %v", err) + } + state, err := store.Get(tk.ID()) + if err != nil { + t.Fatalf("Get: %v", err) + } + + if store.Abandoned(state) { + t.Error("Abandoned(no worker lock) = true, want false") } } func TestAbandonedIgnoresTerminalTasks(t *testing.T) { - // PID 1 is alive, but a terminal task's worker is expected to be gone. + store := newTestStore(t) for _, st := range []Status{StatusSucceeded, StatusFailed} { - if Abandoned(&State{Status: st, PID: 999999}) { + if store.Abandoned(&State{ID: "any", Status: st}) { t.Errorf("Abandoned(%s) = true, want false", st) } } } -func TestAbandonedDetectsDeadWorker(t *testing.T) { - // A PID that cannot be running: the worker died before recording a result. - if !Abandoned(&State{Status: StatusRunning, PID: 999999}) { - t.Error("Abandoned(dead PID) = false, want true") +func TestAbandonedTreatsHeldLockAsLive(t *testing.T) { + // The lock a live worker holds is not acquirable, so the task reads as live. + store := newTestStore(t) + tk, err := store.Create(CreateOptions{Command: "up"}) + if err != nil { + t.Fatalf("Create: %v", err) + } + if err := tk.HoldWorkerLock(); err != nil { + t.Fatalf("HoldWorkerLock: %v", err) + } + state, err := store.Get(tk.ID()) + if err != nil { + t.Fatalf("Get: %v", err) + } + + if store.Abandoned(state) { + t.Error("Abandoned(held lock) = true, want false") } } -func TestAbandonedTreatsLiveWorkerAsRunning(t *testing.T) { - if Abandoned(&State{Status: StatusRunning, PID: os.Getpid()}) { - t.Error("Abandoned(this process) = true, want false") +func TestAbandonedDetectsReleasedLock(t *testing.T) { + // Releasing stands in for the worker dying: the kernel drops the lock the + // same way, leaving it acquirable while the task is still non-terminal. + store := newTestStore(t) + tk, err := store.Create(CreateOptions{Command: "up"}) + if err != nil { + t.Fatalf("Create: %v", err) + } + if err := tk.HoldWorkerLock(); err != nil { + t.Fatalf("HoldWorkerLock: %v", err) + } + if err := tk.ReleaseWorkerLockForTest(); err != nil { + t.Fatalf("ReleaseWorkerLockForTest: %v", err) + } + state, err := store.Get(tk.ID()) + if err != nil { + t.Fatalf("Get: %v", err) + } + + if !store.Abandoned(state) { + t.Error("Abandoned(released lock) = false, want true") } } -func TestReconcileFailsAbandonedTask(t *testing.T) { +func TestHoldWorkerLockRejectsSecondWorker(t *testing.T) { store := newTestStore(t) - task, err := store.Create(CreateOptions{Command: "up"}) + tk, err := store.Create(CreateOptions{Command: "up"}) if err != nil { t.Fatalf("Create: %v", err) } - if err := task.SetPID(999999); err != nil { - t.Fatalf("SetPID: %v", err) + if err := tk.HoldWorkerLock(); err != nil { + t.Fatalf("first HoldWorkerLock: %v", err) } - state, err := store.Get(task.ID()) + + // A second worker for the same task must not start alongside the first. + if err := store.Open(tk.ID()).HoldWorkerLock(); err == nil { + t.Error("second HoldWorkerLock = nil error, want rejection") + } +} + +// abandonTask creates a task, claims its worker lock, then releases it: the +// kernel frees a dead worker's lock the same way. +func abandonTask(t *testing.T, store *Store) *State { + t.Helper() + tk, err := store.Create(CreateOptions{Command: "up"}) + if err != nil { + t.Fatalf("Create: %v", err) + } + if err := tk.HoldWorkerLock(); err != nil { + t.Fatalf("HoldWorkerLock: %v", err) + } + if err := tk.ReleaseWorkerLockForTest(); err != nil { + t.Fatalf("ReleaseWorkerLockForTest: %v", err) + } + state, err := store.Get(tk.ID()) if err != nil { t.Fatalf("Get: %v", err) } + return state +} + +func TestReconcileFailsAbandonedTask(t *testing.T) { + store := newTestStore(t) + state := abandonTask(t, store) reconciled := store.Reconcile(state) @@ -451,8 +518,16 @@ func TestReconcileFailsAbandonedTask(t *testing.T) { if reconciled.Error != ErrAbandoned.Error() { t.Errorf("error = %q, want %q", reconciled.Error, ErrAbandoned.Error()) } - // Persisted, so a later poll doesn't re-derive it. - persisted, err := store.Get(task.ID()) +} + +func TestReconcilePersistsTheFailure(t *testing.T) { + // Persisted, so a later poll doesn't re-derive it and `task list` agrees. + store := newTestStore(t) + state := abandonTask(t, store) + + store.Reconcile(state) + + persisted, err := store.Get(state.ID) if err != nil { t.Fatalf("Get after reconcile: %v", err) } @@ -467,8 +542,8 @@ func TestReconcileLeavesLiveTaskAlone(t *testing.T) { if err != nil { t.Fatalf("Create: %v", err) } - if err := task.SetPID(os.Getpid()); err != nil { - t.Fatalf("SetPID: %v", err) + if err := task.HoldWorkerLock(); err != nil { + t.Fatalf("HoldWorkerLock: %v", err) } state, err := store.Get(task.ID()) if err != nil { From 68075306e5e988d8df236a3a80716efe706473d1 Mon Sep 17 00:00:00 2001 From: Samuel K Date: Wed, 29 Jul 2026 21:03:19 -0500 Subject: [PATCH 13/21] fix(task): hold the worker lock across the abandoned-task transition MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Abandoned released the worker lock before Reconcile wrote ErrAbandoned. A replacement worker could claim it in that window and then be marked failed while actually running — withLock guards state writes, not this lock, so it could not prevent that. Reconcile now claims the lock itself and holds it through the write, and re-reads state under it so a result the worker recorded in the meantime stands instead of being overwritten. The regression test needs a seam: the window is between two internal calls, so a test can only act inside it via a hook. Without one the test passes against the buggy version, which is worse than no test. Also release worker locks via t.Cleanup so an fd stays closed when an assertion fails, which can otherwise block TempDir removal. --- .../workspace_client_status_test.go | 3 + pkg/task/export_test_helpers.go | 7 ++ pkg/task/store.go | 87 +++++++++++---- pkg/task/task_test.go | 105 ++++++++++++++++-- 4 files changed, 171 insertions(+), 31 deletions(-) diff --git a/pkg/client/clientimplementation/workspace_client_status_test.go b/pkg/client/clientimplementation/workspace_client_status_test.go index 677adb890..a6ec94303 100644 --- a/pkg/client/clientimplementation/workspace_client_status_test.go +++ b/pkg/client/clientimplementation/workspace_client_status_test.go @@ -149,6 +149,9 @@ func TestTaskStatusOverride_LiveWorkerStillReportsProvisioning(t *testing.T) { tk, err := store.Create(task.CreateOptions{Command: "up", WorkspaceID: testWorkspaceID}) require.NoError(t, err) require.NoError(t, tk.HoldWorkerLock()) + // Released on cleanup so the fd closes even if an assertion fails; an open + // file can block TempDir removal on some platforms. + t.Cleanup(func() { require.NoError(t, tk.ReleaseWorkerLockForTest()) }) s := &workspaceClient{workspace: &provider.Workspace{ID: testWorkspaceID}} assertOverride(t, s, true, client.StatusProvisioning) diff --git a/pkg/task/export_test_helpers.go b/pkg/task/export_test_helpers.go index f0e257916..81d5a8f34 100644 --- a/pkg/task/export_test_helpers.go +++ b/pkg/task/export_test_helpers.go @@ -9,3 +9,10 @@ func (t *Task) ReleaseWorkerLockForTest() error { } return t.workerLock.Unlock() } + +// SetAfterClaimForTest runs fn inside Reconcile, just after it claims the dead +// worker's lock. Lets a test act in the window an implementation that released +// the lock before writing would leave open. +func (s *Store) SetAfterClaimForTest(fn func()) { + s.afterClaimForTest = fn +} diff --git a/pkg/task/store.go b/pkg/task/store.go index 7e513dc03..ea7bf9d9d 100644 --- a/pkg/task/store.go +++ b/pkg/task/store.go @@ -20,6 +20,8 @@ const lockTimeout = 5 * time.Second // Store persists task state as one JSON file per task under dir. type Store struct { dir string + // Test seam; see Store.SetAfterClaimForTest. + afterClaimForTest func() } func NewStore() (*Store, error) { @@ -116,22 +118,8 @@ func (s *Store) Abandoned(state *State) bool { if state == nil || state.Status.Terminal() { return false } - path, err := s.workerLockPath(state.ID) - if err != nil { - return false - } - // No lock file means the worker never got far enough to claim one. - if _, statErr := os.Stat(path); statErr != nil { - return false - } - - lock := flock.New(path) - locked, err := lock.TryLock() - if err != nil { - // Can't tell; assume alive rather than failing a healthy task. - return false - } - if !locked { + lock, ok := s.claimDeadWorkerLock(state.ID) + if !ok { return false } _ = lock.Unlock() @@ -142,17 +130,26 @@ func (s *Store) Abandoned(state *State) bool { // result, so it stops being reported as still running. Returns the effective // state, which is unchanged for live and already-terminal tasks. func (s *Store) Reconcile(state *State) *State { - if !s.Abandoned(state) { + if state == nil || state.Status.Terminal() { return state } - if err := s.Open(state.ID).Fail(ErrAbandoned); err != nil { + + // Hold the worker lock across the whole transition. Releasing it before + // the write would let a new worker claim it in the gap and then be marked + // failed while it is actually running — withLock guards state writes, not + // this lock, so it cannot prevent that. + lock, ok := s.claimDeadWorkerLock(state.ID) + if !ok { return state } - reconciled, err := s.Get(state.ID) - if err != nil { - return state + defer func() { _ = lock.Unlock() }() + + // Lets a test act in the window a buggy implementation would leave open. + if s.afterClaimForTest != nil { + s.afterClaimForTest() } - return reconciled + + return s.failAbandoned(state) } // List returns every known task, most recently started first. @@ -181,6 +178,52 @@ func (s *Store) List() ([]*State, error) { return states, nil } +// failAbandoned records ErrAbandoned for a task whose worker is confirmed gone. +// Must be called with the worker lock held. +func (s *Store) failAbandoned(state *State) *State { + // Re-read under the lock: the worker may have recorded a terminal result + // between the caller's read and now, in which case there is nothing to + // reconcile and its own outcome must stand. + current, err := s.Get(state.ID) + if err != nil { + return state + } + if current.Status.Terminal() { + return current + } + if err := s.Open(state.ID).Fail(ErrAbandoned); err != nil { + return current + } + reconciled, err := s.Get(state.ID) + if err != nil { + return current + } + return reconciled +} + +// claimDeadWorkerLock acquires the task's worker lock, which only succeeds when +// no worker holds it — i.e. the worker exited without recording a result. The +// caller owns unlocking. ok=false means a worker is alive, none ever started, +// or liveness could not be determined (assume alive over failing a healthy +// task). +func (s *Store) claimDeadWorkerLock(id string) (*flock.Flock, bool) { + path, err := s.workerLockPath(id) + if err != nil { + return nil, false + } + // No lock file means the worker never got far enough to claim one. + if _, statErr := os.Stat(path); statErr != nil { + return nil, false + } + + lock := flock.New(path) + locked, err := lock.TryLock() + if err != nil || !locked { + return nil, false + } + return lock, true +} + // workerLockPath is distinct from the read-modify-write lock in withLock: this // one is held for the worker's entire lifetime, so sharing it would block // every state update. diff --git a/pkg/task/task_test.go b/pkg/task/task_test.go index 1a0497b18..4c9184f89 100644 --- a/pkg/task/task_test.go +++ b/pkg/task/task_test.go @@ -399,6 +399,21 @@ func TestConcurrentReportsAreRaceSafe(t *testing.T) { } } +// holdWorkerLock claims the task's worker lock and releases it on cleanup, so +// the fd closes even if an assertion fails (an open file can block TempDir +// removal on some platforms). +func holdWorkerLock(t *testing.T, tk *Task) { + t.Helper() + if err := tk.HoldWorkerLock(); err != nil { + t.Fatalf("HoldWorkerLock: %v", err) + } + t.Cleanup(func() { + if err := tk.ReleaseWorkerLockForTest(); err != nil { + t.Errorf("ReleaseWorkerLockForTest: %v", err) + } + }) +} + func TestAbandonedIgnoresTaskWithNoWorkerLock(t *testing.T) { // No lock file yet: the worker hasn't claimed the task, not abandoned. store := newTestStore(t) @@ -432,9 +447,7 @@ func TestAbandonedTreatsHeldLockAsLive(t *testing.T) { if err != nil { t.Fatalf("Create: %v", err) } - if err := tk.HoldWorkerLock(); err != nil { - t.Fatalf("HoldWorkerLock: %v", err) - } + holdWorkerLock(t, tk) state, err := store.Get(tk.ID()) if err != nil { t.Fatalf("Get: %v", err) @@ -475,9 +488,7 @@ func TestHoldWorkerLockRejectsSecondWorker(t *testing.T) { if err != nil { t.Fatalf("Create: %v", err) } - if err := tk.HoldWorkerLock(); err != nil { - t.Fatalf("first HoldWorkerLock: %v", err) - } + holdWorkerLock(t, tk) // A second worker for the same task must not start alongside the first. if err := store.Open(tk.ID()).HoldWorkerLock(); err == nil { @@ -536,15 +547,91 @@ func TestReconcilePersistsTheFailure(t *testing.T) { } } -func TestReconcileLeavesLiveTaskAlone(t *testing.T) { +func TestReconcileDoesNotFailARestartedWorker(t *testing.T) { + // The gap this closes: an implementation that released the worker lock + // before writing the failure lets a replacement worker claim it in + // between, and then marks that live worker's task failed. The hook fires + // inside Reconcile, in exactly that window. store := newTestStore(t) - task, err := store.Create(CreateOptions{Command: "up"}) + tk, err := store.Create(CreateOptions{Command: "up"}) + if err != nil { + t.Fatalf("Create: %v", err) + } + if err := tk.HoldWorkerLock(); err != nil { + t.Fatalf("HoldWorkerLock: %v", err) + } + if err := tk.ReleaseWorkerLockForTest(); err != nil { + t.Fatalf("ReleaseWorkerLockForTest: %v", err) + } + stale, err := store.Get(tk.ID()) + if err != nil { + t.Fatalf("Get: %v", err) + } + + // A replacement worker takes over mid-reconcile. Holding the lock is what + // a real worker does; it must not be able to succeed here, because + // Reconcile still holds it. + restarted := store.Open(tk.ID()) + claimed := false + store.SetAfterClaimForTest(func() { + claimed = restarted.HoldWorkerLock() == nil + }) + + reconciled := store.Reconcile(stale) + + if claimed { + t.Error("a replacement worker acquired the lock during Reconcile") + if err := restarted.ReleaseWorkerLockForTest(); err != nil { + t.Errorf("ReleaseWorkerLockForTest: %v", err) + } + } + // With the lock held throughout, the reconcile is the only writer and the + // abandoned task is correctly failed. + if reconciled.Status != StatusFailed { + t.Errorf("status = %s, want %s", reconciled.Status, StatusFailed) + } +} + +func TestReconcileKeepsAWorkerRecordedResult(t *testing.T) { + // The worker may finish between the caller's read and the reconcile; its + // own outcome must stand rather than being overwritten with ErrAbandoned. + store := newTestStore(t) + tk, err := store.Create(CreateOptions{Command: "up"}) if err != nil { t.Fatalf("Create: %v", err) } - if err := task.HoldWorkerLock(); err != nil { + if err := tk.HoldWorkerLock(); err != nil { t.Fatalf("HoldWorkerLock: %v", err) } + stale, err := store.Get(tk.ID()) + if err != nil { + t.Fatalf("Get: %v", err) + } + // Worker succeeds, then exits (dropping its lock). + if err := tk.Succeed(nil); err != nil { + t.Fatalf("Succeed: %v", err) + } + if err := tk.ReleaseWorkerLockForTest(); err != nil { + t.Fatalf("ReleaseWorkerLockForTest: %v", err) + } + + reconciled := store.Reconcile(stale) + + if reconciled.Status != StatusSucceeded { + t.Errorf("status = %s, want %s", reconciled.Status, StatusSucceeded) + } + if reconciled.Error != "" { + t.Errorf("error = %q, want empty", reconciled.Error) + } +} + +func TestReconcileLeavesLiveTaskAlone(t *testing.T) { + store := newTestStore(t) + task, err := store.Create(CreateOptions{Command: "up"}) + if err != nil { + t.Fatalf("Create: %v", err) + } + holdWorkerLock(t, task) state, err := store.Get(task.ID()) if err != nil { t.Fatalf("Get: %v", err) From 2b4ba67f792c6a72e107f80cc6b52da3aad672a4 Mon Sep 17 00:00:00 2001 From: Samuel K Date: Wed, 29 Jul 2026 21:16:49 -0500 Subject: [PATCH 14/21] style: update comments --- cmd/workspace/up/detach.go | 3 +-- cmd/workspace/up/up.go | 2 +- desktop/src/main/__tests__/ipc-up-tasks.test.ts | 8 -------- pkg/compose/helper_test.go | 9 +-------- pkg/config/pathmanager.go | 2 +- pkg/devcontainer/config/envelope.go | 2 -- pkg/task/export_test_helpers.go | 6 ++---- pkg/task/store.go | 13 +------------ pkg/task/task.go | 4 +--- pkg/task/task_test.go | 11 ----------- 10 files changed, 8 insertions(+), 52 deletions(-) diff --git a/cmd/workspace/up/detach.go b/cmd/workspace/up/detach.go index 37736da0f..89b1cd875 100644 --- a/cmd/workspace/up/detach.go +++ b/cmd/workspace/up/detach.go @@ -105,8 +105,7 @@ func (cmd *UpCmd) openTask() (*task.Task, error) { return nil, err } t := store.Open(cmd.taskID) - // Claim the worker lock before anything else: it's how a poller tells this - // task is still being worked on rather than abandoned by a dead process. + // obtain the worker lock first. if err := t.HoldWorkerLock(); err != nil { return nil, err } diff --git a/cmd/workspace/up/up.go b/cmd/workspace/up/up.go index 3664a56ff..f74dac58a 100644 --- a/cmd/workspace/up/up.go +++ b/cmd/workspace/up/up.go @@ -56,7 +56,7 @@ type UpCmd struct { pullFromInsideContainerFlag bool // See cmd.runDetached. Detach bool - // Set only on the detached child re-exec (--task-id). + // Set only on the detached child re-exec. taskID string // Out receives result/error JSON envelopes; nil falls back to os.Stdout. Out io.Writer diff --git a/desktop/src/main/__tests__/ipc-up-tasks.test.ts b/desktop/src/main/__tests__/ipc-up-tasks.test.ts index 1b482d8c0..802389949 100644 --- a/desktop/src/main/__tests__/ipc-up-tasks.test.ts +++ b/desktop/src/main/__tests__/ipc-up-tasks.test.ts @@ -177,8 +177,6 @@ describe("workspace_up detached task tracking", () => { await invokeUp("ws-1") expect(calls.filter((a) => a.includes("--detach"))).toHaveLength(1) - // The mapping is retained, so a later attempt can still cancel task-1; - // dropping it would leave the task running with no handle to stop it. failCancel = false await invokeStop("ws-1") const cancels = calls.filter((a) => a.includes("cancel")) @@ -204,8 +202,6 @@ describe("workspace_up detached task tracking", () => { }) it("does not treat stderr lines as envelopes", async () => { - // Structured envelopes only ever appear on stdout; stderr carries zap logs - // that may happen to be JSON. const { sent, stream } = setup() await invokeUp("ws-1") @@ -222,8 +218,6 @@ describe("workspace_up detached task tracking", () => { JSON.stringify({ kind: "result", outcome: "success" }), "stdout", ) - // The follower exits afterwards; the task must already be released, so a - // subsequent up has nothing to cancel. stream()?.onExit(0) await invokeUp("ws-1") @@ -245,8 +239,6 @@ describe("workspace_up detached task tracking", () => { }) it("keeps the task registered when the follower exits without an envelope", async () => { - // The follower dying says nothing about the detached worker. Releasing - // here would orphan a still-running task with no id left to cancel it by. const { calls, stream } = setup() await invokeUp("ws-1") diff --git a/pkg/compose/helper_test.go b/pkg/compose/helper_test.go index 0a84caf7f..29888c004 100644 --- a/pkg/compose/helper_test.go +++ b/pkg/compose/helper_test.go @@ -185,11 +185,7 @@ func (r stubRuntime) GPUAvailable(_ context.Context, _ *docker.DockerHelper) (bo } func (s *HelperTestSuite) TestNewComposeHelperPodmanRuntimeUsesDockerCommand() { - // The podman detector shells out to a live `podman compose`, which needs a - // running machine/socket even when the binary is installed. Without this - // probe the helper silently falls through to another compose backend and - // the assertion below fails instead of skipping. Bounded because an - // unreachable machine leaves the probe hanging on the socket. + // Does not work on local development machine because I do not have Podman running probeCtx, cancelProbe := context.WithTimeout(context.Background(), 15*time.Second) defer cancelProbe() if exec.CommandContext(probeCtx, "podman", "compose", "version").Run() != nil { @@ -280,8 +276,5 @@ func (s *HelperTestSuite) TestNewComposeHelperNonPodmanFallbackUsesPodman() { s.T().Skipf("no compose binary available in test environment: %v", err) } - // When Docker runtime succeeds, it should use testDockerCmd — but if Docker Compose V2 - // is unavailable, the fallback should independently probe "podman", not re-try testDockerCmd. - // We verify here that the successful helper uses a valid command. s.Contains([]string{testDockerCmd, testPodmanCmd, testDockerComposeCmd}, ch.Command) } diff --git a/pkg/config/pathmanager.go b/pkg/config/pathmanager.go index 26cf64163..df279aeb6 100644 --- a/pkg/config/pathmanager.go +++ b/pkg/config/pathmanager.go @@ -385,7 +385,7 @@ func (b *basePathManager) LogDir() (string, error) { return filepath.Join(dir, "logs"), nil } -// TaskDir holds pkg/task's state files for detached background tasks. +// TaskDir holds state files for detached background tasks. func (b *basePathManager) TaskDir() (string, error) { dir, err := b.pm.StateDir() if err != nil { diff --git a/pkg/devcontainer/config/envelope.go b/pkg/devcontainer/config/envelope.go index ffbb13424..8a0ae12f7 100644 --- a/pkg/devcontainer/config/envelope.go +++ b/pkg/devcontainer/config/envelope.go @@ -9,8 +9,6 @@ import ( "github.com/devsy-org/devsy/pkg/status" ) -// Kind discriminates the NDJSON lines `up --result-format json` writes to -// stdout. const ( KindStatus = "status" KindResult = "result" diff --git a/pkg/task/export_test_helpers.go b/pkg/task/export_test_helpers.go index 81d5a8f34..5d30d61fc 100644 --- a/pkg/task/export_test_helpers.go +++ b/pkg/task/export_test_helpers.go @@ -1,8 +1,7 @@ package task // ReleaseWorkerLockForTest drops the worker lock so a test can simulate the -// worker dying. Production code never releases it explicitly — the kernel does -// that on process exit, which is the whole point of using a lock for liveness. +// worker dying. func (t *Task) ReleaseWorkerLockForTest() error { if t.workerLock == nil { return nil @@ -11,8 +10,7 @@ func (t *Task) ReleaseWorkerLockForTest() error { } // SetAfterClaimForTest runs fn inside Reconcile, just after it claims the dead -// worker's lock. Lets a test act in the window an implementation that released -// the lock before writing would leave open. +// worker's lock. func (s *Store) SetAfterClaimForTest(fn func()) { s.afterClaimForTest = fn } diff --git a/pkg/task/store.go b/pkg/task/store.go index ea7bf9d9d..a5155adda 100644 --- a/pkg/task/store.go +++ b/pkg/task/store.go @@ -109,11 +109,6 @@ func (s *Store) Get(id string) (*State, error) { // worker killed before it recorded a result leaves the task looking live // forever, so callers that treat non-terminal as "in progress" must check this // first. -// -// Liveness is the worker's lock (see Task.HoldWorkerLock), not its PID: the -// kernel drops the lock however the process dies, and a recycled PID can't -// make a dead worker look alive. A task holding no lock yet is still starting -// up, not abandoned. func (s *Store) Abandoned(state *State) bool { if state == nil || state.Status.Terminal() { return false @@ -134,10 +129,7 @@ func (s *Store) Reconcile(state *State) *State { return state } - // Hold the worker lock across the whole transition. Releasing it before - // the write would let a new worker claim it in the gap and then be marked - // failed while it is actually running — withLock guards state writes, not - // this lock, so it cannot prevent that. + // Hold the worker lock across the whole transition. lock, ok := s.claimDeadWorkerLock(state.ID) if !ok { return state @@ -181,9 +173,6 @@ func (s *Store) List() ([]*State, error) { // failAbandoned records ErrAbandoned for a task whose worker is confirmed gone. // Must be called with the worker lock held. func (s *Store) failAbandoned(state *State) *State { - // Re-read under the lock: the worker may have recorded a terminal result - // between the caller's read and now, in which case there is nothing to - // reconcile and its own outcome must stand. current, err := s.Get(state.ID) if err != nil { return state diff --git a/pkg/task/task.go b/pkg/task/task.go index 6a2845212..aaa6d0f82 100644 --- a/pkg/task/task.go +++ b/pkg/task/task.go @@ -76,9 +76,7 @@ func (t *Task) SetPID(pid int) error { // HoldWorkerLock claims this task's worker lock for the rest of the process's // life, marking it as actively being worked on. Callers must not release it: -// the kernel does so when the process exits, crashes, or is killed, which is -// exactly what lets Store.Abandoned distinguish a live worker from one that -// died without recording a result. +// the kernel does so when the process exits, crashes, or is killed. // // Returns an error if another process already holds the lock, since that means // a worker for this task is already running. diff --git a/pkg/task/task_test.go b/pkg/task/task_test.go index 4c9184f89..a4cb74362 100644 --- a/pkg/task/task_test.go +++ b/pkg/task/task_test.go @@ -366,8 +366,6 @@ func TestConcurrentUpdatesAcrossStoreInstancesAreSerialized(t *testing.T) { } wg.Wait() - // The file must still be valid, readable JSON — not truncated or - // interleaved — after N concurrent read-modify-writes. final, err := workerStore.Get(tsk.ID()) if err != nil { t.Fatalf("Get after concurrent updates: %v", err) @@ -532,7 +530,6 @@ func TestReconcileFailsAbandonedTask(t *testing.T) { } func TestReconcilePersistsTheFailure(t *testing.T) { - // Persisted, so a later poll doesn't re-derive it and `task list` agrees. store := newTestStore(t) state := abandonTask(t, store) @@ -548,10 +545,6 @@ func TestReconcilePersistsTheFailure(t *testing.T) { } func TestReconcileDoesNotFailARestartedWorker(t *testing.T) { - // The gap this closes: an implementation that released the worker lock - // before writing the failure lets a replacement worker claim it in - // between, and then marks that live worker's task failed. The hook fires - // inside Reconcile, in exactly that window. store := newTestStore(t) tk, err := store.Create(CreateOptions{Command: "up"}) if err != nil { @@ -593,8 +586,6 @@ func TestReconcileDoesNotFailARestartedWorker(t *testing.T) { } func TestReconcileKeepsAWorkerRecordedResult(t *testing.T) { - // The worker may finish between the caller's read and the reconcile; its - // own outcome must stand rather than being overwritten with ErrAbandoned. store := newTestStore(t) tk, err := store.Create(CreateOptions{Command: "up"}) if err != nil { @@ -643,8 +634,6 @@ func TestReconcileLeavesLiveTaskAlone(t *testing.T) { } func TestReconcilePreservesCancellationReason(t *testing.T) { - // A canceled task's worker is dead by design; reconciling must not - // overwrite ErrCanceled with the abandoned message. store := newTestStore(t) task, err := store.Create(CreateOptions{Command: "up"}) if err != nil { From 0d202d4af6fc3600eca235296ac752a27f062736 Mon Sep 17 00:00:00 2001 From: Samuel K Date: Wed, 29 Jul 2026 21:44:50 -0500 Subject: [PATCH 15/21] fix(task): fsync task state so a crash can't lose a terminal result MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit write() renamed a temp file without syncing it or the directory. rename(2) stops a reader seeing a torn file, but after a crash the entry could point at unflushed data — and a task that loses its terminal update reads as permanently in-flight, which is exactly the state reconciliation exists to avoid. Sync the temp file before the rename and the directory after, matching pkg/secrets/atomicwrite.go. pkg/provider's helper deliberately skips the directory sync because its callers re-resolve on the next run; task state has no such fallback. Costs ~8ms per update, so ~160ms across a workspace up's ~20 status events — immaterial against a multi-minute provision, and the writes were already serialized under the per-task lock. --- pkg/task/store.go | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/pkg/task/store.go b/pkg/task/store.go index a5155adda..512f5ce06 100644 --- a/pkg/task/store.go +++ b/pkg/task/store.go @@ -292,11 +292,31 @@ func (s *Store) write(state *State) error { _ = tmp.Close() return fmt.Errorf("write task state: %w", err) } + // Synced before the rename, and the directory after it: rename(2) alone + // keeps readers from seeing a torn file, but a crash can still leave the + // entry pointing at unflushed data. A task that loses its terminal update + // reads as permanently in-flight, so this is worth the fsync. + if err := tmp.Sync(); err != nil { + _ = tmp.Close() + return fmt.Errorf("sync temp task file: %w", err) + } if err := tmp.Close(); err != nil { return fmt.Errorf("close temp task file: %w", err) } if err := os.Rename(tmpPath, target); err != nil { return fmt.Errorf("commit task state: %w", err) } + syncDir(s.dir) return nil } + +// syncDir fsyncs a directory so a rename survives a crash. Best-effort: some +// filesystems reject directory sync, and durability is not correctness here. +func syncDir(dir string) { + d, err := os.Open(dir) // #nosec G304 -- store's own task dir. + if err != nil { + return + } + defer func() { _ = d.Close() }() + _ = d.Sync() +} From a9694b9ade9f1fd1c312afa347173fbc2d0a15e8 Mon Sep 17 00:00:00 2001 From: Samuel K Date: Wed, 29 Jul 2026 23:15:45 -0500 Subject: [PATCH 16/21] style: update comments --- pkg/task/store.go | 17 +++-------------- 1 file changed, 3 insertions(+), 14 deletions(-) diff --git a/pkg/task/store.go b/pkg/task/store.go index 512f5ce06..de67c2c90 100644 --- a/pkg/task/store.go +++ b/pkg/task/store.go @@ -105,10 +105,7 @@ func (s *Store) Get(id string) (*State, error) { return state, nil } -// Abandoned reports whether a non-terminal task's worker is gone. A detached -// worker killed before it recorded a result leaves the task looking live -// forever, so callers that treat non-terminal as "in progress" must check this -// first. +// Abandoned reports whether a non-terminal task's worker is gone. func (s *Store) Abandoned(state *State) bool { if state == nil || state.Status.Terminal() { return false @@ -191,10 +188,7 @@ func (s *Store) failAbandoned(state *State) *State { } // claimDeadWorkerLock acquires the task's worker lock, which only succeeds when -// no worker holds it — i.e. the worker exited without recording a result. The -// caller owns unlocking. ok=false means a worker is alive, none ever started, -// or liveness could not be determined (assume alive over failing a healthy -// task). +// no worker holds it. func (s *Store) claimDeadWorkerLock(id string) (*flock.Flock, bool) { path, err := s.workerLockPath(id) if err != nil { @@ -292,10 +286,6 @@ func (s *Store) write(state *State) error { _ = tmp.Close() return fmt.Errorf("write task state: %w", err) } - // Synced before the rename, and the directory after it: rename(2) alone - // keeps readers from seeing a torn file, but a crash can still leave the - // entry pointing at unflushed data. A task that loses its terminal update - // reads as permanently in-flight, so this is worth the fsync. if err := tmp.Sync(); err != nil { _ = tmp.Close() return fmt.Errorf("sync temp task file: %w", err) @@ -310,8 +300,7 @@ func (s *Store) write(state *State) error { return nil } -// syncDir fsyncs a directory so a rename survives a crash. Best-effort: some -// filesystems reject directory sync, and durability is not correctness here. +// syncDir fsyncs a directory so a rename survives a crash. func syncDir(dir string) { d, err := os.Open(dir) // #nosec G304 -- store's own task dir. if err != nil { From 416d00d365d9a7ec803b7a7bf25287e52ec09273 Mon Sep 17 00:00:00 2001 From: Samuel K Date: Thu, 30 Jul 2026 00:08:58 -0500 Subject: [PATCH 17/21] style: update podman variable --- pkg/compose/helper_test.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pkg/compose/helper_test.go b/pkg/compose/helper_test.go index 29888c004..b14d58364 100644 --- a/pkg/compose/helper_test.go +++ b/pkg/compose/helper_test.go @@ -185,10 +185,10 @@ func (r stubRuntime) GPUAvailable(_ context.Context, _ *docker.DockerHelper) (bo } func (s *HelperTestSuite) TestNewComposeHelperPodmanRuntimeUsesDockerCommand() { - // Does not work on local development machine because I do not have Podman running + // Skip for local development when I do not have Podman machine running probeCtx, cancelProbe := context.WithTimeout(context.Background(), 15*time.Second) defer cancelProbe() - if exec.CommandContext(probeCtx, "podman", "compose", "version").Run() != nil { + if exec.CommandContext(probeCtx, testPodmanCmd, "compose", "version").Run() != nil { s.T().Skip("podman compose not reachable (is podman machine running?)") } From 6bee2861ef19cf1ac6ea4e1796597d4a163ef0c8 Mon Sep 17 00:00:00 2001 From: Samuel K Date: Thu, 30 Jul 2026 00:11:02 -0500 Subject: [PATCH 18/21] fix: apply testfile naming convention --- pkg/task/{export_test_helpers.go => export_test.go} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename pkg/task/{export_test_helpers.go => export_test.go} (100%) diff --git a/pkg/task/export_test_helpers.go b/pkg/task/export_test.go similarity index 100% rename from pkg/task/export_test_helpers.go rename to pkg/task/export_test.go From 3f63b4dedc1fa1bdbbe980aed80b997339c242a4 Mon Sep 17 00:00:00 2001 From: Samuel K Date: Thu, 30 Jul 2026 09:19:34 -0500 Subject: [PATCH 19/21] fix(task): make the worker-lock test seam visible across packages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ReleaseWorkerLockForTest lived in export_test.go, which is compiled only into pkg/task's own test binary. workspace_client_status_test.go calls it from pkg/client/clientimplementation, so that package failed to build: tk.ReleaseWorkerLockForTest undefined (type *task.Task has no field or method ReleaseWorkerLockForTest) Move it to task.go so other packages' tests can link against it, and clear the stored handle on release so a double call can't unlock twice. SetAfterClaimForTest stays in export_test.go — only pkg/task uses it. --- pkg/task/export_test.go | 9 --------- pkg/task/task.go | 20 ++++++++++++++++++++ 2 files changed, 20 insertions(+), 9 deletions(-) diff --git a/pkg/task/export_test.go b/pkg/task/export_test.go index 5d30d61fc..9ffbc395a 100644 --- a/pkg/task/export_test.go +++ b/pkg/task/export_test.go @@ -1,14 +1,5 @@ package task -// ReleaseWorkerLockForTest drops the worker lock so a test can simulate the -// worker dying. -func (t *Task) ReleaseWorkerLockForTest() error { - if t.workerLock == nil { - return nil - } - return t.workerLock.Unlock() -} - // SetAfterClaimForTest runs fn inside Reconcile, just after it claims the dead // worker's lock. func (s *Store) SetAfterClaimForTest(fn func()) { diff --git a/pkg/task/task.go b/pkg/task/task.go index aaa6d0f82..9d6dae30f 100644 --- a/pkg/task/task.go +++ b/pkg/task/task.go @@ -100,6 +100,26 @@ func (t *Task) HoldWorkerLock() error { return nil } +// ReleaseWorkerLockForTest drops the lock HoldWorkerLock acquired, letting a +// test simulate a worker dying and close the descriptor so its temp dir can be +// removed. Production code must never call it: a worker holds its lock for the +// life of the process, and releasing early makes a live task look abandoned. +// +// Deliberately in a regular file rather than export_test.go — tests in other +// packages (e.g. pkg/client/clientimplementation) need it, and a _test.go file +// is only compiled into its own package's test binary. +func (t *Task) ReleaseWorkerLockForTest() error { + if t.workerLock == nil { + return nil + } + lock := t.workerLock + t.workerLock = nil + if err := lock.Unlock(); err != nil { + return fmt.Errorf("unlock task %s worker: %w", t.id, err) + } + return nil +} + // SetWorkspaceID corrects the task's workspace label to the resolved ID, // which may differ from whatever label it was created with (e.g. a raw // source string guessed before workspace resolution ran). client.Status From 183d5e7c5c605629ad83d14e5913bf3a15458409 Mon Sep 17 00:00:00 2001 From: Samuel K Date: Thu, 30 Jul 2026 09:28:12 -0500 Subject: [PATCH 20/21] style: trim explanatory comments to essentials --- desktop/src/main/__tests__/ipc-up-tasks.test.ts | 9 +-------- desktop/src/main/ipc.ts | 10 +++------- pkg/agent/tunnelserver/options.go | 3 +-- pkg/task/store.go | 4 ++-- pkg/task/task.go | 9 +++------ 5 files changed, 10 insertions(+), 25 deletions(-) diff --git a/desktop/src/main/__tests__/ipc-up-tasks.test.ts b/desktop/src/main/__tests__/ipc-up-tasks.test.ts index 802389949..9d57acbe8 100644 --- a/desktop/src/main/__tests__/ipc-up-tasks.test.ts +++ b/desktop/src/main/__tests__/ipc-up-tasks.test.ts @@ -61,7 +61,6 @@ function setup(overrides: { run?: (args: string[]) => Promise } = {}) { if (args.includes("--detach")) return { kind: "task", id: "task-1" } return {} }), - // Captures the follower's callbacks so tests can drive NDJSON lines. runStreaming: vi.fn( async ( _args: string[], @@ -78,8 +77,6 @@ function setup(overrides: { run?: (args: string[]) => Promise } = {}) { onLine: (line: string, s: "stdout" | "stderr") => void onExit: (code: number, cliError?: unknown) => void } | null = null - // A real window so the workspace-status send path is exercised; returning - // null (as before) meant that branch never ran. const sent: Array<{ channel: string; payload: Record }> = [] const win = { webContents: { @@ -140,8 +137,7 @@ describe("workspace_up detached task tracking", () => { const { calls } = setup({ run: async (args) => { if (args.includes("--detach")) { - // Yield, so an unserialized handler would interleave here and both - // submissions would pass the cancel step before either registered. + // Yield so an unserialized handler would interleave here. await new Promise((r) => setTimeout(r, 5)) seq += 1 return { kind: "task", id: `task-${seq}` } @@ -152,7 +148,6 @@ describe("workspace_up detached task tracking", () => { await Promise.all([invokeUp("ws-1"), invokeUp("ws-1")]) - // The second submission must have observed and cancelled the first. const cancels = calls.filter((a) => a.includes("cancel")) expect(cancels).toEqual([["workspace", "task", "cancel", "task-1"]]) }) @@ -172,8 +167,6 @@ describe("workspace_up detached task tracking", () => { await invokeUp("ws-1") - // A failed cancel must abort the replacement rather than starting a - // second `up` alongside the task that is still running. await invokeUp("ws-1") expect(calls.filter((a) => a.includes("--detach"))).toHaveLength(1) diff --git a/desktop/src/main/ipc.ts b/desktop/src/main/ipc.ts index 1c023fb79..767ed183f 100644 --- a/desktop/src/main/ipc.ts +++ b/desktop/src/main/ipc.ts @@ -803,8 +803,6 @@ export function registerIpcHandlers(deps: IpcDependencies): { ...cliArgs, "--detach", ]) - // A missing id would be stored and later used to cancel a task that - // does not exist. if (!submitted?.id) { throw new Error("workspace up --detach returned no task id") } @@ -822,8 +820,7 @@ export function registerIpcHandlers(deps: IpcDependencies): { } activeUpTasks.set(wsId, taskId) - // Only clear the entry while it still refers to this task; a newer - // submission may already own it and must stay cancellable. + // A newer submission may already own the entry and must stay cancellable. const releaseTask = () => { if (activeUpTasks.get(wsId) === taskId) { activeUpTasks.delete(wsId) @@ -875,9 +872,8 @@ export function registerIpcHandlers(deps: IpcDependencies): { if (!sink.line(formatted)) return logStore.onDrain(logPath) }, (code, cliError) => { - // Only the terminal envelopes above release the task. The follower - // dying says nothing about the detached worker, and unregistering - // here would leave a live task with no id to cancel it by. + // No releaseTask: the follower dying says nothing about the + // detached worker, and would orphan a still-running task. if (tunnelProcesses.get(wsId) === child) { tunnelProcesses.delete(wsId) } diff --git a/pkg/agent/tunnelserver/options.go b/pkg/agent/tunnelserver/options.go index fcaafbcf1..695ef3036 100644 --- a/pkg/agent/tunnelserver/options.go +++ b/pkg/agent/tunnelserver/options.go @@ -77,8 +77,7 @@ func WithGitToken(token *provider2.GitToken) Option { } // WithStatusReporter forwards inbound StatusUpdate RPCs to reporter. A nil -// reporter is ignored rather than installed: it would replace the Nop default -// and panic the next time a status event was reported. +// reporter is ignored, since it would replace the Nop default and panic. func WithStatusReporter(reporter status.Reporter) Option { return func(s *tunnelServer) *tunnelServer { if reporter != nil { diff --git a/pkg/task/store.go b/pkg/task/store.go index de67c2c90..59590b79d 100644 --- a/pkg/task/store.go +++ b/pkg/task/store.go @@ -126,14 +126,14 @@ func (s *Store) Reconcile(state *State) *State { return state } - // Hold the worker lock across the whole transition. + // Held across the whole transition: releasing before the write would let a + // new worker claim the lock and then be marked failed while running. lock, ok := s.claimDeadWorkerLock(state.ID) if !ok { return state } defer func() { _ = lock.Unlock() }() - // Lets a test act in the window a buggy implementation would leave open. if s.afterClaimForTest != nil { s.afterClaimForTest() } diff --git a/pkg/task/task.go b/pkg/task/task.go index 9d6dae30f..0282456a9 100644 --- a/pkg/task/task.go +++ b/pkg/task/task.go @@ -101,13 +101,10 @@ func (t *Task) HoldWorkerLock() error { } // ReleaseWorkerLockForTest drops the lock HoldWorkerLock acquired, letting a -// test simulate a worker dying and close the descriptor so its temp dir can be -// removed. Production code must never call it: a worker holds its lock for the -// life of the process, and releasing early makes a live task look abandoned. +// test simulate a dead worker. Production code must never call it. // -// Deliberately in a regular file rather than export_test.go — tests in other -// packages (e.g. pkg/client/clientimplementation) need it, and a _test.go file -// is only compiled into its own package's test binary. +// Not in export_test.go: other packages' tests need it, and a _test.go file is +// only compiled into its own package's test binary. func (t *Task) ReleaseWorkerLockForTest() error { if t.workerLock == nil { return nil From 4206641a70e8d4f73d97e25b0b6483f1663050e8 Mon Sep 17 00:00:00 2001 From: Samuel K Date: Thu, 30 Jul 2026 14:48:19 -0500 Subject: [PATCH 21/21] fix(desktop): handle a log follower that fails to start cli.runStreaming ran outside the submit try block, so a spawn failure rejected the handler after the detached task was already created: the log sink stayed open and the task kept its map entry with no follower and no error surfaced. Catch it, report through the sink, and keep the task registered so a later stop/delete can still cancel it. --- .../src/main/__tests__/ipc-up-tasks.test.ts | 21 ++- desktop/src/main/ipc.ts | 144 ++++++++++-------- 2 files changed, 101 insertions(+), 64 deletions(-) diff --git a/desktop/src/main/__tests__/ipc-up-tasks.test.ts b/desktop/src/main/__tests__/ipc-up-tasks.test.ts index 9d57acbe8..560362159 100644 --- a/desktop/src/main/__tests__/ipc-up-tasks.test.ts +++ b/desktop/src/main/__tests__/ipc-up-tasks.test.ts @@ -52,7 +52,12 @@ function fakeChild() { return child } -function setup(overrides: { run?: (args: string[]) => Promise } = {}) { +function setup( + overrides: { + run?: (args: string[]) => Promise + failStreaming?: Error + } = {}, +) { const calls: string[][] = [] const cli = { run: vi.fn(async (args: string[]) => { @@ -67,6 +72,7 @@ function setup(overrides: { run?: (args: string[]) => Promise } = {}) { onLine: (line: string, stream: "stdout" | "stderr") => void, onExit: (code: number, cliError?: unknown) => void, ) => { + if (overrides.failStreaming) throw overrides.failStreaming stream = { onLine, onExit } return fakeChild() }, @@ -231,6 +237,19 @@ describe("workspace_up detached task tracking", () => { expect(calls.filter((a) => a.includes("cancel"))).toEqual([]) }) + it("keeps the task cancellable when the follower fails to start", async () => { + // The task is already submitted at this point, so losing the id here would + // orphan a running workspace up with nothing left to cancel it by. + const { calls } = setup({ failStreaming: new Error("spawn ENOENT") }) + + await expect(invokeUp("ws-1")).resolves.toBeTruthy() + + await invokeStop("ws-1") + expect(calls.filter((a) => a.includes("cancel"))).toEqual([ + ["workspace", "task", "cancel", "task-1"], + ]) + }) + it("keeps the task registered when the follower exits without an envelope", async () => { const { calls, stream } = setup() await invokeUp("ws-1") diff --git a/desktop/src/main/ipc.ts b/desktop/src/main/ipc.ts index 767ed183f..191867971 100644 --- a/desktop/src/main/ipc.ts +++ b/desktop/src/main/ipc.ts @@ -637,7 +637,10 @@ export function registerIpcHandlers(deps: IpcDependencies): { async (_event, args: { name: string; value: string }) => { trackEvent("secret_set") try { - await cli.runRawStdin(["secret", "set", args.name, "--stdin"], args.value) + await cli.runRawStdin( + ["secret", "set", args.name, "--stdin"], + args.value, + ) return { ok: true } as const } catch (err) { const cliError = (err as { cliError?: CLIError }).cliError @@ -775,8 +778,7 @@ export function registerIpcHandlers(deps: IpcDependencies): { if (args.debug) cliArgs.push("--debug") if (args.workspaceFolder) cliArgs.push("--workspace-folder", args.workspaceFolder) - if (args.devcontainer) - cliArgs.push("--devcontainer", args.devcontainer) + if (args.devcontainer) cliArgs.push("--devcontainer", args.devcontainer) if (args.prebuildRepository) cliArgs.push("--prebuild-repo", args.prebuildRepository) if (args.platform) cliArgs.push("--platform", args.platform) @@ -828,66 +830,82 @@ export function registerIpcHandlers(deps: IpcDependencies): { } let signalledDone = false - const child = await cli.runStreaming( - ["workspace", "task", "logs", taskId, "--follow"], - (line, stream) => { - if (signalledDone) return - - // Structured NDJSON envelopes only ever appear on stdout; stderr - // carries freeform zap log lines. - const envelope = - stream === "stdout" ? parseCliEnvelope(line) : undefined - - if (envelope?.kind === "status") { - deps.getMainWindow()?.webContents.send("workspace-status", { - commandId: cmdId, - workspaceId: wsId, - phase: envelope.phase, - step: envelope.step, - started: envelope.started, - error: envelope.error, - }) - return - } - - const formatted = formatLogLine(line) - - if (envelope?.kind === "result") { - signalledDone = true - releaseTask() - void sink.done(formatted) - return - } - - if (envelope?.kind === "error") { - signalledDone = true - releaseTask() - void sink.done(formatted, { - level: "error", - cliError: { code: "up_failed", message: envelope.message }, - }) - return - } - - if (!sink.line(formatted)) return logStore.onDrain(logPath) - }, - (code, cliError) => { - // No releaseTask: the follower dying says nothing about the - // detached worker, and would orphan a still-running task. - if (tunnelProcesses.get(wsId) === child) { - tunnelProcesses.delete(wsId) - } - if (signalledDone) return - void sink.done( - formatLogLine( - `Exit code: ${code}`, - code === 0 ? "INFO" : "ERROR", - ), - code === 0 ? undefined : { level: "error", cliError }, - ) - }, - wsId, - ) + let child: import("node:child_process").ChildProcess + try { + child = await cli.runStreaming( + ["workspace", "task", "logs", taskId, "--follow"], + (line, stream) => { + if (signalledDone) return + + // Structured NDJSON envelopes only ever appear on stdout; stderr + // carries freeform zap log lines. + const envelope = + stream === "stdout" ? parseCliEnvelope(line) : undefined + + if (envelope?.kind === "status") { + deps.getMainWindow()?.webContents.send("workspace-status", { + commandId: cmdId, + workspaceId: wsId, + phase: envelope.phase, + step: envelope.step, + started: envelope.started, + error: envelope.error, + }) + return + } + + const formatted = formatLogLine(line) + + if (envelope?.kind === "result") { + signalledDone = true + releaseTask() + void sink.done(formatted) + return + } + + if (envelope?.kind === "error") { + signalledDone = true + releaseTask() + void sink.done(formatted, { + level: "error", + cliError: { code: "up_failed", message: envelope.message }, + }) + return + } + + if (!sink.line(formatted)) return logStore.onDrain(logPath) + }, + (code, cliError) => { + // No releaseTask: the follower dying says nothing about the + // detached worker, and would orphan a still-running task. + if (tunnelProcesses.get(wsId) === child) { + tunnelProcesses.delete(wsId) + } + if (signalledDone) return + void sink.done( + formatLogLine( + `Exit code: ${code}`, + code === 0 ? "INFO" : "ERROR", + ), + code === 0 ? undefined : { level: "error", cliError }, + ) + }, + wsId, + ) + } catch (error) { + // The task is already submitted; keep it registered so a later + // cancel can still reach it, and close the sink so the UI isn't + // left waiting on a follower that never started. + const err = error as Error & { cliError?: CLIError } + void sink.done(formatLogLine(err.message, "ERROR"), { + level: "error", + cliError: err.cliError ?? { + code: "up_follow_failed", + message: err.message, + }, + }) + return cmdId + } tunnelProcesses.set(wsId, child) return cmdId