Repository navigation
fix(task): flush a completion record and the log when a lifecycle limit kills a task - #643
Conversation
…it kills a task A task killed by its own TTL left nothing at all: no completion.json, no command.log, no task prefix in the results bucket. Both the log upload and the completion record are written by the wrapper AFTER the user command, in sequence, so they only ever ran if the command returned — when the TTL fired mid-command the log died with the disk. That is the one failure mode where diagnostics matter most, and structurally the most likely to recur: TTL is documented as the cost cap, so sizing it tight is the recommended practice. The reporter lost a 45-minute run with no way to tell which phase it reached, so re-sizing was another blind guess — while the log already held exactly the answer, because spawn_phase() had written timestamped stage-in/pull/command markers into it. spored now runs a terminal-flush hook from runPreStop, which is called synchronously BEFORE provider.Terminate/Stop/Hibernate. Chosen over the reported suggestion of a SIGTERM trap in the wrapper: a trap only fires during OS shutdown, racing an S3 upload against network teardown, whereas here the instance is still fully healthy. Hanging it off runPreStop also means it covers every exit path spored mediates — so the identical gap is closed for a cost-limit kill, an idle stop and a spot interruption, not just TTL. Design notes: - state is "failed", NOT a new "terminated" value. CompletionRecord.State is documented `completed | failed` and six workflow adapters parse that exact object; a third value sends all of them down a default branch. The WHY rides in retry_class (populating RetryTTLExpired/RetrySpotInterruption, which completion.go already reserved as "needs spored-side signals") and in a new additive, omitempty terminal_reason. exit_code is -1 to distinguish "never returned a status" from a real exit. - The hook is installed to /etc/spawn/task-flush.sh, root-owned 0700 — NOT /tmp. spored execs it as root; the existing /tmp/SPAWN_COMPLETE channel is only ever READ as data, which is a different risk class. - The exit reason is a typed token, never the human log string, so rewording a log message cannot silently change a recorded retry class. - An interlock refuses to write when the wrapper already produced a record. Without it the hook would relabel every normally-completed task failed/exit -1, since the success path also reaches runPreStop. Verified: with the interlock removed the overwrite test fails. Also fixes #642, found while testing this: four exec-based tests read one hardcoded /tmp path that pkg/taskproto and pkg/taskpool both wrote, and `go test ./...` runs packages in parallel. It failed 3/3 and passed 3/3 on the same commit depending only on test-cache state. The local artifact directory is now resolved at generation time (still /tmp in production, which is load-bearing per #66) with tests pointing it at a per-binary temp dir; deliberately not a runtime env var, since the wrapper and hook are separate processes and the hook uses that path as its interlock. Not covered, stated plainly: an instance killed without spored's involvement (direct terminate-instances, out-of-band reaper, host failure) has no pre-stop path to hang the flush off. Tests: the interlock, a parsable terminal record, log-before-record ordering, logs[] only on a successful upload, the reason->class mapping, an unknown reason, bash -n, a hostile task_id, aws discovery outside the system dirs, the agent's token passing/skip/once-guard/failure tolerance, 0700-root-owned installation, heredoc non-collision, and a drift gate asserting every lifecycle exit site passes a token. Each was confirmed to fail against the pre-fix behaviour. Fixes #632 Fixes #642
Codecov Report❌ Patch coverage is
📢 Thoughts on this report? Let us know! |
Semgrep's dangerous-exec-command blocked the PR: making the hook path testable
turned it from a constant into a package variable, and the rule cannot know the
only writer is a test helper.
Rather than suppress it alone, add the check the constant never provided. A
constant tells you WHERE you are about to exec; it says nothing about whether
the file there is still trustworthy. spored runs as root, so that distinction is
the whole game. verifyHookTrustworthy refuses to exec unless the target is:
- a regular file (not a directory, device or socket),
- not a symlink — os.Lstat rather than os.Stat, so a link planted at the path
is rejected instead of silently followed to its target,
- writable by nobody but its owner (perm & 0o022 == 0).
The root-owned /etc/spawn directory remains the primary protection; this is the
residual guard for a file something else has loosened.
Deliberately portable rather than checking uid 0 via syscall.Stat_t: pkg/agent
needs a linux/windows/other build-tag trio (see sys_*.go) and this hook only
ever exists on Linux, while "owner-only writable inside a root-owned directory"
is the property that actually matters. Verified to build for linux, windows and
darwin.
The suppression that remains is narrow and documented, and notes the gotcha that
cost a CI round-trip: a nosemgrep directive is only honoured on the offending
line or the one immediately above it. The first attempt put it at the top of a
comment block and it was silently ignored — semgrep still reported the finding.
Tests: five permission cases (0700/0750 accepted; 0770/0777/0702 refused) and a
symlinked hook. Confirmed to fail with the check removed — 0770, 0777 and 0702
all executed. Verified against the exact CI command, `semgrep scan --config=auto
--error .`: 0 findings across 811 files.
|
Pushed 980a0a5 — Semgrep's The cause was real, not a false positive to wave through. Making the hook path testable turned it from a constant into a package variable, and the rule can't know the only writer is a test helper. So rather than suppress it alone, I added the check the constant never provided. A constant tells you where you're about to exec; it says nothing about whether the file there is still trustworthy — and spored execs it as root, which is the whole game.
The root-owned Tests: five permission cases (0700/0750 accepted, 0770/0777/0702 refused) plus a symlinked hook. Confirmed to fail with the check removed — 0770, 0777 and 0702 all got executed. One gotcha worth recording, since it cost a CI round-trip: a Verified locally against the exact CI command this time rather than guessing: |
Fixes #632. Also fixes #642, found while testing this.
The bug
A
spawn task runtask killed by its own TTL left nothing: nocompletion.json, nocommand.log, no task prefix in the results bucket. Verified in code — both are written by the wrapper after the user command, in sequence:pkg/taskproto/wrapper.go:253-256— the log uploadpkg/taskproto/wrapper.go:259+— thecompletion.jsonheredocand
grep -n trap pkg/taskproto/wrapper.gofinds nothing. So they only ever run if the command returns. When a lifecycle limit fires mid-command, the log dies with the disk — and that log is exactly what you need, becausespawn_phase()has already written timestampedstage-in start/stage-in done/command startmarkers into it.This is distinct from the two already-fixed neighbours, both of which assume the command returns: #566 (a command that fails writes no record) and #561 (a lost output recorded as success). Here it neither succeeded nor failed — it was still running.
The fix, and why it differs from the issue's suggestions
The issue offered three shapes. I used a fourth, because a mechanism already existed that is strictly better than all three:
trap) only fires during OS shutdown, racing an S3 upload against network teardown — the worst possible moment to need the network.runPreStopruns while the instance is still fully healthy.$RESULTS_PREFIX. Here the hook is generated with it baked in.Hanging it off
runPreStopalso means it covers every exit path spored mediates, not just TTL —agent.go:1326(spot interruption),:1581(stop),:1602(hibernate),:1799(terminate). So the identical diagnostic hole is closed for a cost-limit kill and an idle stop, which the issue doesn't mention but which lose the log the same way.Deliberate deviation: no
state: "terminated"The issue asked for
state: "terminated". I usedstate: "failed"instead, becauseCompletionRecord.Stateis documentedcompleted | failedand six workflow adapters parse this exact object — a third value would send every one of them down a default branch, making this a cross-repo wire-contract change. "failed" is also true: the task produced no outputs.The why, which is what you actually needed, rides in two places instead:
retry_class— now populatingRetryTTLExpiredandRetrySpotInterruption, whichcompletion.go:85-89already documents as "defined but not yet populated — these need spored-side signals". This is that signal.terminal_reason— a new additive, omitempty field naming the limit that fired (ttl_expired,cost_limit_exceeded,idle_timeout,spot_interruption). Needed becauseretry_classalone can't express cost/idle, which have no matching class. Additive exactly asrun_idwas for task run --wait can return the PREVIOUS attempt's completion.json when re-running the same task_id (false negative after a fix) #608: adapters ignore unknown fields, and pre-A task killed by its own TTL leaves no completion record and no command.log, so there is nothing to re-size from #632 records still parse.exit_codeis-1, to distinguish "never returned a status" from a real 0 or non-zero exit.The dangerous part, and its interlock
The success path also reaches this code: command returns → wrapper writes
completion.json→SPAWN_COMPLETE→ spored acts onon_complete→terminate()→runPreStop()→ hook. Without a guard, the hook would relabel every normally-completed taskfailed/exit -1— far worse than the bug being fixed. So the hook bails first on the wrapper's record existing, andTestFlushHookNeverOverwritesTheWrapperRecordwas confirmed to fail with that guard removed.Security
The hook is installed to
/etc/spawn/task-flush.sh, root-owned 0700 — not/tmp. spored execs it as root, so a world-writable location would let any local user choose what root runs. The existing/tmp/SPAWN_COMPLETEchannel is only ever read as data, which is why/tmpis fine for that and not for this. Pinned byTestTaskFlushHookIsInstalledRootOwnedAndNotWorldWritable(fails at 0755).The exit reason is a typed token, not the human log string, so rewording
"cost limit reached ($%.2f)"can never silently change a recorded retry class. A drift gate asserts every lifecycle exit site passes one.Two real bugs caught by the tests themselves
PATHexport was substituting, not appending — which made anyawsoutside the six system dirs unreachable. Not hypothetical: Ubuntu's snap package installs it to/snap/bin. A hook that can't findawssalvages nothing.TestFlushHookFindsAwsOutsideTheSystemDirspins it.Not covered, stated plainly
An instance killed without spored's involvement — a direct
terminate-instances, an out-of-band reaper, or host failure — still leaves nothing. spored never runs its pre-stop path there, so there is nothing to hang this off; closing it needs a signal from the terminating side (e.g. the reaper's graceful SSM path). I chose not to ship atrapas a token backstop for it, since I can't verify it works in the case it's meant for. The pooled worker path (pkg/taskpool) is also out of scope — its per-job script arrives over SSM, so the hook would need rewriting per job.Verification
gofmt,go vet,make check-docs, race detector onpkg/agent, all 11 lambda modules build.git stashwithout-u, so the baseline didn't compile and reported nothing forpkg/agent— the resulting 7 "new" U1000s were pre-existing findings merely unmasked. Redone with-u.)PreStopgate, and 0755 installation all produce the expected red.go.mod/go.sumuntouched.