fleetd #546: widen Injector's delivery catch to Throwable #549

Merged
ltms merged 1 commits from worker/546-injector-redelivery-d810ad-7 into main 2026-09-12 09:39:41 +02:00
Member

Fixes #546.

PR #543 (fleetd #538) widened StatusPoller's per-target catch to Throwable so the polling loop survives an Error instead of dying. That fix made a second-order defect reachable: Injector.java's own delivery catch (Injector.java:391) only caught RuntimeException, so a non-RuntimeException throwable (e.g. NoClassDefFoundError, #413) escaping the herdr send at :383 left the poisoned message QUEUED at the head of the target's queue (Injector.java:378 peeks, not polls). Once #543 let the poller survive and come back round, the next onStatus round would re-enter the same try and send the same text again -- the same brief typed into the member's pane twice.

Change

Widen Injector.java:391's catch from RuntimeException to Throwable, matching #543 one layer down. The handler body was already correct (drop the poisoned message, mark NOT_DELIVERED, surface the error) -- it was scoped one class too narrow.

sendError's declared type widens from RuntimeException to Throwable to keep this compiling. Its only consumer is CompletableFuture.completeExceptionally(Throwable), which already accepts Throwable, so no other caller-visible behavior changes. The ordinary HerdrException/RuntimeException path (the only path reachable before #543) is unchanged.

I looked at Injector.java's other catch clause (the resubmit nudge, now at :500 after my added comment lines) -- it has the same RuntimeException-only shape, but its consequence is different: it doesn't touch queue state, so an escaping Error there just skips one debug log and one Enter nudge for that round (the next round retries), not a duplicate send. Left unchanged per the ticket's "one change, one ticket."

Tests (added to InjectorTest.java)

  1. anErrorFromSendRemovesTheMessageAndMarksItNotDelivered -- an Error from the send seam is caught inside onStatus, the message is dropped and marked NOT_DELIVERED (asserted via cancel() returning NOT_DELIVERED rather than CANCELLED, which would mean it was still queued).
  2. anErrorFromSendDoesNotRedeliverOnASecondRound -- the regression test: after surviving the Error the way StatusPoller now does, a second onStatus round does not re-send the same text. Asserts exactly one agent.prompt call was received.
  3. aHerdrExceptionFromSendStillProducesNotDeliveredUnchanged -- control: HerdrException (a RuntimeException) still produces NOT_DELIVERED and still surfaces to the caller, unchanged by the widening.

Verification

  • mvn -B clean install exits 0. Tests run: 1719, Failures: 0, Errors: 0, Skipped: 0 (baseline 1716 + 3 new).
  • Each new test proven with its own mutation: reverting the catch to RuntimeException turns tests 1 and 2 red with their own assertion messages; breaking the NOT_DELIVERED assignment turns test 3 red. Each mutation's pristine-text detection check reported "not applied" on the clean tree beforehand, and the file was confirmed byte-identical (sha256) before and after each restore, followed by a green control run.
  • No socket, no port bind, no spawn; nothing written outside test fixtures/@TempDir.
Fixes #546. PR #543 (fleetd #538) widened StatusPoller's per-target catch to Throwable so the polling loop survives an Error instead of dying. That fix made a second-order defect reachable: Injector.java's own delivery catch (Injector.java:391) only caught RuntimeException, so a non-RuntimeException throwable (e.g. NoClassDefFoundError, #413) escaping the herdr send at :383 left the poisoned message QUEUED at the head of the target's queue (Injector.java:378 peeks, not polls). Once #543 let the poller survive and come back round, the next onStatus round would re-enter the same try and send the same text again -- the same brief typed into the member's pane twice. ## Change Widen Injector.java:391's catch from RuntimeException to Throwable, matching #543 one layer down. The handler body was already correct (drop the poisoned message, mark NOT_DELIVERED, surface the error) -- it was scoped one class too narrow. sendError's declared type widens from RuntimeException to Throwable to keep this compiling. Its only consumer is CompletableFuture<Void>.completeExceptionally(Throwable), which already accepts Throwable, so no other caller-visible behavior changes. The ordinary HerdrException/RuntimeException path (the only path reachable before #543) is unchanged. I looked at Injector.java's other catch clause (the resubmit nudge, now at :500 after my added comment lines) -- it has the same RuntimeException-only shape, but its consequence is different: it doesn't touch queue state, so an escaping Error there just skips one debug log and one Enter nudge for that round (the next round retries), not a duplicate send. Left unchanged per the ticket's "one change, one ticket." ## Tests (added to InjectorTest.java) 1. anErrorFromSendRemovesTheMessageAndMarksItNotDelivered -- an Error from the send seam is caught inside onStatus, the message is dropped and marked NOT_DELIVERED (asserted via cancel() returning NOT_DELIVERED rather than CANCELLED, which would mean it was still queued). 2. anErrorFromSendDoesNotRedeliverOnASecondRound -- the regression test: after surviving the Error the way StatusPoller now does, a second onStatus round does not re-send the same text. Asserts exactly one agent.prompt call was received. 3. aHerdrExceptionFromSendStillProducesNotDeliveredUnchanged -- control: HerdrException (a RuntimeException) still produces NOT_DELIVERED and still surfaces to the caller, unchanged by the widening. ## Verification - mvn -B clean install exits 0. Tests run: 1719, Failures: 0, Errors: 0, Skipped: 0 (baseline 1716 + 3 new). - Each new test proven with its own mutation: reverting the catch to RuntimeException turns tests 1 and 2 red with their own assertion messages; breaking the NOT_DELIVERED assignment turns test 3 red. Each mutation's pristine-text detection check reported "not applied" on the clean tree beforehand, and the file was confirmed byte-identical (sha256) before and after each restore, followed by a green control run. - No socket, no port bind, no spawn; nothing written outside test fixtures/@TempDir.
agent added 1 commit 2026-09-12 09:27:32 +02:00
fleetd #546: widen Injector's delivery catch to Throwable, stop re-delivery on Error
CI / contract (pull_request) Successful in 1m29s
CI / build (pull_request) Successful in 1m49s
87871eaefb
Injector.java:391 caught only RuntimeException around the herdr send seam. PR #543
(fleetd #538) widened StatusPoller's per-target catch to Throwable so the polling
loop now survives an Error there, which means it comes back round — and Injector's
narrower catch let the poisoned message stay QUEUED (the loop peeks, not polls),
so the next round re-sent the same text into the member's pane.

Widen the catch to Throwable, matching #543 one layer down. sendError's declared
type widens from RuntimeException to Throwable to keep compiling; its only consumer
(CompletableFuture.completeExceptionally(Throwable)) already accepts that type, so
no other caller-visible behavior changes. The ordinary HerdrException/RuntimeException
path is unchanged.

Adds three tests: an Error at the send seam is dropped and marked NOT_DELIVERED, a
second onStatus round does not re-send it, and a HerdrException control proves the
ordinary path is untouched.
ltms merged commit 93a9ed3f83 into main 2026-09-12 09:39:41 +02:00
ltms deleted branch worker/546-injector-redelivery-d810ad-7 2026-09-12 09:39:41 +02:00
Sign in to join this conversation.