Skip to content

refactor: rename single.Single() to command.StartWithLockAndLogging()#637

Open
imphil wants to merge 3 commits intoskevetter:mainfrom
imphil:refactor-single
Open

refactor: rename single.Single() to command.StartWithLockAndLogging()#637
imphil wants to merge 3 commits intoskevetter:mainfrom
imphil:refactor-single

Conversation

@imphil
Copy link

@imphil imphil commented Mar 24, 2026

The function single.Single() executes a process in the background and
prevents that a process with the same (user-supplied) name is executed
again until it finishes. Hence the name "single" -- only a single
process of the given name can run at any time.

The naming is not self-descriptive, however, and easily confused with
devcontainer/single.go (where single means running a single container,
as opposed to multiple containers with docker-compose).

Rename the file and update all its users. Refactor its internal
functionality to meet the requirements of lint (e.g., reduced cyclomatic
complexity).

Functional change: the temporary files are no longer named like
something.pid.streams, but only something.streams, which seems more
accurate.

Summary by CodeRabbit

  • Refactor
    • Standardized background process startup across daemon and IDE integrations (Fleet, Jupyter, OpenVSCode, RStudio, VS Code extensions).
    • Replaced legacy PID-file coordination with a unified lock-and-logging startup mechanism to reduce duplicate starts and improve background startup reliability and logging.
    • Removed the older single-instance helper in favor of the new unified approach.

@coderabbitai
Copy link

coderabbitai bot commented Mar 24, 2026

📝 Walkthrough

Walkthrough

Replaces the PID-file based pkg/single singleton launcher with a new pkg/command.StartWithLockAndLogging helper and updates daemon, IDE, and container startup callers to use logical lock names; removes the pkg/single package.

Changes

Cohort / File(s) Summary
Command launcher
pkg/command/single.go
New CreateCommand type and StartWithLockAndLogging(commandName string, createCommand CreateCommand) error: acquires a file lock in tmp, checks PID marker, opens/creates a .streams file for stdout/stderr, starts the command asynchronously, writes the PID file, and ensures unlock/cleanup.
Removed legacy singleton
pkg/single/single.go (deleted)
Deleted legacy single package and its Single(file string, createCommand CreateCommand) error logic that previously implemented PID-file based single-instance starts.
Daemon startup
pkg/daemon/agent/daemon.go
Replaced single.Single("daemon.pid", ...) with command.StartWithLockAndLogging("daemon", ...) for daemon installation/start.
IDE servers
pkg/ide/fleet/fleet.go, pkg/ide/jupyter/jupyter.go, pkg/ide/openvscode/openvscode.go, pkg/ide/rstudio/rstudio.go
Switched callers from single.Single(...) to command.StartWithLockAndLogging(...), changing keys from PID-filename style (e.g., "fleet.pid") to logical names (e.g., "fleet"); core command construction unchanged.
Container agent setup
cmd/agent/container/setup.go
Updated background starts to use logical lock keys (e.g., devpod.daemon instead of devpod.daemon.pid, flavor-specific keys like fmt.Sprintf("%s-async", flavor)), forwarded closures to command.StartWithLockAndLogging, and added //nolint:gosec on constructed exec.Command usages.

Sequence Diagram(s)

mermaid
sequenceDiagram
participant Caller as Caller (start request)
participant CmdPkg as command.StartWithLockAndLogging
participant FS as File system (tmp .lock/.pid/.streams)
participant Creator as createCommand() -> exec.Cmd
participant Process as Child process

Caller->>CmdPkg: StartWithLockAndLogging(commandName, createCommand)
CmdPkg->>FS: create/open <commandName>.lock (TryLock)
alt lock not acquired
    CmdPkg-->>Caller: return nil (already running elsewhere)
else lock acquired
    CmdPkg->>FS: read <commandName>.pid
    alt pid indicates running
        CmdPkg-->>Caller: return nil (process running)
    else no running pid
        CmdPkg->>Creator: call createCommand()
        Creator-->>CmdPkg: *exec.Cmd
        CmdPkg->>FS: open/create <commandName>.streams
        CmdPkg->>Process: start command (redirect stdout/stderr)
        Process-->>CmdPkg: started (pid)
        CmdPkg->>FS: write <commandName>.pid
        CmdPkg->>FS: unlock and return nil
    end
end

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 25.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately describes the main change: refactoring single.Single() to command.StartWithLockAndLogging() across multiple files in the codebase.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

The function `single.Single()` executes a process in the background and
prevents that a process with the same (user-supplied) name is executed
again until it finishes. Hence the name "single" -- only a single
process of the given name can run at any time.

The naming is not self-descriptive, however, and easily confused with
`devcontainer/single.go` (where single means running a single container,
as opposed to multiple containers with docker-compose).

