Skip to content

Stop systemd killing the agent's filesystem utilities - #24

Draft
artemnikitin wants to merge 1 commit into
mainfrom
fix/agent-killmode-for-volume-utilities
Draft

Stop systemd killing the agent's filesystem utilities#24
artemnikitin wants to merge 1 commit into
mainfrom
fix/agent-killmode-for-volume-utilities

Conversation

@artemnikitin

Copy link
Copy Markdown
Owner

Found reviewing artemnikitin/firework#50, which hardens persistent local volumes. One of its fixes is only half-effective without this change, and the other half is here rather than there.

The gap

Creating, checking, or resizing a volume runs mkfs.ext4, e2fsck, and resize2fs as child processes of the agent. Interrupting one partway is what the resize transaction and e2fsck -f -y exist to recover from, so firework#50 detaches those commands from the agent's own shutdown: they survive its context being cancelled, and get their own deadline with a SIGTERM-then-wait rather than an immediate kill.

This unit defeats that. It sets neither KillMode nor TimeoutStopSec, so systemd applies KillMode=control-group — its default. On stop or restart systemd signals every process in the unit's control group, the detached utility included, and escalates to SIGKILL for the whole group once the default stop timeout expires. A node drain during a shrink kills resize2fs exactly as if the agent had never detached it, which is the scenario the firework-side fix names.

Detaching from a Go context cannot escape a cgroup. The supervision contract is the only place this can be fixed.

The change

KillMode=mixed
TimeoutStopSec=2100

KillMode=mixed sends SIGTERM to the main process only — the agent still shuts down promptly — while a utility it detached keeps running. TimeoutStopSec=2100 (35 minutes) exceeds the agent's destructiveCommandTimeout of 30 minutes plus its grace period; below that, systemd force-kills the utility at the timeout and nothing has been gained.

The two numbers are a contract across repositories. destructiveCommandTimeout lives in internal/volume in the firework repo and bounds the command; TimeoutStopSec must stay above it. Raising either alone reopens the gap. firework#50 documents this in docs/persistent-volumes.md and in a comment on the constant itself.

Trade-off worth knowing

A stop or restart can now block for up to 35 minutes if a large resize2fs is genuinely in flight, where previously it was killed promptly. That is the intended exchange: the alternative is a filesystem interrupted mid-resize. In steady state nothing is running and the stop is immediate.

Validation

bash -n and shellcheck clean. Not validated on a live node — this changes how the unit behaves under stop/restart, and the live SIGTERM-during-shrink test that would exercise it is one of the paths firework#50 lists as still blocked on the issue #42 validation lab. Reviewing this against a real drain is the thing I would most want a second pair of eyes on.

Volume work runs mkfs.ext4, e2fsck, and resize2fs as child processes, and the
agent detaches them from its own shutdown so a restart or node drain cannot
interrupt a resize partway through — the case its resize transaction and
e2fsck -f -y exist to recover from.

That protection was defeated here. This unit sets neither KillMode nor
TimeoutStopSec, so systemd's default KillMode=control-group signals every
process in the cgroup on stop, including the detached utility, and force-kills
the whole group once the default stop timeout expires. A drain during a shrink
killed resize2fs exactly as if the agent had never detached it.

KillMode=mixed sends SIGTERM to the agent alone. TimeoutStopSec=2100 exceeds
the agent's 30-minute destructive-command deadline plus its grace period; the
two are a contract, and raising one without the other reopens the gap. The
requirement is documented in docs/persistent-volumes.md in the firework
repository, which is where the deadline lives.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
artemnikitin added a commit to artemnikitin/firework that referenced this pull request Aug 26, 2026
All five reproduce; each in-repo fix has a test that fails without it.

1. Retained manifests with a non-positive applied size bypassed pool capacity.
   readRetained fed AppliedSizeBytes straight into the reserved total, and a
   negative one *subtracts* — a single corrupt or hand-edited manifest admitted
   a 150 MiB volume into a 100 MiB pool. There is no safe number to assume for
   such a record, so it fails closed, and the summation additionally rejects a
   non-positive size from any source: a total that can be driven downwards is a
   capacity bypass whatever produced it.

2. A stale heartbeat reopened a withdrawn refusal. acknowledgeVolumeRecords
   matched on the generation alone, but a record whose refusal was withdrawn
   sits at that same generation — so a stale observation set it back to
   rejected, the next desired-state pass cleared it again, and the pair
   repeated every tick. Two durable writes per tick is bad; a crash between
   them leaves the degraded state behind. A rejected observation is now
   accepted only while the refusal is still outstanding.

3. An explicit zero-volume prior render was mistaken for a missing snapshot.
   Substituting the desired configuration there rendered exactly the
   unvalidated volume config the hold exists to gate. Zero volumes is valid
   prior state; a genuinely missing snapshot cannot reach that code at all,
   because heldPlacementUnrecoverable stops the cycle before anything is
   published.

4. ValidateNodeVolumes checked volume declarations but not the service name,
   which the agent also turns into a path component — so a service named
   "bad/name" passed configcheck and failed agent preflight. It now goes
   through volumeDir, the same function the agent uses, rather than restating
   the pattern.

The fifth finding cannot be fixed here. Detaching a destructive command from
the Go context does not escape the agent's systemd cgroup: under the default
KillMode=control-group, stopping the unit signals the detached mkfs or
resize2fs directly and force-kills it at TimeoutStopSec. The required
supervision contract — KillMode=mixed and a TimeoutStopSec above
destructiveCommandTimeout — is now documented in docs/persistent-volumes.md and
on the constant itself, and implemented in
artemnikitin/firework-deployment-example#24.

Refs #39

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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.

1 participant