Found while porting the tag contract to spawn-ts (spawn-ts#51 / spawn-ts#60), which inherited this bug verbatim and has now fixed it. Filing here because the Go side is the original and is worse.
The claim in the code is false
pkg/aws/tags.go:247:
// Add parameter tags (up to 35 to stay under AWS 50-tag limit)
paramCount := 0
for k, v := range config.Parameters {
if paramCount >= 35 {
break
}
35 params does not stay under 50. The cap counts only the parameters, ignoring the ~30 tags the other six sections of buildTags already appended, plus the 4 spawn:sweep-* tags immediately above it.
AWS caps a resource at 50 tags, and exceeding it fails RunInstances outright — it does not truncate. All the tags land on one slice applied to both the instance and volume TagSpecification (pkg/aws/client.go:382), so the cap binds on a real launch.
Measured, not reasoned
A throwaway test calling buildTags directly with every documented LaunchConfig field populated:
| configuration |
tags |
vs limit |
| fully configured + 60 sweep params (35 admitted) |
86 |
+36 |
fully configured, Parameters: nil |
51 |
+1 — over before any sweep exists |
| bare sweep only (35 params, nothing else set) |
48 |
just under |
The second row is the important one: the sweep path isn't required to hit this. A launch using FSx + EFS + DCV + notify + job array + all lifecycle flags is already at 51 with no parameters at all. The 35-param cap is a red herring — it's the only place with a bound, and the sections without one are where the count actually accumulates.
Section 7 (pkg/aws/tags.go:258) then appends user-supplied --tag entries with no cap whatsoever:
for k, v := range config.Tags {
tags = append(tags, types.Tag{Key: aws.String(k), Value: aws.String(v)})
}
so #161's spawn launch --tag k=v is a user-reachable way to push any launch over the edge.
Why it hasn't been seen much
It needs a launch that populates many optional sections at once. A typical launch is well under. But the failure mode is bad when it lands: RunInstances rejects the call, so the user gets a tag-limit error from EC2 for a launch whose tags they mostly never chose, and the maximal-config user (FSx + DCV + array + notify) is exactly the sophisticated user least likely to suspect tag arithmetic.
Suggested fix — a budget, not a smaller constant
Swapping 35 for a smaller number would be another guess that goes stale the moment a tag is added, and it wouldn't help the Parameters: nil row at all. What spawn-ts did instead (src/core/tags.ts, PR spawn-ts#60):
export const AWS_TAG_LIMIT = 50;
function paramTagBudget(alreadyUsed: number): number {
return Math.max(0, AWS_TAG_LIMIT - alreadyUsed);
}
with the sweep block emitted last so it can be capped against what the rest of the launch actually consumed, and parameter keys emitted in sorted order so the surviving subset is deterministic rather than dependent on Go map iteration order — note the current loop ranges over config.Parameters directly, so which 35 params survive is already nondeterministic between runs. That alone is worth fixing: two members of the same sweep can record different parameter subsets.
Dropping parameters is itself lossy — spawn:param:* is how a sweep member records which point in the space it is — but a truncated tag set beats a launch that fails outright.
For the Go side the budget needs to cover the unbounded sections too, in priority order. The base-identity block (managed/root/iam-user/…) must never be what gets dropped: it's what makes an instance ownable and terminable. Suggested precedence: base identity > lifecycle > job-array/sweep ids > user tags > spawn:param:*.
A regression test should assert the invariant (len(tags) <= 50 for a maximal config), not a parameter count — the existing implicit assumption is what let this sit.
Cross-reference
Found while porting the tag contract to spawn-ts (spawn-ts#51 / spawn-ts#60), which inherited this bug verbatim and has now fixed it. Filing here because the Go side is the original and is worse.
The claim in the code is false
pkg/aws/tags.go:247:35 params does not stay under 50. The cap counts only the parameters, ignoring the ~30 tags the other six sections of
buildTagsalready appended, plus the 4spawn:sweep-*tags immediately above it.AWS caps a resource at 50 tags, and exceeding it fails
RunInstancesoutright — it does not truncate. All the tags land on one slice applied to both the instance and volumeTagSpecification(pkg/aws/client.go:382), so the cap binds on a real launch.Measured, not reasoned
A throwaway test calling
buildTagsdirectly with every documentedLaunchConfigfield populated:Parameters: nilThe second row is the important one: the sweep path isn't required to hit this. A launch using FSx + EFS + DCV + notify + job array + all lifecycle flags is already at 51 with no parameters at all. The 35-param cap is a red herring — it's the only place with a bound, and the sections without one are where the count actually accumulates.
Section 7 (
pkg/aws/tags.go:258) then appends user-supplied--tagentries with no cap whatsoever:so
#161'sspawn launch --tag k=vis a user-reachable way to push any launch over the edge.Why it hasn't been seen much
It needs a launch that populates many optional sections at once. A typical launch is well under. But the failure mode is bad when it lands:
RunInstancesrejects the call, so the user gets a tag-limit error from EC2 for a launch whose tags they mostly never chose, and the maximal-config user (FSx + DCV + array + notify) is exactly the sophisticated user least likely to suspect tag arithmetic.Suggested fix — a budget, not a smaller constant
Swapping 35 for a smaller number would be another guess that goes stale the moment a tag is added, and it wouldn't help the
Parameters: nilrow at all. What spawn-ts did instead (src/core/tags.ts, PR spawn-ts#60):with the sweep block emitted last so it can be capped against what the rest of the launch actually consumed, and parameter keys emitted in sorted order so the surviving subset is deterministic rather than dependent on Go map iteration order — note the current loop ranges over
config.Parametersdirectly, so which 35 params survive is already nondeterministic between runs. That alone is worth fixing: two members of the same sweep can record different parameter subsets.Dropping parameters is itself lossy —
spawn:param:*is how a sweep member records which point in the space it is — but a truncated tag set beats a launch that fails outright.For the Go side the budget needs to cover the unbounded sections too, in priority order. The base-identity block (
managed/root/iam-user/…) must never be what gets dropped: it's what makes an instance ownable and terminable. Suggested precedence: base identity > lifecycle > job-array/sweep ids > user tags >spawn:param:*.A regression test should assert the invariant (
len(tags) <= 50for a maximal config), not a parameter count — the existing implicit assumption is what let this sit.Cross-reference