Rename the file and update all its users. Refactor its internal
functionality to meet the requirements of lint (e.g., reduced cyclomatic
complexity).

Functional change: the temporary files are no longer named like
`something.pid.streams`, but only `something.streams`, which seems more
accurate.
Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@pkg/command/single.go`:
- Around line 24-26: The temp file paths (lockFile, pidFile, streamsFile) are
created directly under os.TempDir() and later opened with
os.WriteFile/os.Create, which is unsafe; change to create and use an owner-only
state directory (e.g., create a dir with 0700 using os.MkdirAll or os.MkdirTemp)
and place lock/pid/streams inside it, create files with O_CREATE|O_EXCL and mode
0600 (or os.WriteFile with 0600) to avoid race/clobber, and before opening
refuse/signal error if target is not a regular file or is a symlink by using
os.Lstat and FileMode checks; update the code paths that use lockFile, pidFile,
and streamsFile (and the places that call os.WriteFile and os.Create) to use
this secure directory and safe-open semantics.
- Around line 41-44: isProcessRunning currently returns nil for both "process
alive" and "not running", so the caller in single.go never short-circuits;
change isProcessRunning to return a sentinel error when the recorded PID is
alive (e.g., define var ErrProcessRunning = errors.New("process already
running") at package scope) and return nil only when the PID is not running, and
update the caller (the call to isProcessRunning in single.go) to detect and
return early on ErrProcessRunning (use errors.Is if you wrap the error) so the
helper does not launch a second process.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: f1de8660-262d-4a4a-80f4-5b90f8062e83

📥 Commits

Reviewing files that changed from the base of the PR and between 09ef2e7 and a1ef3b1.

📒 Files selected for processing (8)
  • cmd/agent/container/setup.go
  • pkg/command/single.go
  • pkg/daemon/agent/daemon.go
  • pkg/ide/fleet/fleet.go
  • pkg/ide/jupyter/jupyter.go
  • pkg/ide/openvscode/openvscode.go
  • pkg/ide/rstudio/rstudio.go
  • pkg/single/single.go
💤 Files with no reviewable changes (1)
  • pkg/single/single.go

Comment on lines +24 to +26
lockFile := filepath.Join(os.TempDir(), commandName+".lock")
pidFile := filepath.Join(os.TempDir(), commandName+".pid")
streamsFile := filepath.Join(os.TempDir(), commandName+".streams")
Copy link

@coderabbitai coderabbitai bot Mar 24, 2026

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

Avoid predictable state files directly under os.TempDir().

These paths are later opened with os.WriteFile and os.Create at Line 114 and Line 118. In the daemon/container setup paths this helper can run with elevated privileges, so a non-privileged user can pre-create those names in a shared temp directory and turn this into file clobbering or stream disclosure. Please move the lock/pid/streams files into an owner-only state directory and refuse to follow symlinks/non-regular files; at minimum, the streams file should not be created with the default os.Create permissions.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@pkg/command/single.go` around lines 24 - 26, The temp file paths (lockFile,
pidFile, streamsFile) are created directly under os.TempDir() and later opened
with os.WriteFile/os.Create, which is unsafe; change to create and use an
owner-only state directory (e.g., create a dir with 0700 using os.MkdirAll or
os.MkdirTemp) and place lock/pid/streams inside it, create files with
O_CREATE|O_EXCL and mode 0600 (or os.WriteFile with 0600) to avoid race/clobber,
and before opening refuse/signal error if target is not a regular file or is a
symlink by using os.Lstat and FileMode checks; update the code paths that use
lockFile, pidFile, and streamsFile (and the places that call os.WriteFile and
os.Create) to use this secure directory and safe-open semantics.

Copy link
Author

Choose a reason for hiding this comment

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

Doesn't matter here.

Copy link

Choose a reason for hiding this comment

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

@imphil, understood — acknowledged and moving on.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🧹 Nitpick comments (2)
pkg/command/single.go (2)

121-121: Consider explicit permissions for the streams file.

The PID file at Line 117 is created with 0o600, but os.Create here uses the default 0666 (minus umask). For consistency and to ensure the output logs aren't world-readable, consider using os.OpenFile with explicit 0o600 permissions.

♻️ Use explicit permissions
-	f, err := os.Create(streamsFile) // `#nosec` G304: not user input
+	f, err := os.OpenFile(streamsFile, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o600) // `#nosec` G304: not user input
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@pkg/command/single.go` at line 121, The streams file is created with
os.Create(streamsFile) which uses default permissions; change to use
os.OpenFile(streamsFile, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o600) so the file
is created with explicit 0600 permissions (matching the PID file) and keep the
existing error handling for the returned file (variable f) — replace the
os.Create call with os.OpenFile and retain or reapply the `#nosec` annotation if
needed.

110-118: Unnecessary wait loop – PID is already set after Start().

