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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion cmd/internal/agentworkspace/up.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)
}

Expand Down
23 changes: 17 additions & 6 deletions pkg/agent/agent.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand All @@ -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) {
Expand Down
7 changes: 2 additions & 5 deletions pkg/agent/workspace.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
9 changes: 9 additions & 0 deletions pkg/client/clientimplementation/workspace_client.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down
25 changes: 25 additions & 0 deletions pkg/config/pathmanager.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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.
Expand Down
10 changes: 10 additions & 0 deletions pkg/config/pathmanager_linux_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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") },
Expand Down
11 changes: 11 additions & 0 deletions pkg/docker/helper.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
54 changes: 54 additions & 0 deletions pkg/driver/docker/docker.go
Original file line number Diff line number Diff line change
Expand Up @@ -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, ",") {
Expand Down
38 changes: 38 additions & 0 deletions pkg/driver/docker/docker_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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},
Expand Down
16 changes: 16 additions & 0 deletions pkg/provider/dir.go
Original file line number Diff line number Diff line change
Expand Up @@ -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/<ctx>/contents).
func GetWorkspaceContentsDir(context string) (string, error) {
return config.DefaultPathManager().WorkspaceContentsDir(context)
}

// GetWorkspaceContentDir returns the host-side bind-mount source for a
// workspace (.../contexts/<ctx>/contents/<id>).
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/<id>/logs) used by the desktop's streaming-log store.
func GetWorkspaceLogDir(context, workspaceID string) (string, error) {
Expand Down
Loading