Skip to content

fix: stop stranding the Telegram poll loop on every settings save - #12

Merged
masseselsev merged 1 commit into
masterfrom
fix/telegram-poll-loop
Sep 18, 2026
Merged

masseselsev merged 1 commit into
masterfrom
fix/telegram-poll-loop

Conversation

@masseselsev

Copy link
Copy Markdown
Owner

What

The Telegram long-poll loop now owns the channel it watches instead of re-reading a service field:

  • Start() builds a fresh poll run (context, stop channel, cancel, completion channel) and hands it to the loop as an argument;
  • Stop() detaches the run under the mutex, closes its channel, cancels its context — which aborts an in-flight getUpdates long poll instead of waiting it out — and returns only after the loop goroutine has exited. It is idempotent, so a double stop cannot panic on a closed channel;
  • Reconfigure() is Stop(); Start() again without the 500 ms sleep, because that sleep was a guess that did not hold while the loop sat in a 20 s poll.

Why

Start() reassigned stopCh while the running loop read the field on every iteration, so a loop could never observe its own channel closing. Since Reconfigure() runs on every settings save, each save stranded one goroutine (plus its HTTP buffers) parked in a getUpdates long poll next to a fresh poller — which is also what produces the HTTP 409 "terminated by other getUpdates request" conflicts, since Telegram serves one consumer per bot token.

Verification

Three new tests use an httptest stand-in that parks every long poll and counts how many polls from this process are parked at once — more than one means a previous loop is still alive:

  • against the previous implementation they fail: Stop() returned but 1 poll loop(s) are still parked in getUpdates — the loop did not exit (TestTelegramBot_StopIsIdempotent, and the reconfigure/termination tests with it);
  • with this change they pass: repeated Reconfigure() leaves exactly one loop, Stop() terminates promptly, and a second Stop() is a no-op.

Full backend suite green (6/6 packages) on master plus only this change, checked in a separate worktree so unrelated work in progress could not mask a failure. Frontend untouched.

Start() replaced the service's stopCh field while the running loop read that
field on every iteration, so a loop never observed its own channel being
closed: each settings save (Stop(); Start()) left one goroutine parked in a
20 s getUpdates long poll next to a fresh poller, which is what produced the
409 conflicts.

A poll run now owns its context, stop channel and completion channel, and the
loop receives the run as an argument instead of reading a service field. Stop()
waits for the loop to actually exit and is idempotent; an in-flight long poll is
aborted through the run's context instead of being waited out; and the 500 ms
sleep in Reconfigure() is gone, because it was a guess that did not hold while
the loop sat in a long poll.

Verified: three new tests fail against the previous implementation (Stop()
returned but 1 poll loop was still parked in getUpdates) and pass with it;
backend suite 6/6 packages.
@masseselsev
masseselsev merged commit 4b0c684 into master Sep 18, 2026
2 checks passed
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