diff --git a/internal/web/service/tgbot/tgbot_invite.go b/internal/web/service/tgbot/tgbot_invite.go index ac1ff8474..29966f6f0 100644 --- a/internal/web/service/tgbot/tgbot_invite.go +++ b/internal/web/service/tgbot/tgbot_invite.go @@ -71,13 +71,7 @@ func (t *Tgbot) claimInvite(chatId int64, fromID int64, payload string) inviteOu return inviteInvalid } - inviteClaimMu.Lock() - outcome, records := t.resolveInviteToken(token, fromID) - var bindErr error - if outcome == inviteBindable { - bindErr = t.bindRecordsToUser(records, fromID) - } - inviteClaimMu.Unlock() + outcome, records, bindErr := t.resolveAndBindInvite(token, fromID) switch outcome { case inviteAlreadyOwned: @@ -97,6 +91,18 @@ func (t *Tgbot) claimInvite(chatId int64, fromID int64, payload string) inviteOu return outcome } +// resolveAndBindInvite holds inviteClaimMu with a deferred unlock: the handler recovers panics, +// and a lock a panic left held would park every later /start claim for good. +func (t *Tgbot) resolveAndBindInvite(token string, fromID int64) (inviteOutcome, []*model.ClientRecord, error) { + inviteClaimMu.Lock() + defer inviteClaimMu.Unlock() + outcome, records := t.resolveInviteToken(token, fromID) + if outcome != inviteBindable { + return outcome, records, nil + } + return outcome, records, t.bindRecordsToUser(records, fromID) +} + func recordEmails(records []*model.ClientRecord) string { emails := make([]string, 0, len(records)) for _, record := range records { diff --git a/internal/web/service/tgbot/tgbot_invite_test.go b/internal/web/service/tgbot/tgbot_invite_test.go index 1207243e3..936e91c65 100644 --- a/internal/web/service/tgbot/tgbot_invite_test.go +++ b/internal/web/service/tgbot/tgbot_invite_test.go @@ -1,6 +1,7 @@ package tgbot import ( + "encoding/base64" "strings" "sync" "testing" @@ -222,3 +223,17 @@ func TestTgUserMentionEscapesName(t *testing.T) { t.Errorf("tgUserMention = %q, want %q", got, want) } } + +// The bot's handlers recover panics, so a claim that panicked with inviteClaimMu held left +// it locked for good and every later /start claim parked on a worker slot. +func TestInviteClaimReleasesTheLockWhenItPanics(t *testing.T) { + func() { + defer func() { _ = recover() }() + // No database is open here, so resolving the token panics inside the lock. + (&Tgbot{}).claimInvite(1, 1, base64.RawURLEncoding.EncodeToString([]byte("some-sub-id"))) + }() + if !inviteClaimMu.TryLock() { + t.Fatal("inviteClaimMu is still held after a claim panicked") + } + inviteClaimMu.Unlock() +}