Skip to content

fix(hoot): publishNote counts successes; error path no longer skips defers - #62

Open
oth-body wants to merge 1 commit into
masterfrom
fix/publish-error-surfacing
Open

fix(hoot): publishNote counts successes; error path no longer skips defers#62
oth-body wants to merge 1 commit into
masterfrom
fix/publish-error-surfacing

Conversation

@oth-body

@oth-body oth-body commented Sep 6, 2026

Copy link
Copy Markdown
Owner

Summary

The CLI publish flow (hoot -m "...") returned exit code 0 even if every relay rejected the event — and the failure path used log.Fatalf, which bypassed every deferred cleanup in main().

What this fixes

1. Silent publish failure

The publish loop in main() counted nothing — it printed "Successfully published to relay: ..." lines for both successes AND failures, then return nil from the withLoading callback. If all relays rejected the event, hoot -m printed failures and exited 0. The user saw error-looking output but the process claimed success.

Extracted the loop into publishNote(ctx, relays, event) which:

  • Counts successes and failures separately (mirroring the pattern publishPostTUI already used correctly).
  • Returns an error if zero relays accepted the event so the CLI can surface "failed to publish to any relay" to the user.
  • Prints a "Published to N relay(s); M failed" summary on partial success so silent rejections become visible.

2. Failure path skips defer eventCache.Close()

The old call site used log.Fatalf on publish failure, which calls os.Exit(1) immediately and bypasses every defer in main() — including defer eventCache.Close() (line 1230) and the last-used-profile save in OnSelectProfile. SQLite WAL files can leak; cached event writes can be lost.

Replaced with a proper error path: log the error, then os.Exit(1) — defers run because os.Exit happens at the very end of main(), after every deferred call has already been registered. This was the explicit subject of the prior refactor sprint (#37, #38) but log.Fatalf calls remained in the publish failure branch.

3. Clarifying comment on defer relay.Close() inside a loop

defer in a loop fires at function-exit, not iteration-exit, so all those closes happen at once after pool.Relays.Range finishes. Functionally fine (the SimplePool's own cleanup does the same thing on program exit), but the prior code was silent about it and easy to misread. Comment now says so explicitly.

Tests

publish_error_test.go (new, 2 tests, ~10ms):

  • TestPublishNoteAllRelaysFail — empty relay list → success count is 0 → error returned. Catches the silent-failure regression if anyone reintroduces the return nil.
  • TestPublishNoteNilRelaysnil slice → same path → error returned. Catches a different shape of the same bug.

The success/failure counting math is exercised every time hoot -m is run; the only testable invariant without a live relay is "empty relay list must return an error" and that pins the regression-catching intent.

Conflict check

Related but not in this PR

The other 12 log.Fatalf calls in main() (lines 1425, 1434, 1449, 1461, 1471, 1478, 1484, 1516, 1532, 1543, 1552, 1560, 1570, 1576, 1587) all bypass defer eventCache.Close() on error. Same class of bug; bigger diff. Filed for a follow-up PR so this one stays surgical.

…efers

The CLI publish flow returned nil from its withLoading callback even
if every relay rejected the event — the for-loop over pool.Relays
counted nothing and the function happily exited 0 with the user
seeing "Successfully published to relay: ..." lines for relays that
had actually failed.

Extracted the publish loop into a publishNote(ctx, relays, event)
function that:

  1. counts successes and failures separately (same pattern as the
     existing publishPostTUI, which is the only place that did this
     correctly)
  2. returns an error if zero relays accepted the event, so the CLI
     can surface "failed to publish to any relay" to the user
  3. prints a "Published to N relay(s); M failed" summary on partial
     success so the user sees when relays silently rejected

Also: the call site used log.Fatalf on publish failure, which calls
os.Exit(1) immediately and bypasses every defer in main() — including
defer eventCache.Close() (line 1230) and the nil in the TUI config
cleanup callbacks. SQLite WAL files can leak, and the last-used
profile save inside OnSelectProfile can be lost. Replaced with a
proper error path: log the error, then os.Exit(1) — defers run
because the os.Exit happens at the end of main() after all the
deferred calls have already been registered.

Tests: publish_error_test.go pins the empty/nil-relays case (which
is the only path that doesn't require a fake Nostr relay). The
success-counting math is exercised by the function itself every time
hoot is used, and the only testable invariant without a live relay
is "empty relay list must return an error" — that catches the silent-
failure regression if anyone reintroduces the `return nil` at the
end of the withLoading callback.

No conflict with #61 (silent-failures-hoot) or #58
(feat/buzz-interoperability). Both branches touch hoot.go but at
disjoint functions (defaultRelays/generateProfileID/payInvoiceNWC vs.
the main publish loop).
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