Skip to content

fix: idleTimeout counts as a bound, costLimit alone warns (#55) - #62

Merged
scttfrdmn merged 1 commit into
mainfrom
fix/55-bounds-guard
Aug 1, 2026
Merged

fix: idleTimeout counts as a bound, costLimit alone warns (#55)#62
scttfrdmn merged 1 commit into
mainfrom
fix/55-bounds-guard

Conversation

@scttfrdmn

Copy link
Copy Markdown
Contributor

Closes #55. Part of #57.

The inversion

The guard accepted the weakest of the three bounds and refused a stronger one:

bound Go accepts as sufficient spawn-ts accepted
--ttl yes yes
--idle-timeout yes no
--cost-limit no (not consulted) yes

Both halves were backwards, and the costLimit half is the one that bills money.

costLimit is a soft limit: spored polls accumulated compute-seconds against spawn:cost-limit. If spored never starts — failed bootstrap, wrong instance profile, crash-loop — nothing enforces it. Only the TTL is enforced from outside the instance: the ttl-reaper Lambda reads spawn:ttl-deadline without the box's cooperation (lambda/ttl-reaper/main.go:688, "the authoritative, launch-anchored deadline"), which is why types.ts calls TTL "the hard cost backstop".

The compounding part, which the issue didn't have: findOrphans skips any instance whose deadline is 0 (src/core/orphans.ts:46). So a cost-limit-only instance isn't merely softly bounded — it's invisible to orphan detection as well. The guard was waving through precisely the launches nothing downstream can catch, and saying nothing while it did.

idleTimeout, meanwhile, was refused although it's the bound Go itself auto-applies as its 1h default (cmd/zombie_guard.go:20, trigger TTL == "" && IdleTimeout == "").

The fix

New src/core/bounds.ts — one pure predicate, with the distinction that was missing as an actual type:

export type Enforcement = "external" | "on-instance";
export const ENFORCEMENT: Record<Bound, Enforcement> = {
  ttl: "external",          // survives spored being dead
  "idle-timeout": "on-instance",
  "cost-limit": "on-instance",
};
  • idleTimeout counts as a bound (matching Go).
  • costLimit alone still permits the launch — refusing it would be a new, harsher divergence from Go — but returns a warn naming the consequence and the orphan blind spot. Silence would read as "bounded", which is the wrong answer: the fix(extend): add the safety floor, write both TTL tags, nudge spored (#54) #63 invariant applied to a safety check.
  • The refusal message now names which bound is the strong one, so a user doesn't satisfy the guard with the weakest available option.

Both launch paths now share it

A correction to the issue's premise, which said the CLI "duplicates the check only to produce a friendlier message before the throw": the CLI's launch calls provider.launch directly (src/cli/commands.ts:211), not client.launch. So its check wasn't a friendlier restatement — it was an independent second implementation of a cost-safety predicate, and the two could drift apart indefinitely. The drift is silent because the lenient copy is the one that lets a launch through. Both now call evaluateBounds.

Go's acknowledgement step, adopted

--no-timeout now requires a confirm (or --yes), matching cmd/zombie_guard.go:58 — "disabling the cost guardrails is an explicit, acknowledged choice". A flag on its own can be a typo or a copy-pasted command line; a confirmation can't.

Latent parser bug, found while testing that

no-timeout was not in BOOLEAN_FLAGS, so parseArgs took the next non-dash token as its value:

launch --no-timeout job   →  flags: { "no-timeout": "job" },  positionals: []

The instance name vanished and flagBool() read false. It failed safe — the guard refused rather than allowing an unbounded launch — but the flag was silently inert in that word order. Fixed, with a regression test.

The divergence from Go is documented, not removed

Go applies a 1h idle default and proceeds; spawn-ts refuses. Refusing is the better choice in a browser — nothing launches, so nothing bills, and there's no daemon here to fall back on. It just shouldn't read as the same guard, so evaluateBounds says so.

Verification

npm run typecheck   # clean
npx vitest run      # 27 files, 420 passed

The new assertions were proven to fail against the old condition before being kept — restoring the inverted logic fails 6 of them (4 in bounds.test.ts, 2 in client.test.ts). Worth noting why nothing failed on the first run: the existing guard test (client.test.ts:55) only exercised the mock path, where the guard doesn't engage, so the inversion was never asserted in either direction.

Unrelated flake seen once during a full-suite run and filed separately as #61 (ssm/session.test.ts waits on a fixed 5 ms setTimeout; reproduced on unmodified main, file untouched here).

The unbounded-launch guard accepted the weakest of the three bounds and
refused a stronger one:

  bound           Go accepts   spawn-ts accepted
  --ttl           yes          yes
  --idle-timeout  YES          NO
  --cost-limit    no           YES

Both halves were backwards. costLimit is a SOFT limit: spored polls
accumulated compute-seconds against spawn:cost-limit, so if spored never
starts (failed bootstrap, wrong instance profile, crash-loop) nothing
enforces it. Only the TTL is enforced from outside the box — the
ttl-reaper Lambda reads spawn:ttl-deadline without the instance's
cooperation (lambda/ttl-reaper/main.go:688), which is why types.ts calls
it "the hard cost backstop".

Worse, findOrphans skips any instance whose deadline is 0
(src/core/orphans.ts:46), so a cost-limit-only instance is invisible to
orphan detection too. The guard was waving through exactly the launches
nothing downstream can catch, and saying nothing while it did — a guard
that passes must not imply a guarantee it isn't making.

New src/core/bounds.ts holds one pure predicate, with the enforcement
distinction (external vs on-instance) as the type that was missing.
idleTimeout now counts; costLimit alone still permits the launch —
refusing it would be a new, harsher divergence from Go — but emits a
warning naming the consequence and the orphan-detection blind spot.

Both launch paths now share it. The CLI's `launch` calls
provider.launch directly rather than going through SpawnClient, so its
check was not a friendlier restatement of the client's, it was an
independent second implementation of a cost-safety predicate. Two copies
drift, and the drift is silent because the lenient copy is the one that
lets a launch through.

Also adopts Go's acknowledgement step: --no-timeout now requires a
confirm (or --yes), matching cmd/zombie_guard.go:58 — "disabling the
cost guardrails is an explicit, acknowledged choice". A flag alone can
be a typo or a copied command line.

Fixes a latent parser bug found while testing that: --no-timeout was
not in BOOLEAN_FLAGS, so `launch --no-timeout job` consumed "job" as the
flag's VALUE (parseArgs takes the next non-dash token for an unknown
flag). The instance name vanished and flagBool read false. It failed
safe — the guard refused rather than allowing an unbounded launch — but
the flag was silently inert in that word order.

The deliberate divergence from Go is documented rather than removed: Go
applies a 1h idle default and proceeds, spawn-ts refuses. Refusing is
right for a browser (nothing launches, so nothing bills) — it just
shouldn't read as the same guard.

Tests: 420 passed. The 6 new assertions covering the inversion were
proven to fail against the old condition before being kept; the existing
guard test only exercised the mock path, which is why the inversion was
never asserted either way.

Part of #57.
@scttfrdmn
scttfrdmn merged commit 1280a47 into main Aug 1, 2026
1 check passed
@scttfrdmn
scttfrdmn deleted the fix/55-bounds-guard branch August 1, 2026 07:38
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Unbounded-launch guard accepts costLimit (soft, spored-dependent) but refuses idleTimeout — inverted from Go

1 participant