Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 32 additions & 13 deletions pkg/instance/service/instance_service.go
Original file line number Diff line number Diff line change
Expand Up @@ -258,7 +258,7 @@ func (i instances) Connect(data *ConnectStruct, instance *instance_model.Instanc
if !isInstanceRunning {
i.loggerWrapper.GetLogger(instance.Id).LogInfo("[%s] Starting new client instance", instance.Id)

i.killChannel[instance.Id] = make(chan bool)
i.killChannel[instance.Id] = make(chan bool, 1)

clientData := &whatsmeow_service.ClientData{
Instance: instance,
Expand Down Expand Up @@ -411,21 +411,40 @@ func (i instances) Status(instance *instance_model.Instance) (*StatusStruct, err
}, nil
}

// ErrSessionAlreadyLoggedIn is returned when a QR code is requested for an
// instance that is already paired and online.
var ErrSessionAlreadyLoggedIn = errors.New("session already logged in")

// qrNeedsNewRuntime decides whether a QR request may start a client.
//
// A paired, logged-in instance must never get a second runtime: the two clients
// share a killChannel, so the spare one exhausts its QR cycle and tears down the
// live session, which then shows up as a disconnected instance holding an open
// WhatsApp socket. A client that exists but is not logged in is already in its
// QR cycle and is reused.
func qrNeedsNewRuntime(clientExists, loggedIn bool) (bool, error) {
if loggedIn {
return false, ErrSessionAlreadyLoggedIn
}

return !clientExists, nil
}

func (i instances) GetQr(instance *instance_model.Instance) (*QrcodeStruct, error) {
logger := i.loggerWrapper.GetLogger(instance.Id)
client := i.clientPointer[instance.Id]

// Se não há cliente ou o cliente está logado, precisamos iniciar um novo cliente
if client == nil || client.IsLoggedIn() {
if client != nil && client.IsLoggedIn() {
logger.LogInfo("[%s] Client is logged in, starting new instance for QR code", instance.Id)
} else {
logger.LogInfo("[%s] No client found, starting new instance for QR code", instance.Id)
}
startRuntime, err := qrNeedsNewRuntime(client != nil, client != nil && client.IsLoggedIn())
if err != nil {
logger.LogInfo("[%s] QR requested for a logged-in session; keeping the current runtime", instance.Id)
return nil, err
}

if startRuntime {
logger.LogInfo("[%s] No client found, starting new instance for QR code", instance.Id)

// Iniciar nova instância para gerar QR code
err := i.whatsmeowService.StartInstance(instance.Id)
if err != nil {
if err = i.whatsmeowService.StartInstance(instance.Id); err != nil {
logger.LogError("[%s] Failed to start instance: %v", instance.Id, err)
return nil, fmt.Errorf("failed to start instance: %w", err)
}
Expand All @@ -437,15 +456,15 @@ func (i instances) GetQr(instance *instance_model.Instance) (*QrcodeStruct, erro
// Verificar novamente se há cliente
client = i.clientPointer[instance.Id]
if client != nil && client.IsLoggedIn() {
return nil, fmt.Errorf("session already logged in")
return nil, ErrSessionAlreadyLoggedIn
}
} else if !client.IsConnected() {
// Se o cliente existe mas não está conectado, pode estar aguardando QR code
logger.LogInfo("[%s] Client exists but not connected, checking for existing QR code", instance.Id)
}

// Buscar instância atualizada do banco para pegar o QR code mais recente
instance, err := i.instanceRepository.GetInstanceByID(instance.Id)
instance, err = i.instanceRepository.GetInstanceByID(instance.Id)
if err != nil {
return nil, err
}
Expand Down Expand Up @@ -713,7 +732,7 @@ func (i instances) ForceReconnect(instanceId string, number string) error {

subscribedEvents := strings.Split(instance.Events, ",")

i.killChannel[instance.Id] = make(chan bool)
i.killChannel[instance.Id] = make(chan bool, 1)

clientData := &whatsmeow_service.ClientData{
Instance: instance,
Expand Down
32 changes: 32 additions & 0 deletions pkg/instance/service/qr_runtime_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
package instance_service

import (
"errors"
"testing"
)

func TestQrNeedsNewRuntime(t *testing.T) {
cases := []struct {
name string
exists bool
loggedIn bool
wantStart bool
wantErrorIs error
}{
{name: "no client at all starts one", exists: false, wantStart: true},
{name: "client waiting for QR reuses it", exists: true, wantStart: false},
{name: "logged in client is never restarted", exists: true, loggedIn: true, wantErrorIs: ErrSessionAlreadyLoggedIn},
}

for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
start, err := qrNeedsNewRuntime(tc.exists, tc.loggedIn)
if !errors.Is(err, tc.wantErrorIs) {
t.Fatalf("error = %v, want %v", err, tc.wantErrorIs)
}
if start != tc.wantStart {
t.Fatalf("start = %v, want %v", start, tc.wantStart)
}
})
}
}
61 changes: 61 additions & 0 deletions pkg/whatsmeow/service/lifecycle_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
package whatsmeow_service

import (
"fmt"
"sync"
"sync/atomic"
"testing"
"time"
)

func TestCanAutoReconnectRequiresPairedJID(t *testing.T) {
for _, jid := range []string{"", " ", " "} {
if canAutoReconnect(jid) {
t.Fatalf("unpaired jid %q must not auto-reconnect", jid)
}
}
if !canAutoReconnect("paired-device@s.whatsapp.net") {
t.Fatal("paired jid must auto-reconnect")
}
}

func TestReserveReconnectDeduplicatesConcurrentAttempts(t *testing.T) {
service := &whatsmeowService{reconnectState: make(map[string]*instanceReconnectState)}
var accepted atomic.Int32
var wg sync.WaitGroup
for attempt := 0; attempt < 100; attempt++ {
wg.Add(1)
go func() {
defer wg.Done()
if _, ok := service.reserveReconnect("tenant-2"); ok {
accepted.Add(1)
}
}()
}
wg.Wait()
if got := accepted.Load(); got != 1 {
t.Fatalf("expected exactly one concurrent reconnect, got %d", got)
}

service.finishReconnect("tenant-2", false)
if _, ok := service.reserveReconnect("tenant-2"); !ok {
t.Fatal("a new retry must be accepted after the prior attempt finishes")
}
}

func TestReconnectDelayIsBoundedAndJitteredAcrossTwentyTenants(t *testing.T) {
seen := map[time.Duration]struct{}{}
for tenant := 1; tenant <= 20; tenant++ {
delay := reconnectDelay(fmt.Sprintf("tenant-%d", tenant), 0)
if delay < 2*time.Second || delay >= 2400*time.Millisecond {
t.Fatalf("tenant %d first delay out of bounds: %s", tenant, delay)
}
seen[delay] = struct{}{}
}
if len(seen) < 5 {
t.Fatalf("expected retry jitter across tenants, got only %d distinct delays", len(seen))
}
if delay := reconnectDelay("tenant-20", 1000); delay < 5*time.Minute || delay >= 6*time.Minute {
t.Fatalf("capped retry delay out of bounds: %s", delay)
}
}
78 changes: 78 additions & 0 deletions pkg/whatsmeow/service/runtime_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
package whatsmeow_service

import (
"sync"
"sync/atomic"
"testing"
)

func TestReserveRuntimeAllowsOneRuntimePerInstance(t *testing.T) {
service := &whatsmeowService{runtimeToken: make(map[string]uint64)}

var accepted atomic.Int32
var wg sync.WaitGroup
for attempt := 0; attempt < 100; attempt++ {
wg.Add(1)
go func() {
defer wg.Done()
if _, ok := service.reserveRuntime("tenant-7"); ok {
accepted.Add(1)
}
}()
}
wg.Wait()

if got := accepted.Load(); got != 1 {
t.Fatalf("expected exactly one runtime per instance, got %d", got)
}
}

func TestReserveRuntimeIsPerInstance(t *testing.T) {
service := &whatsmeowService{runtimeToken: make(map[string]uint64)}

if _, ok := service.reserveRuntime("tenant-a"); !ok {
t.Fatal("first instance must be able to start")
}
if _, ok := service.reserveRuntime("tenant-b"); !ok {
t.Fatal("a different instance must not be blocked by another one's runtime")
}
}

func TestReleaseRuntimeAllowsRestart(t *testing.T) {
service := &whatsmeowService{runtimeToken: make(map[string]uint64)}

token, ok := service.reserveRuntime("tenant-7")
if !ok {
t.Fatal("first reservation must succeed")
}
service.releaseRuntime("tenant-7", token)

if _, ok := service.reserveRuntime("tenant-7"); !ok {
t.Fatal("a new runtime must start once the previous one is released")
}
}

// A late cleanup from a dead runtime must not free the reservation of the one
// that took its place — that is how a second concurrent client gets in.
func TestReleaseRuntimeIgnoresStaleToken(t *testing.T) {
service := &whatsmeowService{runtimeToken: make(map[string]uint64)}

stale, _ := service.reserveRuntime("tenant-7")
service.releaseRuntime("tenant-7", stale)

current, ok := service.reserveRuntime("tenant-7")
if !ok {
t.Fatal("replacement runtime must be able to reserve")
}

service.releaseRuntime("tenant-7", stale)

if _, ok := service.reserveRuntime("tenant-7"); ok {
t.Fatal("stale release freed the live runtime's reservation")
}

service.releaseRuntime("tenant-7", current)
if _, ok := service.reserveRuntime("tenant-7"); !ok {
t.Fatal("current token must still release the reservation")
}
}
Loading