Skip to content

Commit ee6ba5d

Browse files
committed
fix: name the agent binary devpod picked from a hostname
Section 3 of #560, as far as dl can honestly reach it. devpod chooses which agent binary to inject by globbing `uname -a` for `arm`, and `uname -a` prints the nodename beside the machine. So a container whose hostname holds `arm` reads as an ARM machine, devpod downloads the arm64 agent, the version check cannot execute it, and the launch dies with `exit status 126` -- "not executable", which names neither the architecture nor the word that chose it. dl is one of the ways the word gets there: its setup pass sets the container's hostname to the workspace id, and a workspace id is derived from the branch, so `feature/armature` is enough. A refused `devpod up` of a workspace whose id contains `arm` now carries one line of dl's own, beside devpod's sentence and without changing the exit code. The line is a conditional, and that is the honest limit rather than hedging. dl runs the `up` as a passthrough, because an image build's progress belongs on the user's terminal rather than through a pipe, so dl never reads devpod's message: what it holds is a nonzero exit and a name. That is enough to know the trap is set and not enough to know it fired, so the sentence says what to look for in devpod's own output above it. Reading that output instead would mean piping the build through dl -- changing what devpod renders, and dropping the process group that lets a Ctrl-C tear a build down rather than orphan it holding the launch lock, since `Runner::session` starts its child with `OwnGroup::No` where the `up` needs `Yes` (#304). `clients::devpod::reads_as_arm` transcribes devpod's four globs, uncase-folded because a shell `case` is, and answers for a machine name as readily as for a hostname -- which is what the exemption reads: a host that is itself ARM gets no line, because there devpod's guess is right. The upstream fix is one line (glob `uname -m`) and is not here. Claude-Session: https://claude.ai/code/session_01Dn4qJGhkW4KdwQnMuNSXsN
1 parent b5d0300 commit ee6ba5d

10 files changed

Lines changed: 390 additions & 14 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 23 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -49,15 +49,29 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
4949
attach that runs no `up` prints neither line, because it asked devpod for
5050
nothing.
5151

52-
- **`docs/cli.md` now names the cause of `inject agent … exit status 126`.** devpod
53-
picks its agent binary by globbing `uname -a` for `arm`, and `uname -a` carries
54-
the container's hostname, so a workspace whose branch contains `alarm`, `warm`,
55-
`charm`, `swarm`, `harm` or `armature` gets the arm64 agent on an x86 host and a
56-
launch that dies saying only "not executable". `dl` writes the workspace id into
57-
that hostname itself, so it is one of the ways the name gets there. The entry
58-
carries the one-line check, the way to unblock a container that is already in
59-
that state, and the reason a recreate undoes it. The match itself is devpod's and
60-
is not fixed here.
52+
- **A failed `up` of a workspace whose id contains `arm` names the agent binary
53+
devpod picked.** devpod chooses which agent to inject by globbing `uname -a` for
54+
`arm`, and `uname -a` carries the container's hostname, so a workspace whose
55+
branch contains `alarm`, `warm`, `charm`, `swarm`, `harm` or `armature` gets the
56+
arm64 agent on an x86 host and a launch that dies saying only `exit status 126`,
57+
which means "not executable" and names neither the architecture nor the word that
58+
chose it. `dl` writes the workspace id into that hostname itself, so it is one of
59+
the ways the name gets there.
60+
61+
**The line is a conditional, and deliberately.** `devpod up` runs with `dl`'s own
62+
terminal, because an image build's progress belongs on your screen rather than
63+
through a pipe, so `dl` never reads devpod's message: what it holds when the
64+
build fails is an exit code and a name. That is enough to know the trap is set
65+
and not enough to know it fired, so the sentence says what to look for in
66+
devpod's own output above it instead of asserting what happened. An `up` that
67+
failed on an image pull for a workspace called `alarm-clock` gets one sentence it
68+
can ignore, and a host whose own architecture is ARM gets none, because there
69+
devpod's guess is the right one.
70+
71+
`docs/cli.md` carries the whole diagnosis: the one-line check for any running
72+
container, the `docker cp` that unblocks one already in that state, and why a
73+
recreate undoes it. The match itself is devpod's, upstream, and is not fixed
74+
here.
6175

6276
### Changed
6377

‎docs/cli.md‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -677,6 +677,24 @@ branch contains `alarm`, `warm`, `charm`, `swarm`, `harm`, `farm` or `armature`
677677
a candidate, and `dl`'s own setup pass puts the workspace id into the hostname, so
678678
`dl` is one of the ways the name gets there.
679679

680+
`dl` says this itself when an `up` refuses for a workspace whose id contains
681+
`arm`, and says it as a conditional:
682+
683+
```
684+
If devpod said 'inject agent' and 'exit status 126' above, this is why: the workspace id
685+
devlaunch-feature-armature-17uu contains 'arm', devpod picks its agent binary by matching
686+
'uname -a' against '*arm*', ...
687+
```
688+
689+
**It is a conditional because `dl` never reads devpod's message.** `devpod up`
690+
runs with `dl`'s own terminal, so an image build's progress goes straight to you
691+
rather than through `dl`, and what `dl` holds when the build fails is an exit code
692+
and a workspace id. That is enough to know the trap is set and not enough to know
693+
it fired, so the line tells you what to look for in devpod's output above it. An
694+
`up` that failed on an image pull for a workspace called `alarm-clock` gets one
695+
sentence it can ignore. Nothing is said on a host whose own architecture is ARM,
696+
where devpod's guess is the right one.
697+
680698
```bash
681699
# the mechanism, in any running container
682700
docker exec <container> sh -c 'case "$(uname -a)" in *arm*) echo ARM;; *) echo NOT;; esac'

‎docs/rust-rewrite-plan.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -198,6 +198,7 @@ Nothing diverges silently.
198198
| 32 | **`--rm` is docker's `--rm`, and the flag-spelled verbs are retired.** `dl <ws> --rm` and `dl <ws> --rm -- <cmd>` now hand over a session and delete the workspace once it ends — what row 30's `--autorm` did, under the name docker gives it. The word `rm` is unchanged and is the only way to delete one *now*, so `docker rm` / `docker run --rm` is the whole of the grammar and no spelling has to be read twice. Three withdrawals pay for it, each refused at exit 1 rather than reinterpreted: `--autorm` (`--autorm is now spelled --rm: …`); `--stop`, whose only reason to exist was being a flag (rows 15 and 30) and which cannot stay a *cancelling* suffix beside a `--rm` that runs the line; and row 30's suffix override itself, so `aid <ws> 'review this pr' --rm` now runs the review and deletes afterwards where it used to delete instead, and `dl prune <ws> --rm` is row 31's refusal rather than a removal (with `--force` also on the line it is the `--force`-beside-`--rm` refusal instead, named first because the pair is the more confused half). `--force` still does not compose with `--rm` — docker keeps `-f` on `rm` too — and `dl <ws> rm --rm` is refused as the two requests it is. Retired flags are answered *before* anything else the line got wrong, since both moved on account of `--rm`'s new meaning. aid peels the retired spellings so dl refuses them by name instead of joining them into a prompt, and builds no agent command for such a line, so nothing is booted on the way to exit 1. `Overridden`, `pick_target` and the `--rm overrode the rest of the line` notice are gone with the override. | Row 30 bought "and now delete it" as a suffix and paid for it with a flag whose meaning was unguessable from its spelling: `--rm` cancelled the line, `--autorm` ran it, and the two looked like a pair. docker had already split the same problem the other way — a verb for now, a `run` flag for after — and taking that split makes the common line (`aid <ws> <prompt> --rm`: send the agent in, get the disk back) the one the short spelling names, at the cost of the rarer one, which `dl rm` and a pick does in fewer keystrokes than recalling a long prompt to append to it. The withdrawals are recognised rather than deleted, for row 31's reason: a flag dropped from `Cli` is clap's `unexpected argument` at exit 2, naming the spelling and not the replacement — and the spelling is exactly what cannot explain a line that stopped working because a *different* flag changed meaning. Pinned by the `retired_flag` / `rm_` tests in `dl/src/cli.rs`, `the_retired_flag_spellings_name_the_words_that_replaced_them` and `autorm_is_refused_with_the_spelling_that_replaced_it` in `dl/tests/lifecycle.rs`, the `rm_on_exit_` tests in `dl/tests/launch.rs`, and `a_retired_spelling_starts_no_agent_and_is_handed_to_dl_to_refuse` in `aid/src/rewrite.rs`. |
199199
| 33 | **A warm launch of a triple says how far behind its checkout is.** `dl owner/repo@branch` against a workspace devpod already knows prints one line before the attach banner when the clone's `HEAD` is behind the `refs/remotes/origin/<branch>` that clone last fetched, naming the count both ways (`its checkout is 37 commits behind origin/main … (and 3 of its own it has not pushed)`). Python printed nothing, and neither did earlier Rust. The launch still runs no fetch: the report is one `rev-list` against a local repository, so it says how the checkout stands against a ref of whatever age and never claims to know the remote. Silent when the counts agree, when the checkout is only ahead, when there is no clone on disk, for a bare workspace name (no triple, so no branch to name and no clone path to derive), and for a warm resolution addressed by an id `metadata.json` recorded rather than the one the triple derives (devlaunch#88's arm, where the derived clone path is not this container's source). `reset`'s help line and the README table stopped saying "Clean slate: remove everything, recreate" in the same change, because that promise reads as one about the checkout and `reset` cannot keep it. | The warm arm makes no git call and never will (devlaunch#144, built by #149 and #150), and a launch that looks like it verified new work when it verified neither the commit nor the image invalidates whatever was concluded inside the container. A fetch on attach was the tempting fix and is the wrong trade; the fact was available locally the whole time. Pinned by the `checkout`/`warm_triple` tests in `flows::launch` and by `a_warm_triple_whose_checkout_is_behind_says_how_far_and_still_runs_no_git_fetch` plus `a_bare_workspace_name_reports_no_checkout_however_stale_it_is` in `dl/tests/launch.rs`, whose world is `launch_scenario.py`'s `--stale-checkout`. blooop/devlaunch#560 §1. |
200200
| 34 | **Every `devpod up` says whether it forwarded dotfiles.** One line per `up`: the repository and script it passed, or that devpod's context options name none and where to set one. Python read `devpod context options`, silently forwarded `--dotfiles`/`--dotfiles-script` or silently omitted them, and left no way from the terminal to tell which had happened. An attach that runs no `up` prints neither line. | Three plausible causes and no observation to cut between them is what turned "the dotfiles never landed" into a fortnight: `DOTFILES_URL` is read out of `devpod context options` and out of nothing else, not the process environment and not `~/.devpod/config.yaml`, and `context-options.json` is dl's *cache* of that answer rather than an input. The line costs nothing on a path that is already spawning a container build, and it makes a silent policy checkable. Pinned by `an_up_says_which_dotfiles_it_asked_devpod_for_and_the_argv_agrees` in `dl/tests/launch.rs`, which asserts the sentence and the argv together so the two cannot drift. blooop/devlaunch#560 §2. |
201+
| 35 | **A refused `devpod up` of a workspace whose id contains `arm` gains one line of dl's own.** The refusal still renders nothing else and still exits with devpod's status; this is a second line beside it, phrased as a conditional (`If devpod said 'inject agent' and 'exit status 126' above, this is why: …`) naming the workspace id, the `*arm*` glob, the hostname `uname -a` carries, and the `docker cp` that unblocks a container already in that state. Python printed nothing. Silent for every other refusal, for an id with no `arm` in it, and on a host whose own architecture reads as ARM, where devpod's guess is correct. | devpod's `is_arm` globs `uname -a`, which prints the nodename beside the machine, so a branch named `armature` or `alarm` gets the arm64 agent on an x86 host and a launch that dies saying only "not executable" -- and dl is one of the ways the name gets there, since its setup pass sets the container's hostname to the workspace id. **The conditional is not hedging, it is the honest limit**: `devpod up` runs as a passthrough so its progress reaches the terminal directly, dl therefore never reads devpod's message, and what it holds is an exit code and a name. Matching devpod's stderr instead would mean piping the build through dl -- changing what devpod renders and, since `Runner::session` starts its child with `OwnGroup::No`, dropping the process group that lets a Ctrl-C `killpg` a build instead of orphaning it holding the launch lock (row 27, #304). One conditional sentence on a failed launch is the cheaper trade. `clients::devpod::reads_as_arm` is the transcription of devpod's four globs and answers for a machine name as readily as for a hostname, which is what the ARM-host exemption reads. Pinned by the `reads_as_arm` tests in `clients/devpod.rs`, the `arm_agent_hint` tests in `dl/src/render.rs`, and `a_failed_up_of_an_arm_named_workspace_names_the_agent_binary_devpod_picked` plus `an_ordinary_workspace_whose_up_failed_gets_no_arm_explanation` in `dl/tests/launch.rs`, whose world is `launch_scenario.py`'s `--arm-branch`. blooop/devlaunch#560 §3, whose upstream half (glob `uname -m`) is not fixed here. |
201202

202203
Additions require a PR that updates this table; the row number is cited by any
203204
per-binary harness branch.

‎rust/devlaunch-core/public-api.rest.txt‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,7 @@ pub fn devlaunch_core::clients::devpod::Workspace::eq(&self, &devlaunch_core::cl
118118
impl core::fmt::Debug for devlaunch_core::clients::devpod::Workspace
119119
pub fn devlaunch_core::clients::devpod::Workspace::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result
120120
impl core::marker::StructuralPartialEq for devlaunch_core::clients::devpod::Workspace
121+
pub fn devlaunch_core::clients::devpod::reads_as_arm(&str) -> bool
121122
pub mod devlaunch_core::clients::devpod_home
122123
pub enum devlaunch_core::clients::devpod_home::RepointFailure
123124
pub devlaunch_core::clients::devpod_home::RepointFailure::NotADevpodRecord
@@ -1514,6 +1515,7 @@ pub devlaunch_core::flows::launch::LaunchRefusal::UnknownWorkspace::name: alloc:
15141515
pub devlaunch_core::flows::launch::LaunchRefusal::UnsafeSpec(devlaunch_core::domain::workspace_id::UnsafeName)
15151516
pub devlaunch_core::flows::launch::LaunchRefusal::UpRefused
15161517
pub devlaunch_core::flows::launch::LaunchRefusal::UpRefused::exit: devlaunch_runner::Exit
1518+
pub devlaunch_core::flows::launch::LaunchRefusal::UpRefused::workspace_id: alloc::string::String
15171519
impl core::clone::Clone for devlaunch_core::flows::launch::LaunchRefusal
15181520
pub fn devlaunch_core::flows::launch::LaunchRefusal::clone(&self) -> devlaunch_core::flows::launch::LaunchRefusal
15191521
impl core::cmp::Eq for devlaunch_core::flows::launch::LaunchRefusal

‎rust/devlaunch-core/src/clients/devpod.rs‎

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -259,6 +259,53 @@ pub(crate) fn says_it_is_blocked(line: &str) -> bool {
259259
line.contains("Trying to lock workspace")
260260
}
261261

262+
/// The globs devpod's injected shell script tests `uname -a` against to decide
263+
/// it is on an ARM machine.
264+
///
265+
/// Transcribed from `is_arm` in devpod's `pkg/inject/inject.sh`, whose four
266+
/// patterns are `*arm*`, `*arm64*`, `*aarch*` and `*aarch64*`. Two of those are
267+
/// subsumed by the other two -- anything holding `arm64` holds `arm` -- and they
268+
/// are kept anyway, because this is a mirror of somebody else's list and a
269+
/// mirror that has been tidied is one nobody can diff against the original.
270+
const DEVPOD_ARM_GLOBS: [&str; 4] = ["arm", "arm64", "aarch", "aarch64"];
271+
272+
/// Whether devpod would read a container named this way as an ARM machine.
273+
///
274+
/// **This is a bug in devpod that devlaunch walks into by name** (devlaunch#560).
275+
/// `uname -a` prints the *nodename* along with the machine, so the test above is
276+
/// satisfied by a hostname as readily as by an architecture, and
277+
/// `createBinaryLoader` in `pkg/agent/inject.go` takes the boolean straight
278+
/// through to `arch = "arm64"`. The wrong agent downloads, `performVersionCheck`
279+
/// cannot execute it, and the launch dies with `exit status 126` -- "not
280+
/// executable", which is true and names neither the architecture nor the string
281+
/// that chose it.
282+
///
283+
/// devlaunch is one of the ways the string gets there: the setup pass runs
284+
/// `sudo hostname <workspace-id>` in every container it opens, and a workspace id
285+
/// is derived from the branch. So `feature/armature` becomes
286+
/// `devlaunch-feature-armature-<hash>` becomes a container that reads as ARM, and
287+
/// every branch holding `alarm`, `warm`, `charm`, `swarm`, `harm` or `farm` is the
288+
/// same trap.
289+
///
290+
/// Asked of the workspace id rather than of a `uname -a` this process cannot see,
291+
/// which is what makes it a *prediction* and the reason its one caller phrases the
292+
/// answer as a possibility. dl runs `devpod up` as a passthrough -- an image build
293+
/// belongs on the user's terminal -- so devpod's own sentence is never read, and a
294+
/// reader has to be told what to look for in it rather than told it happened.
295+
///
296+
/// The fix is upstream and one line: glob `uname -m`, which prints the machine
297+
/// alone. `blooop/devpod`'s `fix/detect-arch-with-uname-m` carries it.
298+
///
299+
/// **Case-sensitive, because a shell `case` is.** Nothing is folded here even
300+
/// though a workspace id arrives lowercased by [`slug`](crate::domain::workspace_id)
301+
/// and so could not carry an `ARM` anyway: this predicate answers "would devpod's
302+
/// script match", and a fold would make it answer something devpod does not ask.
303+
pub fn reads_as_arm(container_name: &str) -> bool {
304+
DEVPOD_ARM_GLOBS
305+
.iter()
306+
.any(|glob| container_name.contains(glob))
307+
}
308+
262309
/// An outcome, split into "it ran, this is what came back" and "it did not".
263310
///
264311
/// One function over all four arms rather than a `_ =>` at each call site: the
@@ -2370,4 +2417,58 @@ mod tests {
23702417
StatusUnreadable::NotRun(NotRun::NotInstalled)
23712418
);
23722419
}
2420+
2421+
// ------------------------------------------- devpod's own arch detection
2422+
2423+
#[test]
2424+
fn a_name_holding_arm_anywhere_reads_as_arm_because_the_glob_is_a_substring() {
2425+
// devlaunch#560 §3. Not a word match and not a prefix: devpod's script
2426+
// globs `*arm*`, so an ordinary English branch name is enough. Every name
2427+
// below is one somebody would write without a thought about architecture.
2428+
for name in [
2429+
"devlaunch-feature-armature-17uu",
2430+
"app-alarm-clock-3j1t",
2431+
"dl-warm-start-np10",
2432+
"x-charm-1",
2433+
"x-swarm-1",
2434+
"x-harm-1",
2435+
"x-farm-1",
2436+
"repo-aarch-notes-1",
2437+
] {
2438+
assert!(reads_as_arm(name), "{name}");
2439+
}
2440+
}
2441+
2442+
#[test]
2443+
fn a_name_with_no_arm_in_it_reads_as_nothing() {
2444+
for name in [
2445+
"devlaunch-main-3j1t",
2446+
"devlaunch-feature-auth-np10",
2447+
"",
2448+
// The letters, out of order and separated: a substring is a substring.
2449+
"mar-ram-amr",
2450+
] {
2451+
assert!(!reads_as_arm(name), "{name}");
2452+
}
2453+
}
2454+
2455+
#[test]
2456+
fn the_glob_is_not_folded_because_a_shell_case_is_not() {
2457+
// A faithful mirror of somebody else's `case` matters more here than being
2458+
// generous: devpod would not match this, so neither may the prediction. A
2459+
// workspace id cannot carry it anyway -- `slug` lowercases -- which is
2460+
// exactly why a fold would be an untestable kindness.
2461+
assert!(!reads_as_arm("devlaunch-ARM-3j1t"));
2462+
}
2463+
2464+
#[test]
2465+
fn the_same_predicate_answers_for_a_machine_as_for_a_hostname() {
2466+
// `uname -a` prints both, which is the whole bug, so one glob covers both
2467+
// questions and the caller that asks "is this host itself ARM" needs no
2468+
// second list.
2469+
assert!(reads_as_arm("aarch64"));
2470+
assert!(reads_as_arm("arm64"));
2471+
assert!(reads_as_arm("armv7l"));
2472+
assert!(!reads_as_arm("x86_64"));
2473+
}
23732474
}

0 commit comments

Comments
 (0)