Skip to content

feat(launcher): make devbox its own launcher - #3024

Open
mikeland73 wants to merge 2 commits into
mainfrom
mikeland73/launcher-devbox-replace
Open

mikeland73 wants to merge 2 commits into
mainfrom
mikeland73/launcher-devbox-replace

Conversation

@mikeland73

Copy link
Copy Markdown
Collaborator

Summary

Implements item 6 of specs/perf-improvements.md. Instead of speeding up the bash launcher in the axiom repo, release builds of devbox now are the launcher. The design is in the new specs/native-launcher.md.

devbox version, median of 30 runs on macOS arm64:

Path Time
binary run directly 9.0ms
bash launcher, then the binary (today) 78.2ms
native launcher, installed binary is the current version (no exec) 8.4ms
native launcher, then another version 17.3ms

Design

  • internal/launcher runs first in boxcli.Main:

    • It picks the version to run: DEVBOX_USE_VERSION, otherwise current-version.
    • If that's its own version, it runs the command itself. Otherwise it execs that version from ~/.cache/devbox/bin, first downloading it if needed. Downloads are checked against checksums.txt.
    • The 24h version check runs in a detached devbox __launcher-refresh process, so commands no longer wait on curl.
  • When it runs: only in release builds. The new build.Launcher=true ldflag is set only in .goreleaser.yaml, so dev builds and package-manager builds (nixpkgs, Homebrew) stay plain binaries. It also does nothing for binaries inside the install dir: launchers only run binaries from there, so such a binary is already the version a launcher picked. That rule is what prevents exec loops, and env vars that leak into a devbox shell can't confuse it.

  • Compatibility with older versions: it keeps the bash launcher's contract that older versions rely on:

    • env vars: LAUNCHER_PATH, LAUNCHER_VERSION, DEVBOX_LATEST_VERSION
    • files: current-version and latest-version in the cache dir, auto_update and channel in the config dir
    • the ~/.cache/devbox/bin/<version>_<platform> layout

    vercheck's installedBinary from perf(vercheck): switch devbox versions before startup for devbox_version auto #3023 now uses launcher.InstalledBinary. That also fixes the 32-bit ARM directory name, which should be armv7l.

  • devbox version update:

    • It downloads the latest version and saves it as the current version itself. It no longer reruns the installer or the launcher.
    • It replaces the launcher at LAUNCHER_PATH only when LAUNCHER_VERSION is older than expectedLauncherVersion, or with the new --launcher flag. That's the only time it may ask for sudo. The replacement is a temp file plus rename, falling back to sudo when the directory isn't writable.
    • Without LAUNCHER_PATH (not installed with the installer), it returns an error saying to update devbox with the package manager it came from.
  • Migrating existing users: expectedLauncherVersion is now launcher.Version (1.0.0).

    • Bash-launcher users get "New launcher available: v0.2.2 -> v1.0.0" at most once a day. It's skipped with --quiet and for the commands in the existing skip list, such as shellenv.
    • devbox version update then replaces the bash script with the binary directly, so the migration doesn't depend on the installer.
  • Installer: scripts/install.sh installs the latest release binary to /usr/local/bin/devbox, using sudo only when that directory isn't writable. It writes current-version and latest-version, so the first run doesn't wait for the network.

Follow-ups (not in this PR)

  • After a release with this change is the stable version, publish scripts/install.sh to get.jetify.com/devbox. Today that script is published from axiom/installers/devbox/install.sh. It has to wait, because it installs the latest release as the launcher.
  • Keep releases.jetify.com/devbox (the bash launcher) published for anything that downloads it directly, and retire it later.

Testing

  • go test ./internal/... ./nix/... ./pkg/... passes. golangci-lint reports no issues on the changed packages.
  • New unit tests in internal/launcher use a local fake release server and a temp HOME. They cover:
    • running itself when it's the current version
    • running an installed version
    • downloading a missing version
    • rejecting a wrong checksum
    • DEVBOX_USE_VERSION, including rejecting invalid values
    • doing nothing for installed versions
    • the first run pinning the latest version, and running itself when offline
    • the channel file
    • the background check for a stale version, without waiting on the network
    • auto_update
    • replacing the launcher through a symlink
  • New vercheck test for the once-a-day notice. The vercheck tests now use a temp cache dir.
  • Manual, in an isolated HOME, with a release-style build (Version=0.99.0, Launcher=true) and real GitHub releases:
    • dispatching to 0.18.5 (downloaded on first use), running itself, DEVBOX_USE_VERSION, the first run pinning the latest version, and a stale cache starting the background check (12ms)
    • migration: with the real bash launcher in front, the notice printed once, and then devbox version update replaced the script with the binary. Afterwards it ran 0.18.5 with Launcher: 1.0.0.
    • version update doesn't replace an up-to-date launcher, --launcher does, and a dev build gets the "not installed with the installer" error
    • scripts/install.sh -f, with its install dir pointed at a temp dir, installed 0.18.5 and wrote current-version
  • Not tested: the sudo path, either for replacing the launcher or in the installer.

