symlinkguard: a hung ntfy delays symlink removal (alert flush runs in the scan goroutine) #164
Closed
opened 2026-10-03 16:33:02 +00:00 by mathias
·
3 comments
No Branch/Tag Specified
main
fix/pipeline-writes-158
fix/edit-path-io-192
fix/create-regular-188
fix/guard-special-179
fix/linkcheck-keeps-write-178
fix/write-dir-redirect-173
fix/tunnel-detect-174
fix/pipeline-root-157
w3/154-mcp-auth-wiring
fix/tunnel-traversal
test/in-brain-targets-171
fix/brain-scan-root-157
fix/graph-wiki-root-157
fix/vectorstore-root-157
fix/notes-root-157
fix/search-root-157
fix/brainwide-root-157
fix/raw-symlinks-153
feat/symlink-tripwire-157
fix/rest-auth-149
fix/123-brain-edit-envelope-check
feat/106-page-aware-extraction
fix/105-source-drift-on-main
fix/104-surface-merge-conflicts-on-main
agent/b1-compile-on-ingest
agent/104-surface-merge-conflicts
agent/105-source-drift
agent/107-loud-unconfigured-alerting
feat/brain-read-edit
fix/capture-i1-refusal-names-target
renovate/configure
feat/consolidate-single-harness
icebox/cmd-routing
feat/brain-pending-promote
fix/close-session-classification-refresh
fix/capture-summarywriter
v0.24.0
v0.23.1
v0.23.0
v0.22.0
v0.21.1
v0.21.0
v0.20.1
v0.20.0
v0.19.1
v0.19.0
v0.18.4
v0.18.3
v0.18.2
v0.18.1
v0.18.0
v0.17.0
v0.16.1
v0.16.0
v0.15.0
v0.14.0
v0.13.2
v0.13.1
v0.13.0
v0.12.1
v0.12.0
v0.11.2
icebox/cmd-routing-2026-07-01
v0.11.1
v0.11.0
v0.10.0
v0.9.0
v0.8.0
v0.7.0
v0.6.0
v0.5.0
v0.4.1
v0.4.0
v0.3.0
v0.2.0
v0.1.0
Labels
Clear labels
agent/ready
blocked/client
blocked/supplier
blocked/upstream
risk/high
risk/low
risk/medium
size/l
size/m
size/s
size/xs
status:dispatch
status:dispatched
status:review
status:reviewing
type/chore
type/decision
type/docs
type/feat
type/fix
type/ops
type/research
type/test
Meets the agent-ready contract; authorizes unattended execution
Waiting on the client
Waiting on a supplier
Waiting on an upstream project or vendor
Irreversible, security-relevant, or control plane
Reversible, local, test-covered
Touches shared state, deploys or data
Too big - split it
Under two days
Under half a day
Under an hour
Maintenance, no behaviour change
Human decision required; never agent/ready
Documentation only
New capability
Corrects broken behaviour
Infrastructure, deploy, runtime
Investigate; output is a finding, not code
Test coverage only
No labels
Milestone
No items
No Milestone
Projects
Clear projects
No projects
No Assignees
Notifications
Due Date
No due date set.
Dependencies
No dependencies set.
Reference: mathias/hyperguild#164
Reference in New Issue
Block a user
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Found in the PR #160 round-3 review (head
4ba9d20). I judged it non-blocking there and am recording it here so it isn't lost.symlinkguard.Watchscans, removes links and flushes alerts in a single goroutine.alertQueue.flushtries every queued message, plus the summary, on each tick, andaudit.NtfyNotifierhas a 10 s HTTP timeout. Picture an ntfy that accepts connections but never answers (stuck pod, sqlite lock), with a full queue. One flush can then block for about 6 × 10 s = 60 s. During that time no scan runs, so a planted symlink stays readable for up to about 90 s, not the documented "at most one interval" of 30 s.Repro (executed, scaled down): interval 50 ms, notifier latency 40 ms, queue full. The worst link lifetime was 244 ms, about 5 intervals.
A fast failure (connection refused, 5xx) does not trigger this. Only a hung connection does.
Fix options:
Done when: a test with a slow notifier and a full queue shows link lifetime ≤ interval + one scan.
pr-gatekeeper verdict: BLOCKED (round 2, head
52d4bfe)Mode: PR. CI run 9393 (
CI / Lint / Test / Vet) is green on52d4bfe. Green does not cover finding 1.Round 1's findings are fixed, and flipping each fix turns the suite red:
52d4bfealertSender.pushsends blockingTestWatchLinkLifetimeStaysNearOneInterval...overflow.Swapline deletedTestWatchCountsHandoffOverflowWithASlowNotifierTestWatchDeliversARemovalAlertWellWithinOneIntervalwg.Wait()removedTestWatchReturnsOnlyAfterDeliveryHasStopped(+1)TestWatchReturnsOnlyAfterDeliveryHasStoppedgo test -race -count=3passes, and so do vet and golangci-lint on the package. Closing the channel cannot cause a panic: the only sender is Watch's loop, and the close runs in Watch's defer after that loop has exited. The overflow test asserts that delivered + counted equals the number of plants exactly, so an alert counted twice would fail it.Findings
Blocking.
symlinkguard.godeliver,context.WithTimeout(context.WithoutCancel(ctx), shutdownFlushTimeout): theWithoutCancelthat makes the shutdown flush work has no assertion.context.WithTimeout(ctx, shutdownFlushTimeout), which derives the final flush from the already-cancelled ctx. The whole suite stays green.recNotifierandtoggleNotifierignore their ctx, so in the tests a flush on a dead ctx still "delivers". The realaudit.NtfyNotifierbuilds its request withNewRequestWithContext, so it fails at once on a cancelled ctx.TestWatchFlushesTheQueueOnceMoreAtShutdownclaims more than it shows (rule 9). The round-2 comment says "each mechanism was flip-tested", which is not true of this line.ctx.Err() != nil. Both shutdown tests should then go red under this flip.Minor, not blocking. The drain-on-close path is only exercised by timing. If deliver skips the final flush after
drain()reports the channel closed (i.e. when alerts were still in the channel at close), the suite stays green. My ctx-honouring probe caught that only 1 run in 3, because it depends on whether close lands inside the drain. Worth a deterministic test if it's cheap.Minor, not blocking. Overflow can be lost at close. When the first receive after close returns
!ok,overflowis not swapped intosuppressed. Only a narrow race reaches this:overflow.Addlands after deliver's last swap, and the channel is empty at close. One line fixes it: swap in the!okbranch too.Informational. A correction to my round-1 finding.
cmd/server/main.go:370runsgo symlinkguard.Watch(context.Background(), ...), and the server has no signal handling. In production ctx never ends, and SIGTERM kills the process without Watch returning. So the drain at shutdown never runs in the pod. The production shutdown window is closed by the flush on handoff, which brings it back to parity with main, not by the drain. Round 1 said a Flux restart within one interval would lose the alert, and that overstated the case for the drain. The drain is still correct for any caller that does cancel, so the fix to finding 1 is cheap and still wanted. Wiring a cancelling ctx into main is out of scope here and is worth its own issue.#164 done-when
"a test with a slow notifier and a full queue shows link lifetime <= interval + one scan": met. The notifier takes 400 ms against a 100 ms interval, the queue and handoff are overfilled first, and the test asserts a worst lifetime of at most 1.5 intervals. It goes red when the send blocks.
Not checked
The full
task checklocally (I relied on CI run 9393 for the whole module). The realNtfyNotifieragainst a live hung endpoint (my probe stood in for its ctx behaviour). A notifier that ignores ctx at shutdown: Watch would then wait pastshutdownFlushTimeout, butNtfyNotifierhonours ctx and nothing in main waits on Watch.pr-gatekeeper verdict: BLOCKED (round 2, head
52d4bfe)Mode: PR. CI run 9393 (
CI / Lint / Test / Vet) is green on52d4bfe. LocalCI=1 GOFLAGS=-p=2 task checkexits 0.go test -race -count=10on the package also passes. Neither gate covers findings 1 or 2.All four round-1 findings are fixed, and each fix has a test that catches its removal. Round 2 adds one new untested mechanism, and one shutdown case where alerts are lost silently.
Findings (most severe first)
symlinkguard.go:243context.WithoutCancel(ctx)in the final flush has no test. It is the part of the shutdown fix that makes the fix work.ctxkeeps the whole suite green (-count=3).audit.NtfyNotifierdoes not: it useshttp.NewRequestWithContext, so with a cancelled ctx it fails before sending anything.ctx.Err()when ctx is done, run through theTestWatchFlushesTheQueueOnceMoreAtShutdownscenario. Alerts lost: 0/10 at head, 10/10 with the flip.symlinkguard.go:242-246: at shutdown with ntfy hung or down, undelivered alerts are not recorded as dropped."symlink tripwire notify failed; will retry"warnings. No retry follows, because Watch has returned.len(q.items) > 0 || q.suppressed > 0, log one error with both counts ("dropped at shutdown"), and add a test asserting that log line.Non-blocking, track it separately:
cmd/server/main.go:370runsgo symlinkguard.Watch(context.Background(), ...)and main has no signal handling. In production ctx never ends, so the drain-at-shutdown path never runs. On SIGTERM (a Flux rollout) an alert that is mid-send is lost, exactly as on main. Production's real protection is flush-on-handoff, which this round adds and tests. The shutdown drain only protects callers that cancel. Say that in the doc, or wire a signal ctx in a follow-up issue.Flip tests (each run
-count=3)wg.Wait()join removedSwapaccounting removedWithoutCancelremovedWhat survived
audit.NtfyNotifieragainst a hung httptest server, interval 100 ms:shutdownFlushTimeoutset to 200 ms)Not checked
-race -count=10over the whole repo; only this package was run.pr-gatekeeper verdict: APPROVED (round 3, head
cadc0cc)Mode: PR. CI run 9408 (
CI / Lint / Test / Vet) is green oncadc0cc.Round-2 findings, re-verified by execution. Each flip was run 3 times and went red all 3 times:
TestWatchLinkLifetimeStaysNearOneInterval...drainremovedTestWatchCountsHandoffOverflowWithASlowNotifierTestWatchDeliversARemovalAlertWellWithinOneIntervalwg.Wait()removedWithoutCancel)TestDeliverFlushesAlertsDrainedFromAClosedHandoffOnACancelledCtx,TestWatchFinalFlushSendsAQueuedAlertOnACtxThatOutlivesTheCancelTestWatchReturnsOnlyAfterDeliveryHasStoppedTestDeliverFlushesAlertsDrainedFromAClosedHandoffOnACancelledCtx!okbranch removedTestDeliverCountsOverflowWhenTheHandoffIsClosedAndEmptyTestWatchLogsUndeliveredAndSuppressedCountsWhenTheFinalFlushFailsCtx-honouring probe. I used my own notifier, which fails on a done ctx. At head it delivered 1/1 alerts that were queued at shutdown and 4/4 alerts that were in the handoff at close, on 5 of 5 runs under
-race. Under theWithoutCancelflip it delivered 0, on 3 of 3 runs.go test -race -count=3,go vetandgolangci-lintare all clean on the package.#164 done-when is met: a slow notifier (400 ms) against a 100 ms interval with an overfilled queue gives a worst link lifetime of at most 1.5 intervals, and the test goes red when the send blocks. The production signal-handling gap is tracked in #184.
Nit (not blocking): in
symlinkguard_test.go,gofmt -lflags the import order (osbeforelog/slog). The gate does not enforce gofmt.Not checked: the full
task checklocally (I relied on CI 9408), and the realNtfyNotifieragainst a live hung endpoint.