diff --git a/cmd/internal/agentworkspace/up.go b/cmd/internal/agentworkspace/up.go index 28ed2a1b5..d52e2680e 100644 --- a/cmd/internal/agentworkspace/up.go +++ b/cmd/internal/agentworkspace/up.go @@ -544,7 +544,6 @@ func prepareWorkspace(params prepareWorkspaceParams) error { params.workspaceInfo.Workspace.Source.LocalFolder != "" { params.workspaceInfo.ContentFolder = agent.GetAgentWorkspaceContentDir( params.workspaceInfo.Origin, - params.workspaceInfo.Workspace.UID, ) } diff --git a/pkg/agent/agent.go b/pkg/agent/agent.go index 85fda2fce..49f824a06 100644 --- a/pkg/agent/agent.go +++ b/pkg/agent/agent.go @@ -246,7 +246,7 @@ func handleStaleWorkspace( return "", fmt.Errorf("recreate workspace dir: %w", err) } - // Drop the old UID's path so resolveContentFolder picks up the new one. + // Clear so resolveContentFolder recomputes against the recreated dir. workspaceInfo.ContentFolder = "" return newDir, nil } @@ -264,12 +264,23 @@ func resolveContentFolder( workspaceInfo.ContentFolder = workspaceInfo.Workspace.Source.LocalFolder } } - if workspaceInfo.ContentFolder == "" { - workspaceInfo.ContentFolder = GetAgentWorkspaceContentDir( - workspaceDir, - workspaceInfo.Workspace.UID, - ) + if workspaceInfo.ContentFolder != "" { + return + } + + // On the host, place the bind-mount source under the delete-stable + // "contents" dir so its parent inode survives up -> delete -> up. + if IsHostAgentInvocation(workspaceInfo.Agent.DataPath) { + if contentDir, err := provider2.GetWorkspaceContentDir( + workspaceInfo.Workspace.Context, + workspaceInfo.Workspace.ID, + ); err == nil { + workspaceInfo.ContentFolder = contentDir + return + } } + + workspaceInfo.ContentFolder = GetAgentWorkspaceContentDir(workspaceDir) } func CreateWorkspaceBusyFile(folder string) { diff --git a/pkg/agent/workspace.go b/pkg/agent/workspace.go index 5b4ba88e0..38e241786 100644 --- a/pkg/agent/workspace.go +++ b/pkg/agent/workspace.go @@ -215,11 +215,8 @@ echo Devsy return true, nil } -// GetAgentWorkspaceContentDir returns the bind-mount source for a workspace. -// The UID suffix ensures a delete-then-recreate cycle never reuses the same -// host path, avoiding Docker Desktop's stale /host_mnt inode cache. -func GetAgentWorkspaceContentDir(workspaceDir, uid string) string { - return filepath.Join(workspaceDir, "content-"+uid) +func GetAgentWorkspaceContentDir(workspaceDir string) string { + return filepath.Join(workspaceDir, "content") } func GetAgentBinariesDirFromWorkspaceDir(workspaceDir string) (string, error) { diff --git a/pkg/client/clientimplementation/workspace_client.go b/pkg/client/clientimplementation/workspace_client.go index c19064f77..8ae274df9 100644 --- a/pkg/client/clientimplementation/workspace_client.go +++ b/pkg/client/clientimplementation/workspace_client.go @@ -870,6 +870,15 @@ func DeleteWorkspaceFolder(params DeleteWorkspaceFolderParams) error { return err } + // remove the content folder leaf (outside WorkspaceDir); leave its parent + // "contents" dir in place to keep its host inode stable across recreate. + contentFolder, err := provider.GetWorkspaceContentDir(params.Context, params.WorkspaceID) + if err == nil { + if err := os.RemoveAll(contentFolder); err != nil && !os.IsNotExist(err) { + return err + } + } + return nil } diff --git a/pkg/config/pathmanager.go b/pkg/config/pathmanager.go index dead55942..006f702be 100644 --- a/pkg/config/pathmanager.go +++ b/pkg/config/pathmanager.go @@ -41,6 +41,8 @@ type PathManager interface { WorkspacesDir(context string) (string, error) WorkspaceDir(context, workspaceID string) (string, error) WorkspaceAgentDir(context, workspaceID string) (string, error) + WorkspaceContentsDir(context string) (string, error) + WorkspaceContentDir(context, workspaceID string) (string, error) WorkspaceLogDir(context, workspaceID string) (string, error) MachinesDir(context string) (string, error) MachineDir(context, machineID string) (string, error) @@ -135,6 +137,29 @@ func (b *basePathManager) WorkspaceAgentDir(context, workspaceID string) (string return filepath.Join(dir, "agent"), nil } +// WorkspaceContentsDir is the per-context parent of workspace content folders. +// It is never removed on delete, keeping a stable host inode for the bind-mount +// source's parent across up -> delete -> up (avoids Docker Desktop's stale +// file-share inode cache). +func (b *basePathManager) WorkspaceContentsDir(context string) (string, error) { + dir, err := b.ContextDir(context) + if err != nil { + return "", err + } + + return filepath.Join(dir, "contents"), nil +} + +// WorkspaceContentDir is the bind-mount source leaf under WorkspaceContentsDir. +func (b *basePathManager) WorkspaceContentDir(context, workspaceID string) (string, error) { + dir, err := b.WorkspaceContentsDir(context) + if err != nil { + return "", err + } + + return filepath.Join(dir, workspaceID), nil +} + // WorkspaceLogDir is the per-workspace log directory used by the desktop's // streaming-log store. Lives under WorkspaceDir for the same reason as // WorkspaceAgentDir. diff --git a/pkg/config/pathmanager_linux_test.go b/pkg/config/pathmanager_linux_test.go index 8ff235506..58d0b0455 100644 --- a/pkg/config/pathmanager_linux_test.go +++ b/pkg/config/pathmanager_linux_test.go @@ -131,6 +131,16 @@ func TestContextDataSubPaths(t *testing.T) { func() (string, error) { return pm.WorkspaceAgentDir(ctx, "ws1") }, filepath.Join(base, "workspaces", "ws1", "agent"), }, + { + "WorkspaceContentsDir", + func() (string, error) { return pm.WorkspaceContentsDir(ctx) }, + filepath.Join(base, "contents"), + }, + { + "WorkspaceContentDir", + func() (string, error) { return pm.WorkspaceContentDir(ctx, "ws1") }, + filepath.Join(base, "contents", "ws1"), + }, { "WorkspaceLogDir", func() (string, error) { return pm.WorkspaceLogDir(ctx, "ws1") }, diff --git a/pkg/docker/helper.go b/pkg/docker/helper.go index e72c3a020..1cc34a30d 100644 --- a/pkg/docker/helper.go +++ b/pkg/docker/helper.go @@ -75,6 +75,17 @@ func (r *DockerHelper) GetRuntime() ContainerRuntime { return DetectRuntime(r.DockerCommand) } +// ClientVersion returns the docker CLI version (e.g. "29.5.3"), or "". +func (r *DockerHelper) ClientVersion(ctx context.Context) string { + cctx, cancel := context.WithTimeout(ctx, 5*time.Second) + defer cancel() + out, err := r.buildCmd(cctx, "version", "--format", "{{.Client.Version}}").Output() + if err != nil { + return "" + } + return strings.TrimSpace(string(out)) +} + func (r *DockerHelper) FindDevContainer( ctx context.Context, labels []string, diff --git a/pkg/driver/docker/docker.go b/pkg/driver/docker/docker.go index 5526b2fe6..f83360472 100644 --- a/pkg/driver/docker/docker.go +++ b/pkg/driver/docker/docker.go @@ -660,11 +660,65 @@ func (d *dockerDriver) addWorkspaceMountArgs( if !helper.GetRuntime().SupportsMountConsistency() { mountPath = stripMountConsistency(mountPath) } + if shouldBustBindCache(helper) { + mountPath = withBindCreateSrc(mountPath) + } args = append(args, "--mount", mountPath) } return args } +// minBindCreateSrcMajor is the docker CLI major version that added the +// bind-create-src mount option; older clients reject it at parse time. +const minBindCreateSrcMajor = 29 + +// shouldBustBindCache reports whether to add bind-create-src to the workspace +// mount. Gated to Docker (Podman/nerdctl reject it), Docker Desktop +// (GOOS != linux), and CLI >= v29. +func shouldBustBindCache(helper *docker.DockerHelper) bool { + if helper.GetRuntime().Name() != docker.RuntimeDocker { + return false + } + if runtime.GOOS == "linux" { + return false + } + return dockerMajorAtLeast(helper.ClientVersion(context.Background()), minBindCreateSrcMajor) +} + +// dockerMajorAtLeast reports whether version's major component is >= minMajor. +// An unparseable version returns false. +func dockerMajorAtLeast(version string, minMajor int) bool { + major, _, ok := strings.Cut(version, ".") + if !ok { + return false + } + n, err := strconv.Atoi(strings.TrimSpace(major)) + if err != nil { + return false + } + return n >= minMajor +} + +// withBindCreateSrc adds bind-create-src=true to a bind --mount whose source +// exists, forcing Docker Desktop to re-resolve a stale-cached path. A missing +// source is left untouched so docker still fails loudly rather than binding an +// empty dir. +func withBindCreateSrc(mountPath string) string { + m := config.ParseMount(mountPath) + if m.Type != "bind" || m.Source == "" { + return mountPath + } + for part := range strings.SplitSeq(mountPath, ",") { + if strings.HasPrefix(part, "bind-create-src=") { + return mountPath + } + } + if _, err := os.Stat(m.Source); err != nil { + return mountPath + } + return mountPath + ",bind-create-src=true" +} + func stripMountConsistency(mount string) string { var parts []string for part := range strings.SplitSeq(mount, ",") { diff --git a/pkg/driver/docker/docker_test.go b/pkg/driver/docker/docker_test.go index 89d2d6003..73a449d1b 100644 --- a/pkg/driver/docker/docker_test.go +++ b/pkg/driver/docker/docker_test.go @@ -265,6 +265,44 @@ func (s *DockerDriverTestSuite) TestStripMountConsistency() { } } +func (s *DockerDriverTestSuite) TestWithBindCreateSrc() { + existing := s.T().TempDir() + + // source exists -> option appended + withExisting := "type=bind,src=" + existing + ",dst=/b" + s.Equal(withExisting+",bind-create-src=true", withBindCreateSrc(withExisting)) + + // source missing -> untouched + s.Equal(testBindMount, withBindCreateSrc(testBindMount)) + + // idempotent + already := withExisting + ",bind-create-src=true" + s.Equal(already, withBindCreateSrc(already)) + + // non-bind ignored + vol := "type=volume,src=myvol,dst=/b" + s.Equal(vol, withBindCreateSrc(vol)) +} + +func (s *DockerDriverTestSuite) TestDockerMajorAtLeast() { + tests := []struct { + version string + want bool + }{ + {"29.5.3", true}, + {"29.0.0", true}, + {"30.1.0", true}, + {"28.0.4", false}, + {"20.10.21", false}, + {"", false}, + {"garbage", false}, + } + for _, tt := range tests { + s.Equalf(tt.want, dockerMajorAtLeast(tt.version, minBindCreateSrcMajor), + "dockerMajorAtLeast(%q)", tt.version) + } +} + func (s *DockerDriverTestSuite) TestAddRunPlatform_SetAppendsFlag() { b := &runArgsBuilder{ args: []string{testRunArg}, diff --git a/pkg/provider/dir.go b/pkg/provider/dir.go index c42fa46cd..c5e5fd2b3 100644 --- a/pkg/provider/dir.go +++ b/pkg/provider/dir.go @@ -85,6 +85,22 @@ func GetWorkspaceAgentDir(context, workspaceID string) (string, error) { return config.DefaultPathManager().WorkspaceAgentDir(context, workspaceID) } +// GetWorkspaceContentsDir returns the per-context parent of workspace content +// folders (.../contexts//contents). +func GetWorkspaceContentsDir(context string) (string, error) { + return config.DefaultPathManager().WorkspaceContentsDir(context) +} + +// GetWorkspaceContentDir returns the host-side bind-mount source for a +// workspace (.../contexts//contents/). +func GetWorkspaceContentDir(context, workspaceID string) (string, error) { + if workspaceID == "" { + return "", fmt.Errorf("workspace id is empty") + } + + return config.DefaultPathManager().WorkspaceContentDir(context, workspaceID) +} + // GetWorkspaceLogDir returns the per-workspace log dir // (.../workspaces//logs) used by the desktop's streaming-log store. func GetWorkspaceLogDir(context, workspaceID string) (string, error) {