Merge branch 'master' into f/directory-integration-plan

This commit is contained in:
spaced4ndy
2026-09-22 20:32:41 +04:00
224 changed files with 5983 additions and 4135 deletions
@@ -16,7 +16,7 @@ import urllib.request
import zipfile
from ctypes import POINTER, c_char_p, c_int, c_uint8, c_void_p
from pathlib import Path
from typing import Literal
from typing import Any, Literal
from ._version import LIBS_VERSION
@@ -166,7 +166,8 @@ _backend: Backend | None = None
def _load_libc() -> ctypes.CDLL:
if sys.platform == "win32":
return ctypes.CDLL("msvcrt")
# libsimplex.dll allocates results with UCRT malloc; msvcrt free would corrupt the heap.
return ctypes.CDLL("ucrtbase")
return ctypes.CDLL(None) # libc on POSIX is the process's own symbol table
@@ -255,3 +256,19 @@ def lib() -> ctypes.CDLL:
if _lib is None:
raise RuntimeError("lib_for() must be called before lib()")
return _lib
QUEUE_SIZE_UNSUPPORTED = (
"loaded libsimplex does not export chat_migrate_init_queue; queue size needs a newer libsimplex"
)
def migrate_init_queue() -> Any:
"""`chat_migrate_init_queue`, which older libsimplex releases do not export."""
try:
fn = lib().chat_migrate_init_queue
except AttributeError as e:
raise RuntimeError(QUEUE_SIZE_UNSUPPORTED) from e
fn.argtypes = [c_char_p, c_char_p, c_char_p, c_int, POINTER(c_void_p)]
fn.restype = c_void_p
return fn
@@ -5,5 +5,5 @@ Bump both together for normal releases. For wrapper-only fixes use a PEP 440
post-release: __version__ = "6.5.2.post1", LIBS_VERSION unchanged.
"""
__version__ = "7.1.0b3" # PEP 440 — read by hatchling for wheel metadata
LIBS_VERSION = "7.1.0-beta.3" # simplex-chat-libs release tag (no 'v' prefix)
__version__ = "7.1.0b4" # PEP 440 — read by hatchling for wheel metadata
LIBS_VERSION = "7.1.0-beta.4" # simplex-chat-libs release tag (no 'v' prefix)
@@ -2,7 +2,9 @@
from __future__ import annotations
import asyncio
import json
from concurrent.futures import ThreadPoolExecutor
from dataclasses import dataclass
from typing import Any, Literal
@@ -65,17 +67,20 @@ class ChatApi:
def __init__(self, ctrl: int):
self._ctrl: int | None = ctrl
self._started = False
self._recv_executor: ThreadPoolExecutor | None = None
@classmethod
async def init(
cls,
db: Db,
confirm: MigrationConfirmation = MigrationConfirmation.YES_UP,
queue_size: int | None = None,
) -> ChatApi:
path_or_prefix, key_or_conn, backend = _db_to_migrate_args(db)
# Trigger lazy lib load with the right backend BEFORE chat_migrate_init.
_native.lib_for(backend)
ctrl = await core.chat_migrate_init(path_or_prefix, key_or_conn, confirm)
# It may download ~100 MB, so it must not block the event loop.
await asyncio.to_thread(_native.lib_for, backend)
ctrl = await core.chat_migrate_init(path_or_prefix, key_or_conn, confirm, queue_size)
return cls(ctrl)
@property
@@ -114,6 +119,14 @@ class ChatApi:
self._started = False
async def close(self) -> None:
"""Stop the chat and close its store; the store stays open if stopping fails."""
# a running controller keeps using the database connections that closing frees
await self.stop_chat()
if self._recv_executor is not None:
# Waits for a receive already in flight (up to wait_us) so the store
# never closes underneath one; run off-loop since shutdown blocks.
await asyncio.to_thread(self._recv_executor.shutdown, wait=True)
self._recv_executor = None
await core.chat_close_store(self.ctrl)
self._ctrl = None
self._started = False
@@ -122,7 +135,14 @@ class ChatApi:
return await core.chat_send_cmd(self.ctrl, cmd)
async def recv_chat_event(self, wait_us: int = 500_000) -> CEvt.ChatEvent | None:
return await core.chat_recv_msg_wait(self.ctrl, wait_us)
ctrl = self.ctrl # raises before touching the executor if close() was called
if self._recv_executor is None:
# A receive blocks for up to wait_us almost back to back, so it would
# otherwise pin one of the default executor's few worker threads.
self._recv_executor = ThreadPoolExecutor(
max_workers=1, thread_name_prefix="simplex-recv"
)
return await core.chat_recv_msg_wait(ctrl, wait_us, self._recv_executor)
# ------------------------------------------------------------------ #
# Address commands
@@ -158,6 +178,8 @@ class ChatApi:
)
if r["type"] == "userProfileUpdated":
return r["updateSummary"]
if r["type"] == "userProfileNoChange":
return {"updateSuccesses": 0, "updateFailures": 0, "changedContacts": []}
raise ChatCommandError("error setting profile address", r)
async def api_set_address_settings(self, user_id: int, settings: T.AddressSettings) -> None:
@@ -236,6 +258,8 @@ class ChatApi:
)
if r["type"] == "chatItemUpdated":
return r["chatItem"]["chatItem"]
if r["type"] == "chatItemNotChanged":
return r["chatItem"]["chatItem"]
raise ChatCommandError("error updating chat item", r)
async def api_delete_chat_items(
@@ -302,6 +326,8 @@ class ChatApi:
)
if r["type"] == "rcvFileAccepted":
return r["chatItem"]
if r["type"] == "rcvFileAcceptedSndCancelled":
raise ChatCommandError("file cancelled by sender", r)
raise ChatCommandError("error receiving file", r)
async def api_cancel_file(self, file_id: int) -> None:
@@ -477,12 +503,13 @@ class ChatApi:
self,
user_id: int,
incognito: bool,
prepared_link: T.CreatedConnLink | None = None,
prepared_link: T.CreatedConnLink,
) -> ConnReqType:
args: CC.APIConnect = {"userId": user_id, "incognito": incognito}
if prepared_link is not None:
args["preparedLink_"] = prepared_link
r = await self.send_chat_cmd(CC.APIConnect_cmd_string(args))
r = await self.send_chat_cmd(
CC.APIConnect_cmd_string(
{"userId": user_id, "incognito": incognito, "preparedLink_": prepared_link}
)
)
return self._handle_connect_result(r)
async def api_connect_active_user(self, conn_link: str) -> ConnReqType:
@@ -90,6 +90,7 @@ class Bot(Client):
welcome: str | T.MsgContent | None = None,
commands: list[BotCommand] | None = None,
confirm_migrations: MigrationConfirmation = MigrationConfirmation.YES_UP,
queue_size: int | None = None,
create_address: bool = True,
update_address: bool = True,
update_profile: bool = True,
@@ -103,6 +104,7 @@ class Bot(Client):
profile=profile,
db=db,
confirm_migrations=confirm_migrations,
queue_size=queue_size,
update_profile=update_profile,
log_contacts=log_contacts,
log_network=log_network,
@@ -149,6 +149,7 @@ class Client:
profile: Profile,
db: Db,
confirm_migrations: MigrationConfirmation = MigrationConfirmation.YES_UP,
queue_size: int | None = None,
update_profile: bool = True,
log_contacts: bool = False,
log_network: bool = False,
@@ -156,6 +157,7 @@ class Client:
self._profile = profile
self._db = db
self._confirm_migrations = confirm_migrations
self._queue_size = queue_size
self._update_profile = update_profile
self._log_contacts = log_contacts
self._log_network = log_network
@@ -343,7 +345,7 @@ class Client:
# do post-start setup (profile sync; Bot adds address sync).
# `_stop_event` is never cleared: a stop requested during startup has
# to survive into the receive loop. A stopped client is spent.
self._api = await ChatApi.init(self._db, self._confirm_migrations)
self._api = await ChatApi.init(self._db, self._confirm_migrations, self._queue_size)
try:
user = await self._ensure_active_user()
await self._api.start_chat()
@@ -362,11 +364,6 @@ class Client:
api = self._api
if api is None:
return
if api.started:
try:
await api.stop_chat()
except Exception:
log.exception("stop_chat failed during init rollback")
try:
await api.close()
except Exception:
@@ -379,13 +376,16 @@ class Client:
if api is None:
return
# Null out the reference up-front so the Client appears closed even
# if stop_chat / close raise — otherwise `client.api` would still
# if close raises — otherwise `client.api` would still
# hand back a half-shutdown controller after `async with` exits.
self._api = None
try:
await api.stop_chat()
finally:
await api.close()
except BaseException:
# A failed stop leaves the store open; keep it so the caller can retry.
if api.initialized:
self._api = api
raise
async def _post_start(self, user: T.User) -> None:
"""Hook for subclasses to add work between `start_chat` and serving.
@@ -617,7 +617,7 @@ class Client:
# message resolve a future no one is waiting on.
if waiter in waiters:
waiters.remove(waiter)
if not waiters:
if not waiters and self._reply_waiters.get(contact_id) is waiters:
self._reply_waiters.pop(contact_id, None)
async def _receive_loop(self) -> None:
@@ -9,6 +9,7 @@ from __future__ import annotations
import asyncio
import ctypes
import json
from concurrent.futures import Executor
from enum import StrEnum
from typing import Any, TypedDict
@@ -102,7 +103,9 @@ async def chat_send_cmd(ctrl: int, cmd: str) -> CR.ChatResponse:
raise ChatAPIError(f"invalid chat command result: {raw[:200]}")
async def chat_recv_msg_wait(ctrl: int, wait_us: int = 500_000) -> CEvt.ChatEvent | None:
async def chat_recv_msg_wait(
ctrl: int, wait_us: int = 500_000, executor: Executor | None = None
) -> CEvt.ChatEvent | None:
def _call() -> str:
# On timeout, the C side returns a non-NULL pointer to a single NUL byte
# (see Mobile.hs `fromMaybe ""`), so `_read_and_free` returns "" — no
@@ -110,7 +113,10 @@ async def chat_recv_msg_wait(ctrl: int, wait_us: int = 500_000) -> CEvt.ChatEven
ptr = _native.lib().chat_recv_msg_wait(ctrl, wait_us)
return _read_and_free(ptr)
raw = await asyncio.to_thread(_call)
if executor is None:
raw = await asyncio.to_thread(_call)
else:
raw = await asyncio.get_running_loop().run_in_executor(executor, _call)
if not raw:
return None
parsed = json.loads(raw)
@@ -122,17 +128,29 @@ async def chat_recv_msg_wait(ctrl: int, wait_us: int = 500_000) -> CEvt.ChatEven
raise ChatAPIError(f"invalid chat event: {raw[:200]}")
async def chat_migrate_init(db_path: str, db_key: str, confirm: MigrationConfirmation) -> int:
"""Initialize chat controller. Returns opaque ctrl pointer as Python int."""
async def chat_migrate_init(
db_path: str,
db_key: str,
confirm: MigrationConfirmation,
queue_size: int | None = None,
) -> int:
"""Initialize chat controller. Returns opaque ctrl pointer as Python int.
`queue_size` is the size of internal queues; the core default is used when None.
"""
# ctypes silently wraps ints that do not fit C int.
if queue_size is not None and ctypes.c_int(queue_size).value != queue_size:
raise ValueError(f"queue_size {queue_size} does not fit C int")
init_queue = _native.migrate_init_queue() if queue_size is not None else None
def _call() -> tuple[int, str]:
ctrl = ctypes.c_void_p()
ptr = _native.lib().chat_migrate_init(
db_path.encode("utf-8"),
db_key.encode("utf-8"),
confirm.encode("utf-8"),
ctypes.byref(ctrl),
)
args = (db_path.encode("utf-8"), db_key.encode("utf-8"), confirm.encode("utf-8"))
if init_queue is None:
ptr = _native.lib().chat_migrate_init(*args, ctypes.byref(ctrl))
else:
ptr = init_queue(*args, queue_size, ctypes.byref(ctrl))
return (ctrl.value or 0, _read_and_free(ptr))
ctrl_val, raw = await asyncio.to_thread(_call)
@@ -498,7 +498,7 @@ class APIConnect(TypedDict):
def APIConnect_cmd_string(self: APIConnect) -> str:
return '/_connect ' + str(self['userId']) + ((' ' + T.CreatedConnLink_cmd_string(self.get('preparedLink_'))) if self.get('preparedLink_') is not None else '')
return '/_connect ' + str(self['userId']) + (' incognito=on' if self['incognito'] else '') + ((' ' + T.CreatedConnLink_cmd_string(self.get('preparedLink_'))) if self.get('preparedLink_') is not None else '')
APIConnect_Response = CR.SentConfirmation | CR.ContactAlreadyExists | CR.SentInvitation | CR.ChatCmdError
@@ -220,83 +220,7 @@ BadgeRedeemError = (
BadgeRedeemError_Tag = Literal["invalidCode", "serviceNotConfigured", "badgeActive", "serviceError", "invalidResponse", "unknownKeyIndex", "credentialNotVerified"]
class BadgeServiceErrorCode_badRequest(TypedDict):
type: Literal["badRequest"]
class BadgeServiceErrorCode_unsupportedVersion(TypedDict):
type: Literal["unsupportedVersion"]
class BadgeServiceErrorCode_unknownPurchaseKey(TypedDict):
type: Literal["unknownPurchaseKey"]
class BadgeServiceErrorCode_unknownOfferId(TypedDict):
type: Literal["unknownOfferId"]
class BadgeServiceErrorCode_offerDisabled(TypedDict):
type: Literal["offerDisabled"]
class BadgeServiceErrorCode_offerMismatch(TypedDict):
type: Literal["offerMismatch"]
class BadgeServiceErrorCode_productUnavailable(TypedDict):
type: Literal["productUnavailable"]
class BadgeServiceErrorCode_paymentNotEntitled(TypedDict):
type: Literal["paymentNotEntitled"]
class BadgeServiceErrorCode_paymentPending(TypedDict):
type: Literal["paymentPending"]
class BadgeServiceErrorCode_providerUnavailable(TypedDict):
type: Literal["providerUnavailable"]
class BadgeServiceErrorCode_rateLimited(TypedDict):
type: Literal["rateLimited"]
class BadgeServiceErrorCode_codeInvalid(TypedDict):
type: Literal["codeInvalid"]
class BadgeServiceErrorCode_codeUsed(TypedDict):
type: Literal["codeUsed"]
class BadgeServiceErrorCode_codeExpired(TypedDict):
type: Literal["codeExpired"]
class BadgeServiceErrorCode_receiptInvalid(TypedDict):
type: Literal["receiptInvalid"]
class BadgeServiceErrorCode_receiptUsed(TypedDict):
type: Literal["receiptUsed"]
class BadgeServiceErrorCode_internal(TypedDict):
type: Literal["internal"]
class BadgeServiceErrorCode_unknown(TypedDict):
type: Literal["unknown"]
: str
BadgeServiceErrorCode = (
BadgeServiceErrorCode_badRequest
| BadgeServiceErrorCode_unsupportedVersion
| BadgeServiceErrorCode_unknownPurchaseKey
| BadgeServiceErrorCode_unknownOfferId
| BadgeServiceErrorCode_offerDisabled
| BadgeServiceErrorCode_offerMismatch
| BadgeServiceErrorCode_productUnavailable
| BadgeServiceErrorCode_paymentNotEntitled
| BadgeServiceErrorCode_paymentPending
| BadgeServiceErrorCode_providerUnavailable
| BadgeServiceErrorCode_rateLimited
| BadgeServiceErrorCode_codeInvalid
| BadgeServiceErrorCode_codeUsed
| BadgeServiceErrorCode_codeExpired
| BadgeServiceErrorCode_receiptInvalid
| BadgeServiceErrorCode_receiptUsed
| BadgeServiceErrorCode_internal
| BadgeServiceErrorCode_unknown
)
BadgeServiceErrorCode_Tag = Literal["badRequest", "unsupportedVersion", "unknownPurchaseKey", "unknownOfferId", "offerDisabled", "offerMismatch", "productUnavailable", "paymentNotEntitled", "paymentPending", "providerUnavailable", "rateLimited", "codeInvalid", "codeUsed", "codeExpired", "receiptInvalid", "receiptUsed", "internal", "unknown"]
BadgeServiceErrorCode = Literal["bad_request", "unsupported_version", "unknown_purchase_key", "unknown_offer_id", "offer_disabled", "offer_mismatch", "product_unavailable", "payment_not_entitled", "payment_pending", "provider_unavailable", "rate_limited", "code_invalid", "code_used", "code_expired", "receipt_invalid", "receipt_used", "internal"]
BadgeStatus = Literal["active", "expired", "expiredOld", "failed", "unknownKey"]
@@ -2031,13 +1955,8 @@ class GroupInfo(TypedDict):
rosterVersion: NotRequired[int] # int64
membersRequireAttention: int # int
viaGroupLinkUri: NotRequired[str]
groupKeys: NotRequired["GroupKeys"]
groupDomainVerified: NotRequired[bool]
class GroupKeys(TypedDict):
publicGroupKeys: NotRequired["PublicGroupKeys"]
memberPrivKey: str
class GroupLink(TypedDict):
userContactLinkId: int # int64
connLinkContact: "CreatedConnLink"
@@ -2172,18 +2091,6 @@ class GroupRelay(TypedDict):
relayLink: NotRequired[str]
relayCap: "RelayCapabilities"
class GroupRootKey_private(TypedDict):
type: Literal["private"]
rootPrivKey: str
class GroupRootKey_public(TypedDict):
type: Literal["public"]
rootPubKey: str
GroupRootKey = GroupRootKey_private | GroupRootKey_public
GroupRootKey_Tag = Literal["private", "public"]
class GroupShortLinkData(TypedDict):
groupProfile: "GroupProfile"
publicGroupData: NotRequired["PublicGroupData"]
@@ -2626,10 +2533,6 @@ class PublicGroupAccess(TypedDict):
class PublicGroupData(TypedDict):
publicMemberCount: int # int64
class PublicGroupKeys(TypedDict):
publicGroupId: str
groupRootKey: "GroupRootKey"
class PublicGroupProfile(TypedDict):
groupType: "GroupType"
groupLink: str
@@ -101,7 +101,7 @@ def ci_content_text(chat_item: T.ChatItem) -> str | None:
return None
_BOT_COMMAND_RE = re.compile(r"^/([^\s]+)(.*)$")
_BOT_COMMAND_RE = re.compile(r"^/([^\s]+)(.*)$", re.DOTALL)
def ci_bot_command(chat_item: T.ChatItem) -> tuple[str, str] | None:
@@ -7,6 +7,7 @@ shape it accepts.
from __future__ import annotations
import asyncio
from typing import Any
import pytest
@@ -172,3 +173,46 @@ async def test_a_failed_custom_data_write_raises():
api = FakeCtrl({"type": "chatCmdError"})
with pytest.raises(ChatCommandError):
await api.api_merge_group_custom_data({"groupId": 9}, "mine", 1)
# ---------------------------------------------------------------------- #
# Documented success responses
# ---------------------------------------------------------------------- #
def test_update_chat_item_accepts_not_changed():
chat_item = {"meta": {"itemId": 2}}
api = FakeCtrl({"type": "chatItemNotChanged", "chatItem": {"chatItem": chat_item}})
msg_content = {"type": "text", "text": "same"}
assert asyncio.run(api.api_update_chat_item("direct", 1, 2, msg_content)) == chat_item
def test_set_profile_address_accepts_no_change():
api = FakeCtrl({"type": "userProfileNoChange"})
summary = asyncio.run(api.api_set_profile_address(1, True))
assert summary == {"updateSuccesses": 0, "updateFailures": 0, "changedContacts": []}
def test_receive_file_reports_cancelled_by_sender():
api = FakeCtrl({"type": "rcvFileAcceptedSndCancelled", "rcvFileTransfer": {}})
with pytest.raises(ChatCommandError, match="file cancelled by sender"):
asyncio.run(api.api_receive_file(3))
async def test_init_loads_library_off_the_event_loop(monkeypatch):
import threading
from simplex_chat import _native, core
from simplex_chat.api import SqliteDb
threads: list[int] = []
monkeypatch.setattr(_native, "lib_for", lambda _backend: threads.append(threading.get_ident()))
async def fake_migrate_init(*_args):
return 1
monkeypatch.setattr(core, "chat_migrate_init", fake_migrate_init)
loop_thread = threading.get_ident()
await ChatApi.init(SqliteDb(file_prefix="/tmp/unused"))
assert threads and threads[0] != loop_thread
@@ -14,11 +14,13 @@ import pytest
from simplex_chat import (
Bot,
BotProfile,
ChatCommandError,
Client,
ContactAlreadyExistsError,
Profile,
SqliteDb,
)
from simplex_chat.core import MigrationConfirmation
class FakeApi:
@@ -292,6 +294,31 @@ def test_send_and_wait_parallel_different_contacts():
assert (a, b) == ("A", "B")
def test_send_and_wait_keeps_waiter_registered_during_previous_cleanup():
bot, _api = _bot_with_fake_api()
def reply(text: str) -> dict[str, Any]:
return {"type": "newChatItems", "chatItems": [
{
"chatInfo": {"type": "direct", "contact": {"contactId": 42}},
"chatItem": {"content": {"type": "rcvMsgContent", "msgContent": {"type": "text", "text": text}}},
}
]}
async def go() -> tuple[str, str]:
first = asyncio.create_task(bot.send_and_wait(42, "a", timeout=2.0))
await asyncio.sleep(0)
# Created before the reply is dispatched, so it registers before `first` runs its cleanup.
second = asyncio.create_task(bot.send_and_wait(42, "b", timeout=2.0))
await bot._dispatch_event(reply("ra")) # type: ignore[arg-type]
await asyncio.sleep(0)
await asyncio.sleep(0)
await bot._dispatch_event(reply("rb")) # type: ignore[arg-type]
return (await first).text or "", (await second).text or ""
assert asyncio.run(go()) == ("ra", "rb")
# ---------------------------------------------------------------------------
# connect_to
# ---------------------------------------------------------------------------
@@ -466,6 +493,10 @@ def test_aexit_nulls_api_even_if_close_raises(monkeypatch):
async def stop_chat(self):
pass
@property
def initialized(self):
return False
async def close(self):
raise RuntimeError("close failed")
@@ -498,6 +529,83 @@ def test_aexit_nulls_api_even_if_close_raises(monkeypatch):
asyncio.run(go())
def test_aexit_keeps_api_for_retry_when_stop_fails(monkeypatch):
"""A failed stop leaves the store open, so the Client must keep the
controller: dropping it would leak the store with no way to close it."""
import simplex_chat.client as client_mod
stop_results = ["chatCmdError", "chatStopped"]
closed: list[bool] = [False]
class _FailingStopApi:
@classmethod
async def init(cls, *_a, **_kw):
return cls()
@property
def initialized(self):
return not closed[0]
async def start_chat(self):
pass
async def close(self):
response = stop_results.pop(0)
if response != "chatStopped":
raise ChatCommandError("error stopping chat", {"type": response})
closed[0] = True
async def api_get_active_user(self):
return {"userId": 1, "profile": {"displayName": "x"}}
async def send_chat_cmd(self, _cmd):
return {"type": "cmdOk"}
monkeypatch.setattr(client_mod, "ChatApi", _FailingStopApi)
c = Client(profile=Profile(display_name="x"), db=SqliteDb(file_prefix="/tmp/test"))
async def go():
with pytest.raises(ChatCommandError, match="error stopping chat"):
async with c:
pass
assert c._api is not None, "controller dropped while its store is still open"
assert closed == [False]
await c.__aexit__(None, None, None)
assert closed == [True]
assert c._api is None
asyncio.run(go())
@pytest.mark.parametrize("queue_size", [None, 65536])
def test_bot_passes_queue_size_to_chat_api_init(monkeypatch, queue_size):
import simplex_chat.client as client_mod
init_args: list[tuple[Any, ...]] = []
class StopInit(RuntimeError):
pass
class FakeChatApi:
@classmethod
async def init(cls, *args):
init_args.append(args)
raise StopInit
monkeypatch.setattr(client_mod, "ChatApi", FakeChatApi)
db = SqliteDb(file_prefix="/tmp/test")
bot = Bot(profile=BotProfile(display_name="x"), db=db, queue_size=queue_size)
async def go():
with pytest.raises(StopInit):
async with bot:
pytest.fail("should not enter the with-block")
asyncio.run(go())
assert init_args == [(db, MigrationConfirmation.YES_UP, queue_size)]
def test_aenter_rolls_back_partial_init_on_post_start_failure(monkeypatch):
"""If anything in __aenter__ raises after ChatApi.init succeeded — including
_post_start — the controller must be closed. Otherwise the with-block isn't
@@ -550,7 +658,7 @@ def test_aenter_rolls_back_partial_init_on_post_start_failure(monkeypatch):
pytest.fail("should not enter the with-block")
asyncio.run(go())
assert closed == ["stop", "close"], f"controller not cleaned up: {closed}"
assert closed == ["close"], f"controller not cleaned up: {closed}"
assert c._api is None, "Client._api should be reset to None after rollback"
@@ -39,3 +39,9 @@ def test_chat_ref_cmd_string_direct():
"""Sanity check the codegen fix for ChatRef-bearing commands."""
assert T.ChatRef_cmd_string({"chatType": "direct", "chatId": 7}) == "@7"
assert T.ChatRef_cmd_string({"chatType": "group", "chatId": 42}) == "#42"
def test_api_connect_cmd_string_renders_incognito():
link = {"connFullLink": "L"}
assert CC.APIConnect_cmd_string({"userId": 1, "incognito": True, "preparedLink_": link}) == "/_connect 1 incognito=on L"
assert CC.APIConnect_cmd_string({"userId": 1, "incognito": False, "preparedLink_": link}) == "/_connect 1 L"
@@ -0,0 +1,123 @@
"""core.chat_migrate_init picks the FFI export by queue_size, with a fake libsimplex."""
from __future__ import annotations
import asyncio
import json
from typing import Any
import pytest
from simplex_chat import core
from simplex_chat.core import ChatInitError, MigrationConfirmation
CTRL = 42
class FakeLib:
"""Records calls; each export writes CTRL to the out-param and returns the JSON result."""
def __init__(self, result: dict[str, Any]) -> None:
self.result = json.dumps(result)
self.calls: list[tuple[str, tuple[Any, ...]]] = []
def _init(self, name: str, args: tuple[Any, ...]) -> str:
*call_args, ctrl_ref = args
self.calls.append((name, tuple(call_args)))
ctrl_ref._obj.value = CTRL
return self.result
def chat_migrate_init(self, *args: Any) -> str:
return self._init("chat_migrate_init", args)
@property
def chat_migrate_init_queue(self) -> Any:
lib = self
class Fn:
argtypes: Any = None
restype: Any = None
def __call__(self, *args: Any) -> str:
return lib._init("chat_migrate_init_queue", args)
return Fn()
class OldLib(FakeLib):
"""A libsimplex released before chat_migrate_init_queue existed."""
def __getattribute__(self, name: str) -> Any:
if name == "chat_migrate_init_queue":
raise AttributeError(name)
return super().__getattribute__(name)
@pytest.fixture
def fake_lib(monkeypatch: pytest.MonkeyPatch):
def install(result: dict[str, Any]) -> FakeLib:
lib = FakeLib(result)
monkeypatch.setattr(core._native, "lib", lambda: lib)
monkeypatch.setattr(core, "_read_and_free", lambda ptr: ptr)
return lib
return install
def migrate_init(queue_size: int | None = None) -> int:
return asyncio.run(
core.chat_migrate_init("/tmp/db", "key", MigrationConfirmation.YES_UP, queue_size)
)
def test_without_queue_size_uses_chat_migrate_init(fake_lib):
lib = fake_lib({"type": "ok"})
assert migrate_init() == CTRL
assert lib.calls == [("chat_migrate_init", (b"/tmp/db", b"key", b"yesUp"))]
def test_with_queue_size_uses_chat_migrate_init_queue(fake_lib):
lib = fake_lib({"type": "ok"})
assert migrate_init(65536) == CTRL
assert lib.calls == [("chat_migrate_init_queue", (b"/tmp/db", b"key", b"yesUp", 65536))]
def test_invalid_queue_size_result_raises_init_error(fake_lib):
fake_lib({"type": "invalidQueueSize"})
with pytest.raises(ChatInitError) as e:
migrate_init(0)
assert e.value.db_migration_error == {"type": "invalidQueueSize"}
@pytest.mark.parametrize("queue_size", [2**31, -(2**31) - 1])
def test_queue_size_outside_c_int_is_rejected_before_ffi(fake_lib, queue_size):
lib = fake_lib({"type": "ok"})
with pytest.raises(ValueError, match="does not fit C int"):
migrate_init(queue_size)
assert lib.calls == []
def test_queue_size_on_old_lib_raises_clear_error(monkeypatch):
lib = OldLib({"type": "ok"})
monkeypatch.setattr(core._native, "lib", lambda: lib)
with pytest.raises(RuntimeError, match="does not export chat_migrate_init_queue"):
migrate_init(65536)
assert lib.calls == []
def test_setup_signatures_accepts_old_lib():
class Fn:
argtypes: Any = None
restype: Any = None
class Lib:
def __getattr__(self, name: str) -> Fn:
if name == "chat_migrate_init_queue":
raise AttributeError(name)
fn = Fn()
setattr(self, name, fn)
return fn
from simplex_chat import _native
_native._setup_signatures(Lib()) # type: ignore[arg-type]
@@ -91,3 +91,13 @@ def test_atomic_install(tmp_path, monkeypatch):
_download(target, "sqlite")
assert (target / "libsimplex.so").read_text() == "fake-so"
assert (target / "libHS-stub.so").read_text() == "fake-hs"
def test_libc_on_windows_is_ucrt(monkeypatch):
loaded: list[str | None] = []
monkeypatch.setattr("sys.platform", "win32")
monkeypatch.setattr("ctypes.CDLL", lambda name: loaded.append(name))
from simplex_chat import _native
_native._load_libc()
assert loaded == ["ucrtbase"]
@@ -0,0 +1,207 @@
"""ChatApi receives run on a dedicated per-instance thread, not the default pool.
Uses a fake libsimplex (see tests/test_core_migrate_init.py for the pattern):
`core._native.lib` and `core._read_and_free` are monkeypatched so `chat_recv_msg_wait`
sleeps for a controlled time and returns a scripted result.
"""
from __future__ import annotations
import asyncio
import json
import threading
import time
from concurrent.futures import ThreadPoolExecutor
from typing import Any
import pytest
from simplex_chat import ChatApi, ChatCommandError
RECV_SLEEP = 0.3
class FakeRecvLib:
"""Fake chat_recv_msg_wait: blocks for `sleep` seconds, then returns a scripted result.
`events` records "stop" / "recv_start" / "recv_end" / "close_store" in call order, across
all ChatApi instances sharing this fake, so tests can assert ordering between stop, receive
and store-close calls (not just that they happened).
"""
def __init__(
self,
sleep: float = RECV_SLEEP,
results: list[str] | None = None,
stop_response: str = "chatStopped",
) -> None:
self.sleep = sleep
self._results = iter(results or [])
self._stop_response = stop_response
self.calls: list[tuple[int, int]] = [] # (ctrl, thread ident)
self.events: list[str] = []
self._lock = threading.Lock()
def chat_recv_msg_wait(self, ctrl: int, wait_us: int) -> str:
with self._lock:
self.events.append("recv_start")
time.sleep(self.sleep)
with self._lock:
self.events.append("recv_end")
self.calls.append((ctrl, threading.get_ident()))
return next(self._results, "")
def chat_send_cmd(self, ctrl: int, cmd: bytes) -> str:
assert cmd == b"/_stop", f"unexpected command {cmd!r}"
with self._lock:
self.events.append("stop")
return json.dumps({"result": {"type": self._stop_response}})
def chat_close_store(self, ctrl: int) -> str:
with self._lock:
self.events.append("close_store")
return ""
@pytest.fixture
def fake_lib(monkeypatch: pytest.MonkeyPatch):
def install(
sleep: float = RECV_SLEEP,
results: list[str] | None = None,
stop_response: str = "chatStopped",
) -> FakeRecvLib:
lib = FakeRecvLib(sleep=sleep, results=results, stop_response=stop_response)
monkeypatch.setattr("simplex_chat.core._native.lib", lambda: lib)
monkeypatch.setattr("simplex_chat.core._read_and_free", lambda ptr: ptr)
return lib
return install
def _recv_thread_names() -> list[str]:
return [t.name for t in threading.enumerate() if t.name.startswith("simplex-recv")]
async def test_receives_do_not_use_the_default_executor(fake_lib):
fake_lib(sleep=RECV_SLEEP)
loop = asyncio.get_running_loop()
loop.set_default_executor(ThreadPoolExecutor(max_workers=1))
apis = [ChatApi(ctrl=i) for i in range(3)]
recv_tasks = [asyncio.create_task(api.recv_chat_event()) for api in apis]
try:
await asyncio.sleep(0.05) # let all three receives claim their own thread
start = time.monotonic()
await asyncio.to_thread(lambda: None)
elapsed = time.monotonic() - start
await asyncio.gather(*recv_tasks)
finally:
for api in apis:
await api.close()
assert elapsed < 0.1
async def test_one_receive_thread_per_chatapi_reused(fake_lib):
lib = fake_lib(sleep=0.02)
api = ChatApi(ctrl=1)
other_api = ChatApi(ctrl=2)
try:
for _ in range(3):
await api.recv_chat_event()
idents = {ident for ctrl, ident in lib.calls if ctrl == 1}
assert len(idents) == 1
recv_ident = idents.pop()
thread = next(t for t in threading.enumerate() if t.ident == recv_ident)
assert thread.name.startswith("simplex-recv")
assert thread.ident != threading.get_ident()
await other_api.recv_chat_event()
other_idents = {ident for ctrl, ident in lib.calls if ctrl == 2}
assert other_idents and other_idents != {thread.ident}
finally:
await api.close()
await other_api.close()
def test_no_thread_until_first_receive():
# A bare ThreadPoolExecutor spawns no worker thread until the first submit,
# so the real assertion is the attribute itself, not threading.enumerate().
api = ChatApi(ctrl=1)
assert api._recv_executor is None
async def test_close_shuts_down_the_executor_without_blocking_the_loop(fake_lib):
fake_lib(sleep=RECV_SLEEP)
api = ChatApi(ctrl=1)
recv_task = asyncio.create_task(api.recv_chat_event())
await asyncio.sleep(0.05) # let the receive claim its executor thread
assert _recv_thread_names() != []
sleep_task = asyncio.create_task(asyncio.sleep(0.01))
close_task = asyncio.create_task(api.close())
await asyncio.wait_for(sleep_task, timeout=0.2)
assert not close_task.done() # shutdown still waiting on the in-flight receive
await close_task
await recv_task
assert _recv_thread_names() == []
async def test_close_shuts_down_executor_before_closing_the_store(fake_lib):
lib = fake_lib(sleep=RECV_SLEEP)
api = ChatApi(ctrl=1)
recv_task = asyncio.create_task(api.recv_chat_event())
try:
await asyncio.sleep(0.05) # ensure the receive is in flight before close() starts
await api.close()
await recv_task
finally:
if not recv_task.done():
recv_task.cancel()
# recv_end (executor drained) must precede close_store: a receive must never
# be in flight while the store closes underneath it.
assert lib.events == ["recv_start", "stop", "recv_end", "close_store"]
async def test_close_stops_the_chat_before_closing_the_store(fake_lib):
lib = fake_lib()
api = ChatApi(ctrl=1)
await api.close()
assert lib.events == ["stop", "close_store"]
assert not api.initialized
async def test_close_does_not_close_the_store_when_stop_fails(fake_lib):
lib = fake_lib(stop_response="chatCmdError")
api = ChatApi(ctrl=1)
with pytest.raises(ChatCommandError, match="error stopping chat"):
await api.close()
assert lib.events == ["stop"]
assert api.initialized
async def test_recv_chat_event_after_close_raises_before_touching_executor(fake_lib):
fake_lib()
api = ChatApi(ctrl=1)
await api.close()
with pytest.raises(RuntimeError, match="controller not initialized"):
await api.recv_chat_event()
assert api._recv_executor is None
async def test_receive_parses_event_json_and_none_on_timeout(fake_lib):
event: dict[str, Any] = {"type": "chatItemUpdated", "chatItem": {}}
fake_lib(sleep=0.01, results=[json.dumps({"result": event}), ""])
api = ChatApi(ctrl=1)
try:
assert await api.recv_chat_event() == event
assert await api.recv_chat_event() is None
finally:
await api.close()
@@ -167,6 +167,11 @@ def test_ci_bot_command_no_text():
assert util.ci_bot_command(ci) is None
def test_ci_bot_command_multiline_params():
ci = {"content": {"type": "rcvMsgContent", "msgContent": {"type": "text", "text": "/review line1\nline2"}}}
assert util.ci_bot_command(ci) == ("review", "line1\nline2")
def test_reaction_text_emoji():
r = {"chatReaction": {"reaction": {"type": "emoji", "emoji": "🎉"}}}
assert util.reaction_text(r) == "🎉"