🤖 Generated with Claude Code

mikeland73 and others added 2 commits October 25, 2026 18:03
Release builds of devbox now do the bash launcher's job: the binary at
/usr/local/bin/devbox picks the version to run (DEVBOX_USE_VERSION or
current-version), downloads it if needed, and either runs the command itself
or execs that version. Version checks run in a detached child process instead
of blocking the command.

- internal/launcher: version selection, download with checksum verification,
  background refresh, and launcher replacement. Keeps the bash launcher's env
  vars, version files, and install dir so older versions keep working.
- build.Launcher ldflag (set only by goreleaser) turns it on, so dev and
  package-manager builds stay plain binaries.
- `devbox version update` downloads the latest version itself and only
  replaces the launcher when the launcher version is bumped, or with the new
  --launcher flag.
- expectedLauncherVersion is now launcher.Version (1.0.0), so bash-launcher
  users are asked to update, at most once a day.
- scripts/install.sh: new installer that installs the binary.
- specs: add native-launcher.md and mark perf item 6 done.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@claude

claude Bot commented Oct 26, 2026

Copy link
Copy Markdown

Code review

No issues found. Checked for bugs and CLAUDE.md compliance.

1 similar comment
@claude

claude Bot commented Oct 26, 2026

Copy link
Copy Markdown

Code review

No issues found. Checked for bugs and CLAUDE.md compliance.

@mikeland73

Copy link
Copy Markdown
Collaborator Author

@claude ptal

@claude

claude Bot commented Oct 26, 2026 •

Copy link
Copy Markdown

Claude finished @mikeland73's task in 44s —— View job


Review of #3024: native launcher

I read internal/launcher/*, the vercheck, boxcli and build changes, and the goreleaser change. I didn't run the tests or the installer. Nothing blocks merging. The design holds together, and I found no correctness bugs in the main paths.

Checked and looks right

  • Exec-loop guard. inInstallDir returns early before anything else runs, and it resolves symlinks on both sides. filepath.IsLocal also rejects a path that escapes the install dir. A leaked DEVBOX_USE_VERSION can't cause a loop, because installed binaries never reach selectVersion.
  • Gating. build.Launcher is only set in .goreleaser.yaml, so dev builds and package-manager builds are unaffected. The goreleaser targets are linux and darwin only, so syscall.Exec and Setsid are fine.
  • Download safety. The download goes to a temp dir inside the install dir's parent and is renamed into place, so a half-written binary is never visible. The SHA-256 is checked before unpacking. fileutil.Untar already rejects zip-slip entries and non-regular files. validVersion keeps path separators out of paths and URLs.
  • Version files. writeVersion writes atomically. startRefresh resets the mtime before spawning, so concurrent commands don't each start a check.
  • Quiet notice. cmd.SetErr(io.Discard) is what --quiet passes to CheckVersion, so the w == io.Discard guard works and a quiet command won't use up the once-a-day notice.

Minor points (non-blocking)

  1. Race in download (internal/launcher/install.go, the os.RemoveAll(dir) before os.Rename). Two launchers installing the same version at once can go wrong:

    • A renames its copy into dir, and B's RemoveAll(dir) then deletes it.
    • If A has already exec'd the binary, the exec can fail, or the binary can vanish between the rename and the exec.

    Re-checking isExecutable(filepath.Join(dir, "devbox")) just before the RemoveAll and returning early would close most of this. The worst case today is a one-off failed command that works on retry.

  2. Offline first run costs 5s every time. If current-version is missing, selectVersion waits up to firstRunTimeout for the network, and on failure it saves nothing. An offline user with no current-version pays 5s on every command. The installer writes the file, so this should only affect users whose devbox version update deleted it (the older versions do) and who are then offline. Consider falling back to an installed binary, or skipping the fetch for a while after a failure.

  3. sudo replace leaves a stray file (internal/launcher/replace.go). If chmod or mv fails in the sudo script, $2.tmp stays behind in /usr/local/bin. trap 'rm -f "$2.tmp"' EXIT in the shell snippet would fix that. You noted this path is untested, so it's worth a manual run before release.

  4. DEVBOX_LATEST_VERSION can be set to an empty string. When latest-version doesn't exist, run exports DEVBOX_LATEST_VERSION=. Older versions probably treat empty and unset the same, but check this against the bash launcher's behavior. Not exporting it when empty would be safest.

  5. Notice timing. launcherVersionNotice touches the launcher-notice file before printing, so it counts as shown even if the write to stderr fails. That's harmless, and it's the right tradeoff for avoiding nagging.

Test gaps

  • The sudo paths in ReplaceLauncher and install.sh are untested, as you noted.
  • There's no test for two launchers installing the same version at once (point 1).
    · branch mikeland73/launcher-devbox-replace

This branch has not been deployed

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

Labels

None yet

Development

Successfully merging this pull request may close these issues.

1 participant