In Go, cmd.Start() does not return until the process is forked and cmd.Process.Pid is populated. The condition cmd.Process.Pid < 0 will never be true after a successful Start(), making this loop dead code.

♻️ Simplified version
 func writePidToFile(cmd *exec.Cmd, pidFile string) error {
-	// wait until we have a process id
-	for cmd.Process.Pid < 0 {
-		time.Sleep(time.Millisecond)
-	}
-
 	// write pid to file
 	return os.WriteFile(pidFile, []byte(strconv.Itoa(cmd.Process.Pid)), 0o600)
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@pkg/command/single.go` around lines 110 - 118, The loop in writePidToFile is
unnecessary because cmd.Process.Pid is populated by cmd.Start(); remove the
for-loop and instead validate that cmd.Process is non-nil, then immediately
write strconv.Itoa(cmd.Process.Pid) to pidFile with os.WriteFile (mode 0o600),
returning any error; ensure you still handle the case where cmd.Process == nil
by returning a clear error before attempting to access Pid.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@pkg/command/single.go`:
- Around line 120-132: redirectStreams currently opens streamsFile with
os.Create and assigns it to cmd.Stdout/cmd.Stderr but never closes the parent
file handle, leaking descriptors; change redirectStreams to return the *os.File
(or nil on error) instead of closing it internally, update its signature and
callers (notably StartWithLockAndLogging) to receive that *os.File, call
cmd.Start(), and then close the returned file (ignoring close errors) after
Start succeeds so the parent relinquishes its descriptor while the child keeps
its copy.
- Around line 90-108: startAndRecordPid currently starts the process then writes
the PID; if writePidToFile(cmd, pidFile) fails the process is left running
without a recorded PID which can orphan it—update startAndRecordPid to handle
PID-write failures by attempting cleanup and reporting: after cmd.Start(), if
writePidToFile returns an error then try to terminate the started process (use
cmd.Process.Kill() and/or cmd.Wait() to ensure it stops), log a clear
warning/error that includes the PID and the write error, and return the original
write error (or a wrapped error). Keep the successful path that releases
resources (cmd.Process.Release()) only when PID was successfully recorded and
the process is intentionally left running.

---

Nitpick comments:
In `@pkg/command/single.go`:
- Line 121: The streams file is created with os.Create(streamsFile) which uses
default permissions; change to use os.OpenFile(streamsFile,
os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o600) so the file is created with explicit
0600 permissions (matching the PID file) and keep the existing error handling
for the returned file (variable f) — replace the os.Create call with os.OpenFile
and retain or reapply the `#nosec` annotation if needed.
- Around line 110-118: The loop in writePidToFile is unnecessary because
cmd.Process.Pid is populated by cmd.Start(); remove the for-loop and instead
validate that cmd.Process is non-nil, then immediately write
strconv.Itoa(cmd.Process.Pid) to pidFile with os.WriteFile (mode 0o600),
returning any error; ensure you still handle the case where cmd.Process == nil
by returning a clear error before attempting to access Pid.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 3127785c-844c-49d9-96e2-fcb0a61cff1a

📥 Commits

Reviewing files that changed from the base of the PR and between a1ef3b1 and 797f4ef.

📒 Files selected for processing (1)
  • pkg/command/single.go

Signed-off-by: Samuel K <skevetter@pm.me>
Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@pkg/command/single.go`:
- Around line 101-112: openStreamsFile currently always calls
os.Create(streamsFile) which creates/truncates the file even when cmd.Stdout and
cmd.Stderr are already set; change the logic in openStreamsFile to first check
cmd.Stdout and cmd.Stderr and only call os.Create/open the streamsFile when at
least one of them is nil, then assign the returned *os.File to whichever of
cmd.Stdout or cmd.Stderr is nil (cmd.Stderr = f and/or cmd.Stdout = f) and
return the file; keep error propagation the same and avoid creating the file
when no redirection is needed.
- Around line 64-78: The kill path currently calls cmd.Process.Kill() but
doesn't wait, so the child can become a zombie; after calling cmd.Process.Kill()
in the error block that follows os.WriteFile, call cmd.Wait() (handling and
returning any Wait() error appropriately) to reap the child before returning the
wrapped error. Also, after a successful Start() (before writing the pid file),
call cmd.Process.Release() to detach the child from the Cmd object so the parent
stops tracking it; keep the existing streamsF.Close() behavior and ensure errors
from Release() or Wait() are handled/wrapped similarly to other returns.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: c9624c56-fc11-423f-ade7-424fcf776543

📥 Commits

Reviewing files that changed from the base of the PR and between 797f4ef and e3a9ae5.

📒 Files selected for processing (1)
  • pkg/command/single.go

Comment on lines +64 to +78
if err := cmd.Start(); err != nil {
streamsF.Close()
return fmt.Errorf("start process: %w", err)
}
// Close the parent's copy of the streams fd. After Start() forks, the
// child has its own copy; the parent no longer needs it.
streamsF.Close()

if err := os.WriteFile(pidFile, []byte(strconv.Itoa(cmd.Process.Pid)), 0o600); err != nil {
// Process is running but untracked. Kill it to prevent orphans.
_ = cmd.Process.Kill()
return fmt.Errorf("write pid file (process killed to prevent orphan): %w", err)
}

return nil
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Verify process lifecycle handling in this helper.
rg -n 'cmd\.Start\(|cmd\.Wait\(|Process\.Release\(|Process\.Kill\(' pkg/command/single.go -C2

Repository: skevetter/devpod

Length of output: 468


🏁 Script executed:

# First, let's read the full function to understand its intent
cat -n pkg/command/single.go | head -100

Repository: skevetter/devpod

Length of output: 3581


🏁 Script executed:

# Also check if cmd.Wait() or Release() is called elsewhere in this file or related files
rg -n 'cmd\.Wait\(|Process\.Wait\(|Process\.Release\(' pkg/command/ -C3

Repository: skevetter/devpod

Length of output: 42


🏁 Script executed:

# Check what happens to the process after Start() - is it meant to be a daemon?
rg -n 'daemoniz|background|detach|orphan' pkg/command/single.go -C2

Repository: skevetter/devpod

Length of output: 352


Add Wait() on the kill path to reap killed processes.

After cmd.Process.Kill() at line 74, the child process should be waited on to ensure it's reaped. Otherwise, the killed process becomes a zombie until the parent exits.

Additionally, since this function starts a detached daemon process (per line 17 documentation), calling cmd.Process.Release() after successful Start() would explicitly declare the process as detached and prevent unnecessary resource tracking by the Cmd object in long-running parents.

Suggested changes
 	if err := os.WriteFile(pidFile, []byte(strconv.Itoa(cmd.Process.Pid)), 0o600); err != nil {
 		// Process is running but untracked. Kill it to prevent orphans.
 		_ = cmd.Process.Kill()
+		_ = cmd.Wait()
 		return fmt.Errorf("write pid file (process killed to prevent orphan): %w", err)
 	}
+
+	_ = cmd.Process.Release()
 	return nil
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@pkg/command/single.go` around lines 64 - 78, The kill path currently calls
cmd.Process.Kill() but doesn't wait, so the child can become a zombie; after
calling cmd.Process.Kill() in the error block that follows os.WriteFile, call
cmd.Wait() (handling and returning any Wait() error appropriately) to reap the
child before returning the wrapped error. Also, after a successful Start()
(before writing the pid file), call cmd.Process.Release() to detach the child
from the Cmd object so the parent stops tracking it; keep the existing
streamsF.Close() behavior and ensure errors from Release() or Wait() are
handled/wrapped similarly to other returns.

Comment on lines +101 to +112
func openStreamsFile(cmd *exec.Cmd, streamsFile string) (*os.File, error) {
f, err := os.Create(streamsFile) // #nosec G304: not user input
if err != nil {
return nil, err
}
if cmd.Stderr == nil {
cmd.Stderr = f
}
if cmd.Stdout == nil {
cmd.Stdout = f
}
return f, nil
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Avoid creating/truncating .streams when no redirection is needed.

openStreamsFile always calls os.Create at Line [102]. If both outputs are already configured, this still creates/truncates the file unnecessarily.

♻️ Proposed fix
 func openStreamsFile(cmd *exec.Cmd, streamsFile string) (*os.File, error) {
+	if cmd.Stdout != nil && cmd.Stderr != nil {
+		return nil, nil
+	}
+
 	f, err := os.Create(streamsFile) // `#nosec` G304: not user input
 	if err != nil {
 		return nil, err
 	}
@@
 	return f, nil
 }
 	streamsF, err := openStreamsFile(cmd, streamsFile)
 	if err != nil {
 		return err
 	}
 
 	if err := cmd.Start(); err != nil {
-		streamsF.Close()
+		if streamsF != nil {
+			_ = streamsF.Close()
+		}
 		return fmt.Errorf("start process: %w", err)
 	}
@@
-	streamsF.Close()
+	if streamsF != nil {
+		_ = streamsF.Close()
+	}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@pkg/command/single.go` around lines 101 - 112, openStreamsFile currently
always calls os.Create(streamsFile) which creates/truncates the file even when
cmd.Stdout and cmd.Stderr are already set; change the logic in openStreamsFile
to first check cmd.Stdout and cmd.Stderr and only call os.Create/open the
streamsFile when at least one of them is nil, then assign the returned *os.File
to whichever of cmd.Stdout or cmd.Stderr is nil (cmd.Stderr = f and/or
cmd.Stdout = f) and return the file; keep error propagation the same and avoid
creating the file when no redirection is needed.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants