Severity: high — this spends money on the exact command a user runs to avoid spending money
--estimate-only is honoured on one of the two parameter-sweep orchestration paths. On
the other, it is silently ignored: no estimate is printed, and every row in the param file is
launched for real.
| how the sweep is invoked |
path taken |
--estimate-only |
--param-file f.yaml (nothing else) |
detached / Lambda |
honoured ✓ |
--param-file f.yaml --no-detach --ttl 1h |
foreground |
ignored — launches every row ✗ |
--param-file f.yaml --detach (no --max-concurrent) |
foreground |
ignored — launches every row ✗ |
The bare default is safe. The unsafe cases are the ones a careful user is most likely to
reach for:
--no-detach is the documented advice for a heterogeneous sweep. The foreground path is
the one that does per-config AMI detection (launch_sweep.go:220-241, #372), so a sweep
mixing arm64 / x86 / GPU rows wants --no-detach. That is exactly the flag that disables
--estimate-only. The two safeguards are mutually exclusive today, and nothing says so.
--detach written explicitly, without --max-concurrent, also lands in the foreground
path — the maxConcurrent default of min(len(params), 10) is only applied inside the
auto-enable branch (launch_sweep.go:64-71), so an explicit --detach leaves it at 0 and
the dispatch condition detach && maxConcurrent > 0 is false. The user asked for detached
and got foreground, with no estimate and a live launch.
Reproduction (spawn v0.100.2, cmd/ at ad8a4e1)
cat > /tmp/two-rows.yaml <<'YAML'
defaults:
region: us-west-2
ttl: 1h
on_complete: terminate
params:
- instance_type: c8g.large
- instance_type: c7g.large
YAML
spawn launch est-check --param-file /tmp/two-rows.yaml --no-detach --ttl 1h --estimate-only
spawn list --state all -o json --region us-west-2 # <-- two instances, not []
I have not executed this. Running it is the bug, and the account it was found in is not
one to demonstrate it in. The evidence below is a source trace at ad8a4e1, verifiable
without spending anything; the fix should be confirmed under the Substrate emulator, where a
launch costs nothing.
Evidence — cmd/launch_sweep.go
estimateOnly is referenced in exactly two places in the whole tree
(grep -n estimateOnly cmd/*.go pkg/**/*.go):
launch_single.go:294 — the single-launch path. Correct.
launch_sweep.go:682 — inside launchSweepDetached (declared at :607), before the
S3 param upload and the DynamoDB record. Correct, but only reachable via that function.
The foreground sweep path has zero references. Its control flow:
| line |
what happens |
launch_single.go:70-77 |
--param-file short-circuits to launchParameterSweep; launch_single.go:294 is never reached |
launch_sweep.go:54-55 |
if !detach && !noDetach { detach = true } — detach auto-enables |
launch_sweep.go:64-71 |
maxConcurrent defaults to min(len(params), 10) — only inside that auto-enable branch |
launch_sweep.go:150-151 |
if detach && maxConcurrent > 0 { return launchSweepDetached(...) } — the only route to the estimateOnly check |
launch_sweep.go:154-176 |
otherwise: build per-row configs and fall through |
launch_sweep.go:220-241 |
detect AMI per config |
launch_sweep.go:288 |
SetupSporedIAMRole → launch. No estimateOnly check anywhere in between. |
So the check isn't misplaced by a few lines — it lives on one branch of a fork and is simply
absent from the other. A sweep's spend-preview guarantee currently depends on which
orchestration path it happens to take, which is not a property a user can be expected to
reason about.
Expected behaviour
spawn launch <name> --param-file f.yaml --estimate-only, with any combination of
--detach / --no-detach / --max-concurrent:
- prints a per-row estimate and a total,
- launches nothing,
- exits 0,
- leaves
spawn list --state all unchanged.
Fix sketch
Hoist the estimateOnly handling above the dispatch at launch_sweep.go:150, so it is
checked once for the sweep rather than once per orchestration path. pricing.EstimateSweepCost
already takes only the param file, so the estimate needs no AWS calls and the early return can
sit before the AWS client is even constructed. Then delete the now-unreachable block at :682
(the detached path still needs costEstimate.TotalCost for the SweepRecord, so keep the
computation there, just not the estimateOnly return).
Structuring it that way is the point of the fix: a per-path check is what failed here, and
adding a second per-path check would leave the same shape in place for the next path someone
adds.
Related: #124 established that --estimate-only should also run #110's constraint validation
first. A sweep-wide estimate is the natural place to do that per row, but it needs AWS and can
be a follow-up — launching nothing is the part that matters.
Regression test to add
In cmd/ alongside sweep_test.go, against the Substrate emulator:
given a 2-row param file
when launch runs with --estimate-only, for EACH of:
(a) no detach flags [detached path]
(b) --no-detach --ttl 1h [foreground path] <-- the failing case
(c) --detach [foreground path, via maxConcurrent=0]
then exit 0, an estimate is printed, and ZERO instances exist
The assertion must be spawn list --state all returns an empty set — not "the
estimateOnly flag was observed", not "launchSweepDetached was not called". This bug is a
correct-looking flag check sitting behind a dispatch: a test that trusts an internal flag would
have passed for the whole time the bug was live. Assert the observable.
Case (b) is the one that must fail before the fix and pass after. (a) and (c) are there so the
fix cannot regress into "only the path we happened to test is safe."
Cross-references
Found while building a deliberately heterogeneous 6-row GROMACS benchmark sweep (arm64 + x86 +
GPU) for the cost-to-result project, where --estimate-only was the first command in the
documented sequence — and where --no-detach had already been made mandatory for the AMI
reason above. That combination is the failing case, so the mitigation for one problem disabled
the safeguard for another. Recorded there as finding 15.
Severity: high — this spends money on the exact command a user runs to avoid spending money
--estimate-onlyis honoured on one of the two parameter-sweep orchestration paths. Onthe other, it is silently ignored: no estimate is printed, and every row in the param file is
launched for real.
--estimate-only--param-file f.yaml(nothing else)--param-file f.yaml --no-detach --ttl 1h--param-file f.yaml --detach(no--max-concurrent)The bare default is safe. The unsafe cases are the ones a careful user is most likely to
reach for:
--no-detachis the documented advice for a heterogeneous sweep. The foreground path isthe one that does per-config AMI detection (
launch_sweep.go:220-241,#372), so a sweepmixing arm64 / x86 / GPU rows wants
--no-detach. That is exactly the flag that disables--estimate-only. The two safeguards are mutually exclusive today, and nothing says so.--detachwritten explicitly, without--max-concurrent, also lands in the foregroundpath — the
maxConcurrentdefault ofmin(len(params), 10)is only applied inside theauto-enable branch (
launch_sweep.go:64-71), so an explicit--detachleaves it at 0 andthe dispatch condition
detach && maxConcurrent > 0is false. The user asked for detachedand got foreground, with no estimate and a live launch.
Reproduction (spawn v0.100.2,
cmd/atad8a4e1)I have not executed this. Running it is the bug, and the account it was found in is not
one to demonstrate it in. The evidence below is a source trace at
ad8a4e1, verifiablewithout spending anything; the fix should be confirmed under the Substrate emulator, where a
launch costs nothing.
Evidence —
cmd/launch_sweep.goestimateOnlyis referenced in exactly two places in the whole tree(
grep -n estimateOnly cmd/*.go pkg/**/*.go):launch_single.go:294— the single-launch path. Correct.launch_sweep.go:682— insidelaunchSweepDetached(declared at:607), before theS3 param upload and the DynamoDB record. Correct, but only reachable via that function.
The foreground sweep path has zero references. Its control flow:
launch_single.go:70-77--param-fileshort-circuits tolaunchParameterSweep;launch_single.go:294is never reachedlaunch_sweep.go:54-55if !detach && !noDetach { detach = true }— detach auto-enableslaunch_sweep.go:64-71maxConcurrentdefaults tomin(len(params), 10)— only inside that auto-enable branchlaunch_sweep.go:150-151if detach && maxConcurrent > 0 { return launchSweepDetached(...) }— the only route to theestimateOnlychecklaunch_sweep.go:154-176launch_sweep.go:220-241launch_sweep.go:288SetupSporedIAMRole→ launch. NoestimateOnlycheck anywhere in between.So the check isn't misplaced by a few lines — it lives on one branch of a fork and is simply
absent from the other. A sweep's spend-preview guarantee currently depends on which
orchestration path it happens to take, which is not a property a user can be expected to
reason about.
Expected behaviour
spawn launch <name> --param-file f.yaml --estimate-only, with any combination of--detach/--no-detach/--max-concurrent:spawn list --state allunchanged.Fix sketch
Hoist the
estimateOnlyhandling above the dispatch atlaunch_sweep.go:150, so it ischecked once for the sweep rather than once per orchestration path.
pricing.EstimateSweepCostalready takes only the param file, so the estimate needs no AWS calls and the early return can
sit before the AWS client is even constructed. Then delete the now-unreachable block at
:682(the detached path still needs
costEstimate.TotalCostfor theSweepRecord, so keep thecomputation there, just not the
estimateOnlyreturn).Structuring it that way is the point of the fix: a per-path check is what failed here, and
adding a second per-path check would leave the same shape in place for the next path someone
adds.
Related: #124 established that
--estimate-onlyshould also run #110's constraint validationfirst. A sweep-wide estimate is the natural place to do that per row, but it needs AWS and can
be a follow-up — launching nothing is the part that matters.
Regression test to add
In
cmd/alongsidesweep_test.go, against the Substrate emulator:The assertion must be
spawn list --state allreturns an empty set — not "theestimateOnlyflag was observed", not "launchSweepDetachedwas not called". This bug is acorrect-looking flag check sitting behind a dispatch: a test that trusts an internal flag would
have passed for the whole time the bug was live. Assert the observable.
Case (b) is the one that must fail before the fix and pass after. (a) and (c) are there so the
fix cannot regress into "only the path we happened to test is safe."
Cross-references
--cost-limitand--ttlfrom a sweep. Shared root cause:per-row configs are built from scratch (
sweep.go:74-82, "Start with an empty config") andonly
Region/InstanceType/Nameare copied off the CLI base config.PARAM_*env vars silently, which is what makes thecost_limit:workaround for Sweep dispatch silently drops --cost-limit and --ttl, then logs "Using safeguards: ttl=..." #525 fail quietly rather than loudly.--estimate-onlybypassing feat(spawn): validate instance type constraints before launch — EFA, placement groups, hibernation #110 constraint validation. Adjacent andnarrower: that one printed an estimate for a config that couldn't launch. This one launches
instead of estimating.
Found while building a deliberately heterogeneous 6-row GROMACS benchmark sweep (arm64 + x86 +
GPU) for the
cost-to-resultproject, where--estimate-onlywas the first command in thedocumented sequence — and where
--no-detachhad already been made mandatory for the AMIreason above. That combination is the failing case, so the mitigation for one problem disabled
the safeguard for another. Recorded there as finding 15.