Skip to content

Commit 36e10f5

Browse files
committed
fix(client): improve thread handling and stop mechanism for Neonize connections
1 parent 80828a8 commit 36e10f5

1 file changed

Lines changed: 119 additions & 20 deletions

File tree

neonize/client.py

Lines changed: 119 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
from functools import partial
1414
from io import BytesIO
1515
from os import urandom
16-
from threading import Thread
16+
from threading import Event as ThreadEvent, Thread
1717
from types import NoneType
1818
from typing import List, Optional, Sequence, overload
1919
from uuid import uuid4
@@ -33,7 +33,7 @@
3333
gocode,
3434
)
3535
from .builder import build_edit, build_revoke
36-
from .events import Event, EventsManager
36+
from .events import Event, EventsManager, event as stop_event
3737
from .exc import (
3838
BuildPollVoteCreationError,
3939
BuildPollVoteError,
@@ -3162,7 +3162,7 @@ def stop(self):
31623162
Stops the client by disconnecting it from the WhatsApp servers.
31633163
"""
31643164
_log_.debug("Stopping client and disconnecting from WhatsApp servers.")
3165-
self.__client.stop(self.uuid)
3165+
self.__client.Stop(self.uuid)
31663166

31673167
def get_message_for_retry(
31683168
self, requester: JID, to: JID, message_id: str
@@ -3312,25 +3312,88 @@ def connect_with_proxy(self, proxy_settings: ProxySettings | None = None):
33123312
c_settings = proxy_settings._to_c_struct()
33133313
proxy_ref = ctypes.byref(c_settings)
33143314

3315-
# Initiate connection to the server
3316-
err = self.__client.Neonize(
3317-
self.name.encode(),
3318-
self.uuid,
3319-
jidbuf,
3320-
jidbuf_size,
3321-
LogLevel.from_logging(log.level).level,
3315+
# Keep the ctypes callback wrappers and the subscriber buffer referenced
3316+
# for the whole lifetime of the connection. ``Neonize`` runs on a worker
3317+
# thread (below) and Go invokes these callbacks throughout the session;
3318+
# if they were mere temporaries they could be garbage-collected mid-call.
3319+
subscriber_buf = (ctypes.c_char * len(self.event.list_func)).from_buffer(d)
3320+
self._connect_refs = (
33223321
func_string(self.__onQr),
33233322
func_string(self.__onLoginStatus),
33243323
func_callback_bytes(self.event.execute),
33253324
func_callback_bytes2(log_whatsmeow),
3326-
(ctypes.c_char * len(self.event.list_func)).from_buffer(d),
3327-
len(d),
3328-
deviceprops,
3329-
len(deviceprops),
3330-
b"",
3331-
0,
3332-
proxy_ref,
3325+
subscriber_buf,
3326+
d,
33333327
)
3328+
qr_cb, login_cb, event_cb, log_cb, subscriber_buf, _ = self._connect_refs
3329+
3330+
# ``Neonize`` is a blocking Go call that only returns once the Go-side
3331+
# context is cancelled (via ``Stop``). Running it directly on the caller
3332+
# thread would trap that thread inside the C call, so CPython could never
3333+
# run signal handlers or raise ``KeyboardInterrupt`` -- Ctrl+C would be
3334+
# ignored. Instead we run it on a worker thread and wait interruptibly on
3335+
# the caller thread; on Ctrl+C (or the global stop event) we cancel Go so
3336+
# ``Neonize`` returns and the worker exits cleanly.
3337+
holder: typing.Dict[str, typing.Any] = {}
3338+
# Set the instant Neonize() returns. This -- not worker.is_alive() -- is
3339+
# the source of truth for "the Go call finished": when a KeyboardInterrupt
3340+
# interrupts Thread.join(), CPython can leave is_alive() wrongly reporting
3341+
# False for a still-running thread, whereas an Event flag is never
3342+
# corrupted by the interrupt.
3343+
done = ThreadEvent()
3344+
3345+
def _worker():
3346+
try:
3347+
holder["err"] = self.__client.Neonize(
3348+
self.name.encode(),
3349+
self.uuid,
3350+
jidbuf,
3351+
jidbuf_size,
3352+
LogLevel.from_logging(log.level).level,
3353+
qr_cb,
3354+
login_cb,
3355+
event_cb,
3356+
log_cb,
3357+
subscriber_buf,
3358+
len(d),
3359+
deviceprops,
3360+
len(deviceprops),
3361+
b"",
3362+
0,
3363+
proxy_ref,
3364+
)
3365+
except BaseException as exc: # noqa: BLE001 - surfaced on caller thread
3366+
holder["exc"] = exc
3367+
finally:
3368+
done.set()
3369+
3370+
worker = Thread(target=_worker, name=self.uuid.decode(), daemon=False)
3371+
worker.start()
3372+
try:
3373+
# Short timeouts return control to the interpreter periodically, so a
3374+
# pending signal / KeyboardInterrupt can fire on this thread instead
3375+
# of being trapped behind the blocking Go call on the worker thread.
3376+
while not done.is_set():
3377+
if stop_event.is_set():
3378+
break
3379+
done.wait(0.2)
3380+
except KeyboardInterrupt:
3381+
pass # clean shutdown, no traceback
3382+
finally:
3383+
if not done.is_set():
3384+
self.stop() # cancel the Go context so Neonize() returns
3385+
# Drain the worker, absorbing any further interrupts, until Neonize
3386+
# has actually returned.
3387+
while not done.is_set():
3388+
try:
3389+
done.wait(0.2)
3390+
except KeyboardInterrupt:
3391+
self.stop()
3392+
worker.join()
3393+
3394+
if "exc" in holder:
3395+
raise holder["exc"]
3396+
err = holder.get("err")
33343397
if err:
33353398
raise NeonizeError(err.decode())
33363399

@@ -3411,10 +3474,46 @@ def new_client(
34113474
self.clients.append(client)
34123475
return client
34133476

3477+
def stop(self):
3478+
"""Stop every client managed by this factory.
3479+
3480+
Cancels the Go-side context of all running clients at once so their
3481+
blocking ``Neonize`` calls return and the worker threads can exit.
3482+
"""
3483+
gocode.StopAll()
3484+
34143485
def run(self):
3486+
"""Connect every client and block until interrupted.
3487+
3488+
Each client's blocking ``connect`` runs on its own (non-daemon) worker
3489+
thread, while this call blocks the caller thread interruptibly. On Ctrl+C
3490+
(or when the global stop event is set) all clients are stopped and every
3491+
worker thread is joined before returning, so shutdown is clean.
3492+
"""
3493+
threads = []
34153494
for client in self.clients:
3416-
Thread(
3495+
t = Thread(
34173496
target=client.connect,
3418-
daemon=True,
3497+
daemon=False,
34193498
name=client.uuid.decode(),
3420-
).start()
3499+
)
3500+
t.start()
3501+
threads.append(t)
3502+
try:
3503+
while any(t.is_alive() for t in threads):
3504+
if stop_event.is_set():
3505+
break
3506+
for t in threads:
3507+
t.join(0.2)
3508+
except KeyboardInterrupt:
3509+
pass
3510+
finally:
3511+
self.stop() # StopAll: cancel every client's Go context
3512+
# Drain all worker threads, absorbing any further interrupts so a
3513+
# repeated Ctrl+C can't leave a client's Go call running.
3514+
for t in threads:
3515+
while t.is_alive():
3516+
try:
3517+
t.join(0.2)
3518+
except KeyboardInterrupt:
3519+
self.stop()

0 commit comments

Comments
 (0)