From c8f20bcc9150e5f7883b210c53d5c9838c06ae0c Mon Sep 17 00:00:00 2001 From: sh <37271604+shumvgolove@users.noreply.github.com> Date: Sat, 19 Sep 2026 15:18:46 +0400 Subject: [PATCH] core, libs: configurable queue size, library fixes (#7542) * core: add chat_migrate_init_queue FFI export * bots: fix BadgeServiceErrorCode API type * nodejs: pass required command fields * nodejs: fix migration error types * nodejs: install libsimplex from SIMPLEX_LIBS_DIR * nodejs: add queue size option * nodejs: regenerate docs * python: add queue size option * bots: pass incognito in APIConnect * nodejs: accept documented success responses * python: accept documented success responses * nodejs: dispatch each bot message once * nodejs: fix startChat events loop lifecycle * nodejs, python: parse multi-line bot commands * nodejs: fix file buffer handling in addon * nodejs: keep events loop when chat stop fails * python: fix send_and_wait race, load lib off loop * python: make queue size export optional * nodejs: receive events on a dedicated thread * nodejs: release haskell thread after receive * python: receive on a dedicated thread per chat * python: test receive shutdown order * nodejs: one receive thread per chat controller * nodejs: harden receiver shutdown and tests * nodejs: stop chat before closing store * python: stop chat before closing store * nodejs, python: harden close regression and retry * python: free results with ucrt on windows * nodejs: enable c++ exceptions on mac and windows --- bots/api/COMMANDS.md | 6 +- bots/api/TYPES.md | 74 ++--- bots/src/API/Docs/Commands.hs | 2 +- bots/src/API/Docs/Types.hs | 2 +- flake.nix | 2 + libsimplex.dll.def | 2 + .../types/typescript/src/commands.ts | 2 +- .../types/typescript/src/types.ts | 135 ++------ packages/simplex-chat-nodejs/binding.gyp | 2 + packages/simplex-chat-nodejs/cpp/simplex.cc | 292 ++++++++++++++++-- packages/simplex-chat-nodejs/cpp/simplex.h | 2 + .../simplex-chat-nodejs/docs/Namespace.bot.md | 1 + .../docs/api.Class.ChatApi.md | 200 +++++++----- .../docs/bot.Function.run.md | 2 +- .../docs/bot.Function.subscribeChatItems.md | 27 ++ .../docs/bot.Interface.BotConfig.md | 14 +- .../docs/bot.Interface.BotOptions.md | 20 +- .../docs/bot.TypeAlias.BotDbOpts.md | 4 + .../docs/core.Class.ChatAPIError.md | 8 +- .../docs/core.Class.ChatInitError.md | 8 +- ...MigrationError.Interface.ErrorMigration.md | 8 +- ...rationError.Interface.ErrorNotADatabase.md | 6 +- ...ore.DBMigrationError.Interface.ErrorSQL.md | 8 +- ...tionError.Interface.InvalidConfirmation.md | 4 +- ...grationError.Interface.InvalidQueueSize.md | 25 ++ .../core.DBMigrationError.TypeAlias.Tag.md | 4 +- .../core.Enumeration.MigrationConfirmation.md | 10 +- .../docs/core.Function.chatCloseStore.md | 2 +- .../docs/core.Function.chatDecryptFile.md | 2 +- .../docs/core.Function.chatEncryptFile.md | 2 +- .../docs/core.Function.chatMigrateInit.md | 10 +- .../docs/core.Function.chatReadFile.md | 6 +- .../docs/core.Function.chatRecvMsgWait.md | 2 +- .../docs/core.Function.chatSendCmd.md | 2 +- .../docs/core.Function.chatWriteFile.md | 4 +- .../docs/core.Interface.APIResult.md | 6 +- .../docs/core.Interface.CryptoArgs.md | 6 +- .../docs/core.Interface.UpMigration.md | 6 +- .../core.MTRError.Interface.MTREDifferent.md | 6 +- .../core.MTRError.Interface.MTRENoDown.md | 20 +- .../docs/core.MTRError.TypeAlias.Tag.md | 2 +- ...re.MigrationError.Interface.MEDowngrade.md | 6 +- ...core.MigrationError.Interface.MEUpgrade.md | 8 +- ...MigrationError.Interface.MigrationError.md | 6 +- .../docs/core.MigrationError.TypeAlias.Tag.md | 2 +- .../docs/core.Namespace.DBMigrationError.md | 1 + .../docs/core.TypeAlias.DBMigrationError.md | 4 +- .../docs/core.TypeAlias.MTRError.md | 2 +- .../docs/core.TypeAlias.MigrationError.md | 2 +- packages/simplex-chat-nodejs/src/api.ts | 62 ++-- packages/simplex-chat-nodejs/src/bot.ts | 47 +-- packages/simplex-chat-nodejs/src/core.ts | 22 +- .../simplex-chat-nodejs/src/download-libs.js | 27 ++ packages/simplex-chat-nodejs/src/simplex.d.ts | 5 +- packages/simplex-chat-nodejs/src/util.ts | 2 +- .../simplex-chat-nodejs/tests/api.test.ts | 4 +- .../tests/api.unit.test.ts | 102 ++++++ .../tests/bot.unit.test.ts | 50 +++ .../tests/commands.test.ts | 13 + .../simplex-chat-nodejs/tests/core.test.ts | 158 +++++++++- .../simplex-chat-nodejs/tests/util.test.ts | 4 + .../src/simplex_chat/_native.py | 21 +- .../src/simplex_chat/api.py | 43 ++- .../src/simplex_chat/bot.py | 2 + .../src/simplex_chat/client.py | 20 +- .../src/simplex_chat/core.py | 38 ++- .../src/simplex_chat/types/_commands.py | 2 +- .../src/simplex_chat/types/_types.py | 78 +---- .../src/simplex_chat/util.py | 2 +- .../simplex-chat-python/tests/test_api.py | 44 +++ .../tests/test_client_and_waiters.py | 110 ++++++- .../simplex-chat-python/tests/test_codegen.py | 6 + .../tests/test_core_migrate_init.py | 123 ++++++++ .../tests/test_native_cache.py | 10 + .../tests/test_recv_executor.py | 207 +++++++++++++ .../simplex-chat-python/tests/test_util.py | 5 + src/Simplex/Chat/Mobile.hs | 34 +- tests/MobileTests.hs | 23 +- 78 files changed, 1670 insertions(+), 571 deletions(-) create mode 100644 packages/simplex-chat-nodejs/docs/bot.Function.subscribeChatItems.md create mode 100644 packages/simplex-chat-nodejs/docs/core.DBMigrationError.Interface.InvalidQueueSize.md create mode 100644 packages/simplex-chat-nodejs/tests/api.unit.test.ts create mode 100644 packages/simplex-chat-nodejs/tests/bot.unit.test.ts create mode 100644 packages/simplex-chat-nodejs/tests/commands.test.ts create mode 100644 packages/simplex-chat-python/tests/test_core_migrate_init.py create mode 100644 packages/simplex-chat-python/tests/test_recv_executor.py diff --git a/bots/api/COMMANDS.md b/bots/api/COMMANDS.md index 5f49b26653..f90e8e08da 100644 --- a/bots/api/COMMANDS.md +++ b/bots/api/COMMANDS.md @@ -1537,15 +1537,15 @@ Connect via prepared SimpleX link. The link can be 1-time invitation link, conta **Syntax**: ``` -/_connect [ ] +/_connect [ incognito=on][ ] ``` ```javascript -'/_connect ' + userId + (preparedLink_ ? ' ' + CreatedConnLink.cmdString(preparedLink_) : '') // JavaScript +'/_connect ' + userId + (incognito ? ' incognito=on' : '') + (preparedLink_ ? ' ' + CreatedConnLink.cmdString(preparedLink_) : '') // JavaScript ``` ```python -'/_connect ' + str(userId) + ((' ' + CreatedConnLink_cmd_string(preparedLink_)) if preparedLink_ is not None else '') # Python +'/_connect ' + str(userId) + (' incognito=on' if incognito else '') + ((' ' + CreatedConnLink_cmd_string(preparedLink_)) if preparedLink_ is not None else '') # Python ``` **Responses**: diff --git a/bots/api/TYPES.md b/bots/api/TYPES.md index 14ff04b08c..f1122979a9 100644 --- a/bots/api/TYPES.md +++ b/bots/api/TYPES.md @@ -466,62 +466,24 @@ CredentialNotVerified: ## BadgeServiceErrorCode -**Discriminated union type**: - -BadRequest: -- type: "badRequest" - -UnsupportedVersion: -- type: "unsupportedVersion" - -UnknownPurchaseKey: -- type: "unknownPurchaseKey" - -UnknownOfferId: -- type: "unknownOfferId" - -OfferDisabled: -- type: "offerDisabled" - -OfferMismatch: -- type: "offerMismatch" - -ProductUnavailable: -- type: "productUnavailable" - -PaymentNotEntitled: -- type: "paymentNotEntitled" - -PaymentPending: -- type: "paymentPending" - -ProviderUnavailable: -- type: "providerUnavailable" - -RateLimited: -- type: "rateLimited" - -CodeInvalid: -- type: "codeInvalid" - -CodeUsed: -- type: "codeUsed" - -CodeExpired: -- type: "codeExpired" - -ReceiptInvalid: -- type: "receiptInvalid" - -ReceiptUsed: -- type: "receiptUsed" - -Internal: -- type: "internal" - -Unknown: -- type: "unknown" -- : string +**Enum type**: +- "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" --- diff --git a/bots/src/API/Docs/Commands.hs b/bots/src/API/Docs/Commands.hs index eb4804fa45..4ffba7bfbe 100644 --- a/bots/src/API/Docs/Commands.hs +++ b/bots/src/API/Docs/Commands.hs @@ -140,7 +140,7 @@ chatCommandsDocsData = [ ("APIAddContact", [], "Create 1-time invitation link.", ["CRInvitation", "CRChatCmdError"], [], Just UNInteractive, "/_connect " <> Param "userId" <> OnOffParam "incognito" "incognito" (Just False)), -- `Maybe` in `connectTarget :: Maybe ConnectTarget` is used to signal parse failure to the runtime (the handler returns CEInvalidConnReq on Nothing); it is NOT API-level optionality. The parameter is required from callers. ("APIConnectPlan", [], "Determine SimpleX link type and if the bot is already connected via this link or name.", ["CRConnectionPlan", "CRChatCmdError"], [], Just UNInteractive, "/_connect plan " <> Param "userId" <> " " <> Param "connectTarget"), - ("APIConnect", [], "Connect via prepared SimpleX link. The link can be 1-time invitation link, contact address or group link.", ["CRSentConfirmation", "CRContactAlreadyExists", "CRSentInvitation", "CRChatCmdError"], [], Just UNInteractive, "/_connect " <> Param "userId" <> Optional "" (" " <> Param "$0") "preparedLink_"), + ("APIConnect", [], "Connect via prepared SimpleX link. The link can be 1-time invitation link, contact address or group link.", ["CRSentConfirmation", "CRContactAlreadyExists", "CRSentInvitation", "CRChatCmdError"], [], Just UNInteractive, "/_connect " <> Param "userId" <> OnOffParam "incognito" "incognito" (Just False) <> Optional "" (" " <> Param "$0") "preparedLink_"), ("Connect", [], "Connect via SimpleX link or name as string in the active user profile.", ["CRSentConfirmation", "CRContactAlreadyExists", "CRSentInvitation", "CRConnectionPlan", "CRSentInvitationToContact", "CRStartedConnectionToContact", "CRStartedConnectionToGroup", "CRChatCmdError"], [], Just UNInteractive, "/connect" <> Optional "" (" " <> Param "$0") "connTarget_"), ("APIAcceptContact", ["incognito"], "Accept contact request.", ["CRAcceptingContactRequest", "CRChatCmdError"], [], Just UNInteractive, "/_accept " <> Param "contactReqId"), ("APIRejectContact", [], "Reject contact request. The user who sent the request is **not notified**.", ["CRContactRequestRejected", "CRChatCmdError"], [], Nothing, "/_reject " <> Param "contactReqId") diff --git a/bots/src/API/Docs/Types.hs b/bots/src/API/Docs/Types.hs index 58298c44fb..792f3e6fd5 100644 --- a/bots/src/API/Docs/Types.hs +++ b/bots/src/API/Docs/Types.hs @@ -217,7 +217,7 @@ chatTypesDocsData = (sti @AutoAccept, STRecord, "", [], "", ""), (sti @BadgeProof, STRecord, "", [], "", ""), (sti @BadgeRedeemError, STUnion, "BRE", [], "", ""), - (sti @BadgeServiceErrorCode, STUnion, "BSE", [], "", ""), + (sti @BadgeServiceErrorCode, STEnum' (consSep "BSE" '_'), "", ["BSEUnknown"], "", ""), (sti @BlockingInfo, STRecord, "", [], "", ""), (sti @BlockingReason, STEnum, "BR", [], "", ""), (sti @BrokerErrorType, STUnion, "", [], "", ""), diff --git a/flake.nix b/flake.nix index 0863222c92..84f84c34ec 100644 --- a/flake.nix +++ b/flake.nix @@ -393,6 +393,7 @@ "chat_encrypt_file" "chat_encrypt_media" "chat_migrate_init" + "chat_migrate_init_queue" "chat_parse_markdown" "chat_parse_server" "chat_parse_uri" @@ -515,6 +516,7 @@ "chat_encrypt_file" "chat_encrypt_media" "chat_migrate_init" + "chat_migrate_init_queue" "chat_parse_markdown" "chat_parse_server" "chat_parse_uri" diff --git a/libsimplex.dll.def b/libsimplex.dll.def index a7a66992a6..79b88dc782 100644 --- a/libsimplex.dll.def +++ b/libsimplex.dll.def @@ -2,7 +2,9 @@ LIBRARY libsimplex EXPORTS hs_init hs_init_with_rtsopts + hs_thread_done chat_migrate_init + chat_migrate_init_queue chat_close_store chat_send_cmd chat_send_cmd_retry diff --git a/packages/simplex-chat-client/types/typescript/src/commands.ts b/packages/simplex-chat-client/types/typescript/src/commands.ts index fbde8ce9ac..c217ef944c 100644 --- a/packages/simplex-chat-client/types/typescript/src/commands.ts +++ b/packages/simplex-chat-client/types/typescript/src/commands.ts @@ -570,7 +570,7 @@ export namespace APIConnect { export type Response = CR.SentConfirmation | CR.ContactAlreadyExists | CR.SentInvitation | CR.ChatCmdError export function cmdString(self: APIConnect): string { - return '/_connect ' + self.userId + (self.preparedLink_ ? ' ' + T.CreatedConnLink.cmdString(self.preparedLink_) : '') + return '/_connect ' + self.userId + (self.incognito ? ' incognito=on' : '') + (self.preparedLink_ ? ' ' + T.CreatedConnLink.cmdString(self.preparedLink_) : '') } } diff --git a/packages/simplex-chat-client/types/typescript/src/types.ts b/packages/simplex-chat-client/types/typescript/src/types.ts index 3bf3fdc831..d7db7f259f 100644 --- a/packages/simplex-chat-client/types/typescript/src/types.ts +++ b/packages/simplex-chat-client/types/typescript/src/types.ts @@ -301,123 +301,24 @@ export namespace BadgeRedeemError { } } -export type 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 - -export namespace BadgeServiceErrorCode { - export type Tag = - | "badRequest" - | "unsupportedVersion" - | "unknownPurchaseKey" - | "unknownOfferId" - | "offerDisabled" - | "offerMismatch" - | "productUnavailable" - | "paymentNotEntitled" - | "paymentPending" - | "providerUnavailable" - | "rateLimited" - | "codeInvalid" - | "codeUsed" - | "codeExpired" - | "receiptInvalid" - | "receiptUsed" - | "internal" - | "unknown" - - interface Interface { - type: Tag - } - - export interface BadRequest extends Interface { - type: "badRequest" - } - - export interface UnsupportedVersion extends Interface { - type: "unsupportedVersion" - } - - export interface UnknownPurchaseKey extends Interface { - type: "unknownPurchaseKey" - } - - export interface UnknownOfferId extends Interface { - type: "unknownOfferId" - } - - export interface OfferDisabled extends Interface { - type: "offerDisabled" - } - - export interface OfferMismatch extends Interface { - type: "offerMismatch" - } - - export interface ProductUnavailable extends Interface { - type: "productUnavailable" - } - - export interface PaymentNotEntitled extends Interface { - type: "paymentNotEntitled" - } - - export interface PaymentPending extends Interface { - type: "paymentPending" - } - - export interface ProviderUnavailable extends Interface { - type: "providerUnavailable" - } - - export interface RateLimited extends Interface { - type: "rateLimited" - } - - export interface CodeInvalid extends Interface { - type: "codeInvalid" - } - - export interface CodeUsed extends Interface { - type: "codeUsed" - } - - export interface CodeExpired extends Interface { - type: "codeExpired" - } - - export interface ReceiptInvalid extends Interface { - type: "receiptInvalid" - } - - export interface ReceiptUsed extends Interface { - type: "receiptUsed" - } - - export interface Internal extends Interface { - type: "internal" - } - - export interface Unknown extends Interface { - type: "unknown" - : string - } +export enum BadgeServiceErrorCode { + Bad_request = "bad_request", + Unsupported_version = "unsupported_version", + Unknown_purchase_key = "unknown_purchase_key", + Unknown_offer_id = "unknown_offer_id", + Offer_disabled = "offer_disabled", + Offer_mismatch = "offer_mismatch", + Product_unavailable = "product_unavailable", + Payment_not_entitled = "payment_not_entitled", + Payment_pending = "payment_pending", + Provider_unavailable = "provider_unavailable", + Rate_limited = "rate_limited", + Code_invalid = "code_invalid", + Code_used = "code_used", + Code_expired = "code_expired", + Receipt_invalid = "receipt_invalid", + Receipt_used = "receipt_used", + Internal = "internal", } export enum BadgeStatus { diff --git a/packages/simplex-chat-nodejs/binding.gyp b/packages/simplex-chat-nodejs/binding.gyp index 09c63cecba..cfa6d61039 100644 --- a/packages/simplex-chat-nodejs/binding.gyp +++ b/packages/simplex-chat-nodejs/binding.gyp @@ -11,6 +11,8 @@ ], "cflags!": [ "-fno-exceptions" ], "cflags_cc!": [ "-fno-exceptions" ], + "xcode_settings": { "GCC_ENABLE_CPP_EXCEPTIONS": "YES" }, + "msvs_settings": { "VCCLCompilerTool": { "ExceptionHandling": 1 } }, "defines": [ "NAPI_DISABLE_CPP_EXCEPTIONS" ], "conditions": [ ["OS=='mac'", { diff --git a/packages/simplex-chat-nodejs/cpp/simplex.cc b/packages/simplex-chat-nodejs/cpp/simplex.cc index 97233eff6f..bcca2b994a 100644 --- a/packages/simplex-chat-nodejs/cpp/simplex.cc +++ b/packages/simplex-chat-nodejs/cpp/simplex.cc @@ -3,6 +3,14 @@ #include #include #include +#include +#include +#include +#include +#include +#include +#include +#include #include "simplex.h" namespace simplex { @@ -81,6 +89,11 @@ class ResultAsyncWorker : public AsyncWorker { return ctrl_; } + // the worker thread reads this object's memory until the worker completes + void KeepAlive(Object obj) { + keep_alive_ = Persistent(obj); + } + protected: std::string result_; uintptr_t ctrl_ = 0; @@ -88,6 +101,7 @@ class ResultAsyncWorker : public AsyncWorker { private: ExecuteFn execute_fn_; ResultProcessor result_processor_; + ObjectReference keep_alive_; }; class BinaryAsyncWorker : public AsyncWorker { @@ -97,21 +111,25 @@ class BinaryAsyncWorker : public AsyncWorker { BinaryAsyncWorker(Function& callback, ExecuteFn execute_fn) : AsyncWorker(callback), execute_fn_(std::move(execute_fn)) {} + ~BinaryAsyncWorker() { + free(original_buf); + } + void Execute() override { execute_fn_(this); } void OnOK() override { HandleScope scope(Env()); - if (original_buf == nullptr || binary_len == 0) { - Callback().Call({Env().Null(), Env().Undefined()}); + char* buf = original_buf; + original_buf = nullptr; + if (binary_len == 0) { + free(buf); + Callback().Call({Env().Null(), Buffer::New(Env(), 0)}); return; } - char* data_ptr = original_buf + 5; - auto finalizer = [](Napi::Env env, char* finalize_data, char* orig) { - free(orig); - }; - Napi::Buffer buffer = Napi::Buffer::New(Env(), data_ptr, binary_len, finalizer, original_buf); + // Copies when the runtime forbids external buffers (Electron); the finalizer then runs immediately. + Buffer buffer = Buffer::NewOrCopy(Env(), buf + 5, binary_len, [](Napi::Env, char*, char* orig) { free(orig); }, buf); Callback().Call({Env().Null(), buffer}); } @@ -176,6 +194,159 @@ Napi::Promise CreatePromiseAndCallback(Env env, Function& cb_out) { return deferred.Promise(); } +const char* const RECEIVER_STOPPED = "chat receiver stopped"; + +struct RecvRequest { + int wait = 0; + std::shared_ptr deferred; +}; + +// Holds the event loop open only while receives are pending; used only on the JS main thread. +class PendingReceives { + public: + explicit PendingReceives(ThreadSafeFunction tsfn) : tsfn_(tsfn) {} + + const ThreadSafeFunction& Tsfn() const { + return tsfn_; + } + + void Add(Napi::Env env) { + if (count_++ == 0) tsfn_.Ref(env); + } + + void Remove(Napi::Env env) { + if (--count_ == 0) tsfn_.Unref(env); + } + + private: + ThreadSafeFunction tsfn_; + size_t count_ = 0; +}; + +// A blocking receive would hold a libuv pool thread for up to `wait`, stalling fs, dns and crypto. +class Receiver { + public: + // Returns nullptr with a pending JS exception if the TSFN cannot be created, throws std::system_error if the thread cannot start. + static std::shared_ptr Start(Napi::Env env, chat_ctrl ctrl) { + ThreadSafeFunction tsfn = ThreadSafeFunction::New(env, Function::New(env, [](const CallbackInfo&) {}), "chat_recv_msg_wait", 0, 1); + if (env.IsExceptionPending()) { + return nullptr; + } + auto receiver = std::make_shared(ctrl, tsfn); + try { + receiver->thread_ = std::thread(&Receiver::Run, receiver.get()); + } catch (const std::system_error&) { + tsfn.Release(); + throw; + } + return receiver; + } + + Receiver(chat_ctrl ctrl, ThreadSafeFunction tsfn) : ctrl_(ctrl), pending_(std::make_shared(tsfn)) {} + + Receiver(const Receiver&) = delete; + Receiver& operator=(const Receiver&) = delete; + + ~Receiver() { + Stop(); + } + + void Enqueue(Napi::Env env, RecvRequest request) { + pending_->Add(env); + { + std::lock_guard lock(mutex_); + queue_.push_back(std::move(request)); + } + cv_.notify_one(); + } + + void RequestStop() { + { + std::lock_guard lock(mutex_); + stop_ = true; + } + cv_.notify_one(); + } + + // Waits for the receive in progress, so it must not run on the JS main thread outside env teardown. + void Stop() { + RequestStop(); + if (thread_.joinable()) { + thread_.join(); + } + } + + private: + void Run() { + for (;;) { + RecvRequest request; + { + std::unique_lock lock(mutex_); + cv_.wait(lock, [this] { return stop_ || !queue_.empty(); }); + if (stop_) break; + request = std::move(queue_.front()); + queue_.pop_front(); + } + char* c_res = chat_recv_msg_wait(ctrl_, request.wait); + napi_status status = Settle(request.deferred, [c_res](Napi::Env env, Promise::Deferred& deferred) { + if (c_res == nullptr) { + deferred.Reject(Error::New(env, "chat_recv_msg_wait failed").Value()); + } else { + deferred.Resolve(String::New(env, c_res)); + free(c_res); + } + }); + if (status != napi_ok) { + free(c_res); + } + } + std::deque unserved; + { + std::lock_guard lock(mutex_); + unserved.swap(queue_); + } + for (RecvRequest& request : unserved) { + Settle(request.deferred, [](Napi::Env env, Promise::Deferred& deferred) { + deferred.Reject(Error::New(env, RECEIVER_STOPPED).Value()); + }); + } + pending_->Tsfn().Release(); + // Each OS thread that enters Haskell keeps an RTS task record until it calls hs_thread_done. + hs_thread_done(); + } + + template + napi_status Settle(std::shared_ptr deferred, SettleFn settle) { + // The callback may run after this Receiver is destroyed, so it owns the pending count. + std::shared_ptr pending = pending_; + return pending->Tsfn().BlockingCall([pending, deferred, settle](Napi::Env env, Function) { + settle(env, *deferred); + pending->Remove(env); + }); + } + + const chat_ctrl ctrl_; + const std::shared_ptr pending_; + std::mutex mutex_; + std::condition_variable cv_; + std::deque queue_; + bool stop_ = false; + std::thread thread_; +}; + +// Keyed by chat_ctrl, accessed only on the JS main thread. +using Receivers = std::unordered_map>; + +std::shared_ptr TakeReceiver(Receivers& receivers, chat_ctrl ctrl) { + auto it = receivers.find(reinterpret_cast(ctrl)); + if (it == receivers.end()) { + return nullptr; + } + std::shared_ptr receiver = std::move(it->second); + receivers.erase(it); + return receiver; +} + // Common result processors ResultAsyncWorker::ResultProcessor MigrateResultProcessor() { return [](ResultAsyncWorker* worker, Napi::Env env) { @@ -215,6 +386,39 @@ Value ChatMigrateInit(const CallbackInfo& args) { return promise; } +Value ChatMigrateInitQueue(const CallbackInfo& args) { + Env env = args.Env(); + if (args.Length() < 4 || !args[0].IsString() || !args[1].IsString() || !args[2].IsString() || !args[3].IsNumber()) { + TypeError::New(env, "Expected three string arguments and number").ThrowAsJavaScriptException(); + return env.Undefined(); + } + + std::string path = args[0].As().Utf8Value(); + std::string key = args[1].As().Utf8Value(); + std::string confirm = args[2].As().Utf8Value(); + Number queue_size_arg = args[3].As(); + int queue_size = queue_size_arg.Int32Value(); + if (static_cast(queue_size) != queue_size_arg.DoubleValue()) { + RangeError::New(env, "Expected 32-bit integer queue size").ThrowAsJavaScriptException(); + return env.Undefined(); + } + + Function cb; + Promise promise = CreatePromiseAndCallback(env, cb); + + auto execute_fn = [path, key, confirm, queue_size](ResultAsyncWorker* worker) { + chat_ctrl ctrl = nullptr; + char* c_res = chat_migrate_init_queue(path.c_str(), key.c_str(), confirm.c_str(), queue_size, &ctrl); + worker->SetCtrl(reinterpret_cast(ctrl)); + HandleCResult(worker, c_res, "chat_migrate_init_queue"); + }; + + ResultAsyncWorker* worker = new ResultAsyncWorker(cb, std::move(execute_fn), MigrateResultProcessor()); + worker->Queue(); + + return promise; +} + Value ChatCloseStore(const CallbackInfo& args) { Env env = args.Env(); if (args.Length() < 1 || !args[0].IsBigInt()) { @@ -223,11 +427,15 @@ Value ChatCloseStore(const CallbackInfo& args) { } chat_ctrl ctrl = FromChatCtrlBigInt(args[0]); + std::shared_ptr receiver = TakeReceiver(*static_cast(args.Data()), ctrl); Function cb; Promise promise = CreatePromiseAndCallback(env, cb); - auto execute_fn = [ctrl](ResultAsyncWorker* worker) { + auto execute_fn = [ctrl, receiver](ResultAsyncWorker* worker) { + if (receiver) { + receiver->Stop(); + } char* c_res = chat_close_store(ctrl); HandleCResult(worker, c_res, "chat_close_store"); }; @@ -271,44 +479,63 @@ Value ChatRecvMsgWait(const CallbackInfo& args) { chat_ctrl ctrl = FromChatCtrlBigInt(args[0]); int wait = static_cast(args[1].As().Int32Value()); + Receivers& receivers = *static_cast(args.Data()); - Function cb; - Promise promise = CreatePromiseAndCallback(env, cb); + auto deferred = std::make_shared(Promise::Deferred::New(env)); + auto it = receivers.find(reinterpret_cast(ctrl)); + if (it == receivers.end()) { + std::shared_ptr receiver; + try { + receiver = Receiver::Start(env, ctrl); + } catch (const std::system_error& e) { + deferred->Reject(Error::New(env, e.what()).Value()); + return deferred->Promise(); + } + if (!receiver) { + return env.Undefined(); + } + it = receivers.emplace(reinterpret_cast(ctrl), std::move(receiver)).first; + } + it->second->Enqueue(env, {wait, deferred}); - auto execute_fn = [ctrl, wait](ResultAsyncWorker* worker) { - char* c_res = chat_recv_msg_wait(ctrl, wait); - HandleCResult(worker, c_res, "chat_recv_msg_wait"); - }; - - ResultAsyncWorker* worker = new ResultAsyncWorker(cb, std::move(execute_fn)); - worker->Queue(); - - return promise; + return deferred->Promise(); } Value ChatWriteFile(const CallbackInfo& args) { Env env = args.Env(); - if (args.Length() < 3 || !args[0].IsBigInt() || !args[1].IsString() || !args[2].IsArrayBuffer()) { - TypeError::New(env, "Expected bigint (ctrl), string (path), ArrayBuffer").ThrowAsJavaScriptException(); + if (args.Length() < 3 || !args[0].IsBigInt() || !args[1].IsString() || !(args[2].IsArrayBuffer() || args[2].IsTypedArray())) { + TypeError::New(env, "Expected bigint (ctrl), string (path), ArrayBuffer or Uint8Array").ThrowAsJavaScriptException(); return env.Undefined(); } chat_ctrl ctrl = FromChatCtrlBigInt(args[0]); std::string path = args[1].As().Utf8Value(); - ArrayBuffer ab = args[2].As(); - char* data = static_cast(ab.Data()); - size_t len = ab.ByteLength(); + char* data; + size_t len; + if (args[2].IsArrayBuffer()) { + ArrayBuffer ab = args[2].As(); + data = static_cast(ab.Data()); + len = ab.ByteLength(); + } else { + TypedArray view = args[2].As(); + data = static_cast(view.ArrayBuffer().Data()) + view.ByteOffset(); + len = view.ByteLength(); + } + if (len > static_cast(INT_MAX)) { + RangeError::New(env, "Buffer is too large").ThrowAsJavaScriptException(); + return env.Undefined(); + } Function cb; Promise promise = CreatePromiseAndCallback(env, cb); - auto execute_fn = [ctrl, path, ab, data, len](ResultAsyncWorker* worker) { - (void)ab; // to keep ArrayBuffer alive + auto execute_fn = [ctrl, path, data, len](ResultAsyncWorker* worker) { char* c_res = chat_write_file(ctrl, path.c_str(), data, static_cast(len)); HandleCResult(worker, c_res, "chat_write_file"); }; ResultAsyncWorker* worker = new ResultAsyncWorker(cb, std::move(execute_fn)); + worker->KeepAlive(args[2].As()); worker->Queue(); return promise; @@ -410,10 +637,19 @@ Value ChatDecryptFile(const CallbackInfo& args) { Object Init(Env env, Object exports) { haskell_init(); + auto* receivers = new Receivers(); + // Stopping all receivers before joining any bounds teardown by the longest in-flight receive. + env.AddCleanupHook([receivers]() { + for (auto& entry : *receivers) { + entry.second->RequestStop(); + } + delete receivers; + }); exports.Set("chat_migrate_init", Function::New(env, ChatMigrateInit)); - exports.Set("chat_close_store", Function::New(env, ChatCloseStore)); + exports.Set("chat_migrate_init_queue", Function::New(env, ChatMigrateInitQueue)); + exports.Set("chat_close_store", Function::New(env, ChatCloseStore, "chat_close_store", receivers)); exports.Set("chat_send_cmd", Function::New(env, ChatSendCmd)); - exports.Set("chat_recv_msg_wait", Function::New(env, ChatRecvMsgWait)); + exports.Set("chat_recv_msg_wait", Function::New(env, ChatRecvMsgWait, "chat_recv_msg_wait", receivers)); exports.Set("chat_write_file", Function::New(env, ChatWriteFile)); exports.Set("chat_read_file", Function::New(env, ChatReadFile)); exports.Set("chat_encrypt_file", Function::New(env, ChatEncryptFile)); diff --git a/packages/simplex-chat-nodejs/cpp/simplex.h b/packages/simplex-chat-nodejs/cpp/simplex.h index 8e579626ed..ddc052946a 100644 --- a/packages/simplex-chat-nodejs/cpp/simplex.h +++ b/packages/simplex-chat-nodejs/cpp/simplex.h @@ -11,11 +11,13 @@ extern "C" void hs_init(int argc, char **argv[]); extern "C" void hs_init_with_rtsopts(int * argc, char **argv[]); +extern "C" void hs_thread_done(void); typedef long* chat_ctrl; // the last parameter is used to return the pointer to chat controller extern "C" char *chat_migrate_init(const char *path, const char *key, const char *confirm, chat_ctrl *ctrl); +extern "C" char *chat_migrate_init_queue(const char *path, const char *key, const char *confirm, const int queueSize, chat_ctrl *ctrl); extern "C" char *chat_close_store(chat_ctrl ctrl); extern "C" char *chat_reopen_store(chat_ctrl ctrl); extern "C" char *chat_send_cmd(chat_ctrl ctrl, const char *cmd); diff --git a/packages/simplex-chat-nodejs/docs/Namespace.bot.md b/packages/simplex-chat-nodejs/docs/Namespace.bot.md index 4b9171d0f7..7adeeec0c7 100644 --- a/packages/simplex-chat-nodejs/docs/Namespace.bot.md +++ b/packages/simplex-chat-nodejs/docs/Namespace.bot.md @@ -21,3 +21,4 @@ It automates creating and updating of the bot profile, address and bot commands ## Functions - [run](bot.Function.run.md) +- [subscribeChatItems](bot.Function.subscribeChatItems.md) diff --git a/packages/simplex-chat-nodejs/docs/api.Class.ChatApi.md b/packages/simplex-chat-nodejs/docs/api.Class.ChatApi.md index b81677b976..287099906b 100644 --- a/packages/simplex-chat-nodejs/docs/api.Class.ChatApi.md +++ b/packages/simplex-chat-nodejs/docs/api.Class.ChatApi.md @@ -26,7 +26,7 @@ Defined in: [src/api.ts:103](../src/api.ts#L103) > **get** **ctrl**(): `bigint` -Defined in: [src/api.ts:329](../src/api.ts#L329) +Defined in: [src/api.ts:344](../src/api.ts#L344) Chat controller reference @@ -42,7 +42,7 @@ Chat controller reference > **get** **initialized**(): `boolean` -Defined in: [src/api.ts:315](../src/api.ts#L315) +Defined in: [src/api.ts:330](../src/api.ts#L330) Chat controller is initialized @@ -58,7 +58,7 @@ Chat controller is initialized > **get** **started**(): `boolean` -Defined in: [src/api.ts:322](../src/api.ts#L322) +Defined in: [src/api.ts:337](../src/api.ts#L337) Chat controller is started @@ -72,7 +72,7 @@ Chat controller is started > **apiAcceptContactRequest**(`contactReqId`): `Promise`\<`Contact`\> -Defined in: [src/api.ts:731](../src/api.ts#L731) +Defined in: [src/api.ts:750](../src/api.ts#L750) Accept contact request. Network usage: interactive. @@ -93,7 +93,7 @@ Network usage: interactive. > **apiAcceptMember**(`groupId`, `groupMemberId`, `memberRole`): `Promise`\<`GroupMember`\> -Defined in: [src/api.ts:551](../src/api.ts#L551) +Defined in: [src/api.ts:570](../src/api.ts#L570) Accept group member. Requires Admin role. Network usage: background. @@ -122,7 +122,7 @@ Network usage: background. > **apiAddMember**(`groupId`, `contactId`, `memberRole`): `Promise`\<`GroupMember`\> -Defined in: [src/api.ts:531](../src/api.ts#L531) +Defined in: [src/api.ts:550](../src/api.ts#L550) Add contact to group. Requires bot to have Admin role. Network usage: interactive. @@ -151,7 +151,7 @@ Network usage: interactive. > **apiBlockMembersForAll**(`groupId`, `groupMemberIds`, `blocked`): `Promise`\<`void`\> -Defined in: [src/api.ts:571](../src/api.ts#L571) +Defined in: [src/api.ts:590](../src/api.ts#L590) Block members. Requires Moderator role. Network usage: background. @@ -180,7 +180,7 @@ Network usage: background. > **apiCancelFile**(`fileId`): `Promise`\<`void`\> -Defined in: [src/api.ts:521](../src/api.ts#L521) +Defined in: [src/api.ts:540](../src/api.ts#L540) Cancel file. Network usage: background. @@ -199,9 +199,9 @@ Network usage: background. ### apiChatItemReaction() -> **apiChatItemReaction**(`chatType`, `chatId`, `chatItemId`, `add`, `reaction`): `Promise`\<`ChatItemDeletion`[]\> +> **apiChatItemReaction**(`chatType`, `chatId`, `chatItemId`, `add`, `reaction`): `Promise`\<`ACIReaction`\> -Defined in: [src/api.ts:495](../src/api.ts#L495) +Defined in: [src/api.ts:513](../src/api.ts#L513) Add/remove message reaction. Network usage: background. @@ -230,15 +230,15 @@ Network usage: background. #### Returns -`Promise`\<`ChatItemDeletion`[]\> +`Promise`\<`ACIReaction`\> *** ### apiConnect() -> **apiConnect**(`userId`, `incognito`, `preparedLink?`): `Promise`\<[`ConnReqType`](api.Enumeration.ConnReqType.md)\> +> **apiConnect**(`userId`, `incognito`, `preparedLink`): `Promise`\<[`ConnReqType`](api.Enumeration.ConnReqType.md)\> -Defined in: [src/api.ts:700](../src/api.ts#L700) +Defined in: [src/api.ts:719](../src/api.ts#L719) Connect via prepared SimpleX link. The link can be 1-time invitation link, contact address or group link Network usage: interactive. @@ -253,7 +253,7 @@ Network usage: interactive. `boolean` -##### preparedLink? +##### preparedLink `CreatedConnLink` @@ -267,7 +267,7 @@ Network usage: interactive. > **apiConnectActiveUser**(`connLink`): `Promise`\<[`ConnReqType`](api.Enumeration.ConnReqType.md)\> -Defined in: [src/api.ts:709](../src/api.ts#L709) +Defined in: [src/api.ts:728](../src/api.ts#L728) Connect via SimpleX link as string in the active user profile. Network usage: interactive. @@ -288,7 +288,7 @@ Network usage: interactive. > **apiConnectPlan**(`userId`, `connectionLink`): `Promise`\<\[`ConnectionPlan`, `CreatedConnLink`\]\> -Defined in: [src/api.ts:690](../src/api.ts#L690) +Defined in: [src/api.ts:709](../src/api.ts#L709) Determine SimpleX link type and if the bot is already connected via this link. Network usage: interactive. @@ -313,7 +313,7 @@ Network usage: interactive. > **apiCreateActiveUser**(`profile?`): `Promise`\<`User`\> -Defined in: [src/api.ts:849](../src/api.ts#L849) +Defined in: [src/api.ts:887](../src/api.ts#L887) Create new user profile Network usage: no. @@ -334,7 +334,7 @@ Network usage: no. > **apiCreateGroupLink**(`groupId`, `memberRole`): `Promise`\<`string`\> -Defined in: [src/api.ts:631](../src/api.ts#L631) +Defined in: [src/api.ts:650](../src/api.ts#L650) Create group link. Network usage: interactive. @@ -359,7 +359,7 @@ Network usage: interactive. > **apiCreateLink**(`userId`): `Promise`\<`string`\> -Defined in: [src/api.ts:677](../src/api.ts#L677) +Defined in: [src/api.ts:696](../src/api.ts#L696) Create 1-time invitation link. Network usage: interactive. @@ -380,7 +380,7 @@ Network usage: interactive. > **apiCreateMemberContact**(`groupId`, `groupMemberId`): `Promise`\<`Contact`\> -Defined in: [src/api.ts:915](../src/api.ts#L915) +Defined in: [src/api.ts:953](../src/api.ts#L953) Create a direct message contact with a group member. Returns the created contact. @@ -406,7 +406,7 @@ Network usage: interactive. > **apiCreateUserAddress**(`userId`): `Promise`\<`CreatedConnLink`\> -Defined in: [src/api.ts:346](../src/api.ts#L346) +Defined in: [src/api.ts:361](../src/api.ts#L361) Create bot address. Network usage: interactive. @@ -427,7 +427,7 @@ Network usage: interactive. > **apiDeleteChat**(`chatType`, `chatId`, `deleteMode?`): `Promise`\<`void`\> -Defined in: [src/api.ts:771](../src/api.ts#L771) +Defined in: [src/api.ts:809](../src/api.ts#L809) Delete chat. Network usage: background. @@ -456,7 +456,7 @@ Network usage: background. > **apiDeleteChatItems**(`chatType`, `chatId`, `chatItemIds`, `deleteMode`): `Promise`\<`ChatItemDeletion`[]\> -Defined in: [src/api.ts:470](../src/api.ts#L470) +Defined in: [src/api.ts:488](../src/api.ts#L488) Delete message. Network usage: background. @@ -489,7 +489,7 @@ Network usage: background. > **apiDeleteGroupLink**(`groupId`): `Promise`\<`void`\> -Defined in: [src/api.ts:653](../src/api.ts#L653) +Defined in: [src/api.ts:672](../src/api.ts#L672) Delete group link. Network usage: background. @@ -510,7 +510,7 @@ Network usage: background. > **apiDeleteMemberChatItem**(`groupId`, `chatItemIds`): `Promise`\<`ChatItemDeletion`[]\> -Defined in: [src/api.ts:485](../src/api.ts#L485) +Defined in: [src/api.ts:503](../src/api.ts#L503) Moderate message. Requires Moderator role (and higher than message author's). Network usage: background. @@ -535,7 +535,7 @@ Network usage: background. > **apiDeleteUser**(`userId`, `delSMPQueues`, `viewPwd?`): `Promise`\<`void`\> -Defined in: [src/api.ts:879](../src/api.ts#L879) +Defined in: [src/api.ts:917](../src/api.ts#L917) Delete user profile. Network usage: background. @@ -564,7 +564,7 @@ Network usage: background. > **apiDeleteUserAddress**(`userId`): `Promise`\<`void`\> -Defined in: [src/api.ts:356](../src/api.ts#L356) +Defined in: [src/api.ts:371](../src/api.ts#L371) Deletes a user address. Network usage: background. @@ -585,7 +585,7 @@ Network usage: background. > **apiGetActiveUser**(): `Promise`\<`User` \| `undefined`\> -Defined in: [src/api.ts:829](../src/api.ts#L829) +Defined in: [src/api.ts:867](../src/api.ts#L867) Get active user profile Network usage: no. @@ -600,7 +600,7 @@ Network usage: no. > **apiGetChat**(`chatType`, `chatId`, `count`): `Promise`\<`any`\> -Defined in: [src/api.ts:819](../src/api.ts#L819) +Defined in: [src/api.ts:857](../src/api.ts#L857) Get chat items. Network usage: no. @@ -625,11 +625,48 @@ Network usage: no. *** +### apiGetChats() + +> **apiGetChats**(`userId`, `pagination`, `query?`, `pendingConnections?`): `Promise`\<`AChat`[]\> + +Defined in: [src/api.ts:794](../src/api.ts#L794) + +Get chat previews (paginated). +Network usage: no. + +Prefer this over apiListContacts / apiListGroups for any scan: those +methods load every record into memory in a single response and will fail +on large databases. + +#### Parameters + +##### userId + +`number` + +##### pagination + +`Last` + +##### query? + +`ChatListQuery` = `...` + +##### pendingConnections? + +`boolean` = `false` + +#### Returns + +`Promise`\<`AChat`[]\> + +*** + ### apiGetGroupLink() > **apiGetGroupLink**(`groupId`): `Promise`\<`GroupLink`\> -Defined in: [src/api.ts:662](../src/api.ts#L662) +Defined in: [src/api.ts:681](../src/api.ts#L681) Get group link. Network usage: no. @@ -650,7 +687,7 @@ Network usage: no. > **apiGetGroupLinkStr**(`groupId`): `Promise`\<`string`\> -Defined in: [src/api.ts:668](../src/api.ts#L668) +Defined in: [src/api.ts:687](../src/api.ts#L687) #### Parameters @@ -668,7 +705,7 @@ Defined in: [src/api.ts:668](../src/api.ts#L668) > **apiGetUserAddress**(`userId`): `Promise`\<`UserContactLink` \| `undefined`\> -Defined in: [src/api.ts:366](../src/api.ts#L366) +Defined in: [src/api.ts:381](../src/api.ts#L381) Get bot address and settings. Network usage: no. @@ -689,7 +726,7 @@ Network usage: no. > **apiJoinGroup**(`groupId`): `Promise`\<`GroupInfo`\> -Defined in: [src/api.ts:541](../src/api.ts#L541) +Defined in: [src/api.ts:560](../src/api.ts#L560) Join group. Network usage: interactive. @@ -710,7 +747,7 @@ Network usage: interactive. > **apiLeaveGroup**(`groupId`): `Promise`\<`GroupInfo`\> -Defined in: [src/api.ts:591](../src/api.ts#L591) +Defined in: [src/api.ts:610](../src/api.ts#L610) Leave group. Network usage: background. @@ -731,7 +768,7 @@ Network usage: background. > **apiListContacts**(`userId`): `Promise`\<`Contact`[]\> -Defined in: [src/api.ts:751](../src/api.ts#L751) +Defined in: [src/api.ts:770](../src/api.ts#L770) Get contacts. Network usage: no. @@ -752,7 +789,7 @@ Network usage: no. > **apiListGroups**(`userId`, `contactId?`, `search?`): `Promise`\<`GroupInfo`[]\> -Defined in: [src/api.ts:761](../src/api.ts#L761) +Defined in: [src/api.ts:780](../src/api.ts#L780) Get groups. Network usage: no. @@ -781,7 +818,7 @@ Network usage: no. > **apiListMembers**(`groupId`): `Promise`\<`GroupMember`[]\> -Defined in: [src/api.ts:601](../src/api.ts#L601) +Defined in: [src/api.ts:620](../src/api.ts#L620) Get group members. Network usage: no. @@ -802,7 +839,7 @@ Network usage: no. > **apiListUsers**(): `Promise`\<`UserInfo`[]\> -Defined in: [src/api.ts:859](../src/api.ts#L859) +Defined in: [src/api.ts:897](../src/api.ts#L897) Get all user profiles Network usage: no. @@ -817,7 +854,7 @@ Network usage: no. > **apiNewGroup**(`userId`, `groupProfile`): `Promise`\<`GroupInfo`\> -Defined in: [src/api.ts:611](../src/api.ts#L611) +Defined in: [src/api.ts:630](../src/api.ts#L630) Create group. Network usage: no. @@ -842,7 +879,7 @@ Network usage: no. > **apiReceiveFile**(`fileId`): `Promise`\<`AChatItem`\> -Defined in: [src/api.ts:511](../src/api.ts#L511) +Defined in: [src/api.ts:529](../src/api.ts#L529) Receive file. Network usage: no. @@ -863,7 +900,7 @@ Network usage: no. > **apiRejectContactRequest**(`contactReqId`): `Promise`\<`void`\> -Defined in: [src/api.ts:741](../src/api.ts#L741) +Defined in: [src/api.ts:760](../src/api.ts#L760) Reject contact request. The user who sent the request is **not notified**. Network usage: no. @@ -884,7 +921,7 @@ Network usage: no. > **apiRemoveMembers**(`groupId`, `memberIds`, `withMessages?`): `Promise`\<`GroupMember`[]\> -Defined in: [src/api.ts:581](../src/api.ts#L581) +Defined in: [src/api.ts:600](../src/api.ts#L600) Remove members. Requires Admin role. Network usage: background. @@ -913,7 +950,7 @@ Network usage: background. > **apiSendMemberContactInvitation**(`contactId`, `message?`): `Promise`\<`Contact`\> -Defined in: [src/api.ts:926](../src/api.ts#L926) +Defined in: [src/api.ts:964](../src/api.ts#L964) Send a direct message invitation to a group member contact. The contact must have been created with [apiCreateMemberContact](#apicreatemembercontact). @@ -939,7 +976,7 @@ Network usage: interactive. > **apiSendMessages**(`chat`, `messages`, `liveMessage?`): `Promise`\<`AChatItem`[]\> -Defined in: [src/api.ts:415](../src/api.ts#L415) +Defined in: [src/api.ts:432](../src/api.ts#L432) Send messages. Network usage: background. @@ -968,7 +1005,7 @@ Network usage: background. > **apiSendTextMessage**(`chat`, `text`, `inReplyTo?`): `Promise`\<`AChatItem`[]\> -Defined in: [src/api.ts:437](../src/api.ts#L437) +Defined in: [src/api.ts:455](../src/api.ts#L455) Send text message. Network usage: background. @@ -997,7 +1034,7 @@ Network usage: background. > **apiSendTextReply**(`chatItem`, `text`): `Promise`\<`AChatItem`[]\> -Defined in: [src/api.ts:445](../src/api.ts#L445) +Defined in: [src/api.ts:463](../src/api.ts#L463) Send text message in reply to received message. Network usage: background. @@ -1022,7 +1059,7 @@ Network usage: background. > **apiSetActiveUser**(`userId`, `viewPwd?`): `Promise`\<`User`\> -Defined in: [src/api.ts:869](../src/api.ts#L869) +Defined in: [src/api.ts:907](../src/api.ts#L907) Set active user profile Network usage: no. @@ -1047,7 +1084,7 @@ Network usage: no. > **apiSetAddressSettings**(`userId`, `__namedParameters`): `Promise`\<`void`\> -Defined in: [src/api.ts:398](../src/api.ts#L398) +Defined in: [src/api.ts:415](../src/api.ts#L415) Set bot address settings. Network usage: interactive. @@ -1072,7 +1109,7 @@ Network usage: interactive. > **apiSetAutoAcceptMemberContacts**(`userId`, `onOff`): `Promise`\<`void`\> -Defined in: [src/api.ts:808](../src/api.ts#L808) +Defined in: [src/api.ts:846](../src/api.ts#L846) Set auto-accept member contacts. Network usage: no. @@ -1097,7 +1134,7 @@ Network usage: no. > **apiSetContactCustomData**(`contactId`, `customData?`): `Promise`\<`void`\> -Defined in: [src/api.ts:798](../src/api.ts#L798) +Defined in: [src/api.ts:836](../src/api.ts#L836) Set contact custom data. Network usage: no. @@ -1122,7 +1159,7 @@ Network usage: no. > **apiSetContactPrefs**(`contactId`, `preferences`): `Promise`\<`void`\> -Defined in: [src/api.ts:905](../src/api.ts#L905) +Defined in: [src/api.ts:943](../src/api.ts#L943) Configure chat preference overrides for the contact. Network usage: background. @@ -1147,7 +1184,7 @@ Network usage: background. > **apiSetGroupCustomData**(`groupId`, `customData?`): `Promise`\<`void`\> -Defined in: [src/api.ts:788](../src/api.ts#L788) +Defined in: [src/api.ts:826](../src/api.ts#L826) Set group custom data. Network usage: no. @@ -1172,7 +1209,7 @@ Network usage: no. > **apiSetGroupLinkMemberRole**(`groupId`, `memberRole`): `Promise`\<`void`\> -Defined in: [src/api.ts:644](../src/api.ts#L644) +Defined in: [src/api.ts:663](../src/api.ts#L663) Set member role for group link. Network usage: no. @@ -1197,7 +1234,7 @@ Network usage: no. > **apiSetMembersRole**(`groupId`, `groupMemberIds`, `memberRole`): `Promise`\<`void`\> -Defined in: [src/api.ts:561](../src/api.ts#L561) +Defined in: [src/api.ts:580](../src/api.ts#L580) Set members role. Requires Admin role. Network usage: background. @@ -1226,7 +1263,7 @@ Network usage: background. > **apiSetProfileAddress**(`userId`, `enable`): `Promise`\<`UserProfileUpdateSummary`\> -Defined in: [src/api.ts:384](../src/api.ts#L384) +Defined in: [src/api.ts:399](../src/api.ts#L399) Add address to bot profile. Network usage: interactive. @@ -1251,7 +1288,7 @@ Network usage: interactive. > **apiUpdateChatItem**(`chatType`, `chatId`, `chatItemId`, `msgContent`, `liveMessage`): `Promise`\<`ChatItem`\> -Defined in: [src/api.ts:453](../src/api.ts#L453) +Defined in: [src/api.ts:471](../src/api.ts#L471) Update message. Network usage: background. @@ -1288,7 +1325,7 @@ Network usage: background. > **apiUpdateGroupProfile**(`groupId`, `groupProfile`): `Promise`\<`GroupInfo`\> -Defined in: [src/api.ts:621](../src/api.ts#L621) +Defined in: [src/api.ts:640](../src/api.ts#L640) Update group profile. Network usage: background. @@ -1313,7 +1350,7 @@ Network usage: background. > **apiUpdateProfile**(`userId`, `profile`): `Promise`\<`UserProfileUpdateSummary` \| `undefined`\> -Defined in: [src/api.ts:889](../src/api.ts#L889) +Defined in: [src/api.ts:927](../src/api.ts#L927) Update user profile. Network usage: background. @@ -1338,9 +1375,10 @@ Network usage: background. > **close**(): `Promise`\<`void`\> -Defined in: [src/api.ts:148](../src/api.ts#L148) +Defined in: [src/api.ts:158](../src/api.ts#L158) -Close chat database. +Stop chat controller and close chat database. +The database is not closed if stopping fails. Usually doesn't need to be called in chat bots. #### Returns @@ -1353,7 +1391,7 @@ Usually doesn't need to be called in chat bots. > **off**\<`K`\>(`event`, `subscriber?`): `void` -Defined in: [src/api.ts:287](../src/api.ts#L287) +Defined in: [src/api.ts:302](../src/api.ts#L302) Unsubscribe all or a specific handler from a specific event. @@ -1387,7 +1425,7 @@ An optional subscriber function for the event. > **offAny**(`receiver?`): `void` -Defined in: [src/api.ts:303](../src/api.ts#L303) +Defined in: [src/api.ts:318](../src/api.ts#L318) Unsubscribe all or a specific handler from any events. @@ -1411,7 +1449,7 @@ An optional subscriber function for the event. > **on**\<`K`\>(`subscribers`): `void` -Defined in: [src/api.ts:197](../src/api.ts#L197) +Defined in: [src/api.ts:212](../src/api.ts#L212) Subscribe multiple event handlers at once. @@ -1441,7 +1479,7 @@ If the same function is subscribed to event. > **on**\<`K`\>(`event`, `subscriber`): `void` -Defined in: [src/api.ts:205](../src/api.ts#L205) +Defined in: [src/api.ts:220](../src/api.ts#L220) Subscribe a handler to a specific event. @@ -1479,7 +1517,7 @@ If the same function is subscribed to event. > **onAny**(`receiver`): `void` -Defined in: [src/api.ts:228](../src/api.ts#L228) +Defined in: [src/api.ts:243](../src/api.ts#L243) Subscribe a handler to any event. @@ -1505,7 +1543,7 @@ If the same function is subscribed to event. > **once**\<`K`\>(`event`, `subscriber`): `void` -Defined in: [src/api.ts:239](../src/api.ts#L239) +Defined in: [src/api.ts:254](../src/api.ts#L254) Subscribe a handler to a specific event to be delivered one time. @@ -1543,13 +1581,13 @@ If the same function is subscribed to event. > **recvChatEvent**(`wait?`): `Promise`\<`ChatEvent` \| `undefined`\> -Defined in: [src/api.ts:338](../src/api.ts#L338) +Defined in: [src/api.ts:353](../src/api.ts#L353) #### Parameters ##### wait? -`number` = `5_000_000` +`number` = `500_000` #### Returns @@ -1561,7 +1599,7 @@ Defined in: [src/api.ts:338](../src/api.ts#L338) > **sendChatCmd**(`cmd`): `Promise`\<`ChatResponse`\> -Defined in: [src/api.ts:334](../src/api.ts#L334) +Defined in: [src/api.ts:349](../src/api.ts#L349) #### Parameters @@ -1579,7 +1617,7 @@ Defined in: [src/api.ts:334](../src/api.ts#L334) > **startChat**(): `Promise`\<`void`\> -Defined in: [src/api.ts:122](../src/api.ts#L122) +Defined in: [src/api.ts:124](../src/api.ts#L124) Start chat controller. Must be called with the existing user profile. @@ -1593,10 +1631,10 @@ Start chat controller. Must be called with the existing user profile. > **stopChat**(): `Promise`\<`void`\> -Defined in: [src/api.ts:136](../src/api.ts#L136) +Defined in: [src/api.ts:147](../src/api.ts#L147) Stop chat controller. -Must be called before closing the database. +`close` calls it before closing the database. Usually doesn't need to be called in chat bots. #### Returns @@ -1611,7 +1649,7 @@ Usually doesn't need to be called in chat bots. > **wait**\<`K`\>(`event`): `Promise`\<`ChatEvent` & `object`\> -Defined in: [src/api.ts:247](../src/api.ts#L247) +Defined in: [src/api.ts:262](../src/api.ts#L262) Waits for specific event, with an optional predicate. Returns `undefined` on timeout if specified. @@ -1636,7 +1674,7 @@ Returns `undefined` on timeout if specified. > **wait**\<`K`\>(`event`, `predicate`): `Promise`\<`ChatEvent` & `object`\> -Defined in: [src/api.ts:248](../src/api.ts#L248) +Defined in: [src/api.ts:263](../src/api.ts#L263) Waits for specific event, with an optional predicate. Returns `undefined` on timeout if specified. @@ -1665,7 +1703,7 @@ Returns `undefined` on timeout if specified. > **wait**\<`K`\>(`event`, `timeout`): `Promise`\ -Defined in: [src/api.ts:249](../src/api.ts#L249) +Defined in: [src/api.ts:264](../src/api.ts#L264) Waits for specific event, with an optional predicate. Returns `undefined` on timeout if specified. @@ -1694,7 +1732,7 @@ Returns `undefined` on timeout if specified. > **wait**\<`K`\>(`event`, `predicate`, `timeout`): `Promise`\ -Defined in: [src/api.ts:250](../src/api.ts#L250) +Defined in: [src/api.ts:265](../src/api.ts#L265) Waits for specific event, with an optional predicate. Returns `undefined` on timeout if specified. @@ -1727,9 +1765,9 @@ Returns `undefined` on timeout if specified. ### init() -> `static` **init**(`db`, `confirm?`): `Promise`\<`ChatApi`\> +> `static` **init**(`db`, `confirm?`, `queueSize?`): `Promise`\<`ChatApi`\> -Defined in: [src/api.ts:110](../src/api.ts#L110) +Defined in: [src/api.ts:111](../src/api.ts#L111) Initializes the ChatApi. @@ -1747,6 +1785,12 @@ Database configuration (sqlite or postgres). Migration confirmation mode. +##### queueSize? + +`number` + +Size of internal queues, the core default is used when omitted. + #### Returns `Promise`\<`ChatApi`\> diff --git a/packages/simplex-chat-nodejs/docs/bot.Function.run.md b/packages/simplex-chat-nodejs/docs/bot.Function.run.md index 3c33e6c7d4..0af2644bc2 100644 --- a/packages/simplex-chat-nodejs/docs/bot.Function.run.md +++ b/packages/simplex-chat-nodejs/docs/bot.Function.run.md @@ -8,7 +8,7 @@ > **run**(`__namedParameters`): `Promise`\<\[[`ChatApi`](api.Class.ChatApi.md), `User`, `UserContactLink` \| `undefined`\]\> -Defined in: [src/bot.ts:47](../src/bot.ts#L47) +Defined in: [src/bot.ts:48](../src/bot.ts#L48) ## Parameters diff --git a/packages/simplex-chat-nodejs/docs/bot.Function.subscribeChatItems.md b/packages/simplex-chat-nodejs/docs/bot.Function.subscribeChatItems.md new file mode 100644 index 0000000000..5397cf3aeb --- /dev/null +++ b/packages/simplex-chat-nodejs/docs/bot.Function.subscribeChatItems.md @@ -0,0 +1,27 @@ +[**simplex-chat**](README.md) + +*** + +[simplex-chat](README.md) / [bot](Namespace.bot.md) / subscribeChatItems + +# Function: subscribeChatItems() + +> **subscribeChatItems**(`bot`, `onMessage`, `commands`): `void` + +Defined in: [src/bot.ts:108](../src/bot.ts#L108) + +## Parameters + +### bot + +[`ChatApi`](api.Class.ChatApi.md) + +### onMessage + +((`chatItem`, `content`) => `void` \| `Promise`\<`void`\>) \| `undefined` + +### commands + +## Returns + +`void` diff --git a/packages/simplex-chat-nodejs/docs/bot.Interface.BotConfig.md b/packages/simplex-chat-nodejs/docs/bot.Interface.BotConfig.md index 4624b1608b..f763f414da 100644 --- a/packages/simplex-chat-nodejs/docs/bot.Interface.BotConfig.md +++ b/packages/simplex-chat-nodejs/docs/bot.Interface.BotConfig.md @@ -6,7 +6,7 @@ # Interface: BotConfig -Defined in: [src/bot.ts:35](../src/bot.ts#L35) +Defined in: [src/bot.ts:36](../src/bot.ts#L36) ## Properties @@ -14,7 +14,7 @@ Defined in: [src/bot.ts:35](../src/bot.ts#L35) > **dbOpts**: [`BotDbOpts`](bot.TypeAlias.BotDbOpts.md) -Defined in: [src/bot.ts:37](../src/bot.ts#L37) +Defined in: [src/bot.ts:38](../src/bot.ts#L38) *** @@ -22,7 +22,7 @@ Defined in: [src/bot.ts:37](../src/bot.ts#L37) > `optional` **events?**: [`EventSubscribers`](api.TypeAlias.EventSubscribers.md) -Defined in: [src/bot.ts:44](../src/bot.ts#L44) +Defined in: [src/bot.ts:45](../src/bot.ts#L45) *** @@ -30,7 +30,7 @@ Defined in: [src/bot.ts:44](../src/bot.ts#L44) > `optional` **onCommands?**: `object` -Defined in: [src/bot.ts:41](../src/bot.ts#L41) +Defined in: [src/bot.ts:42](../src/bot.ts#L42) #### Index Signature @@ -42,7 +42,7 @@ Defined in: [src/bot.ts:41](../src/bot.ts#L41) > `optional` **onMessage?**: (`chatItem`, `content`) => `void` \| `Promise`\<`void`\> -Defined in: [src/bot.ts:39](../src/bot.ts#L39) +Defined in: [src/bot.ts:40](../src/bot.ts#L40) #### Parameters @@ -64,7 +64,7 @@ Defined in: [src/bot.ts:39](../src/bot.ts#L39) > **options**: [`BotOptions`](bot.Interface.BotOptions.md) -Defined in: [src/bot.ts:38](../src/bot.ts#L38) +Defined in: [src/bot.ts:39](../src/bot.ts#L39) *** @@ -72,4 +72,4 @@ Defined in: [src/bot.ts:38](../src/bot.ts#L38) > **profile**: `Profile` -Defined in: [src/bot.ts:36](../src/bot.ts#L36) +Defined in: [src/bot.ts:37](../src/bot.ts#L37) diff --git a/packages/simplex-chat-nodejs/docs/bot.Interface.BotOptions.md b/packages/simplex-chat-nodejs/docs/bot.Interface.BotOptions.md index eee56b879a..c5d5df5193 100644 --- a/packages/simplex-chat-nodejs/docs/bot.Interface.BotOptions.md +++ b/packages/simplex-chat-nodejs/docs/bot.Interface.BotOptions.md @@ -6,7 +6,7 @@ # Interface: BotOptions -Defined in: [src/bot.ts:11](../src/bot.ts#L11) +Defined in: [src/bot.ts:12](../src/bot.ts#L12) ## Properties @@ -14,7 +14,7 @@ Defined in: [src/bot.ts:11](../src/bot.ts#L11) > `optional` **addressSettings?**: [`BotAddressSettings`](api.Interface.BotAddressSettings.md) -Defined in: [src/bot.ts:15](../src/bot.ts#L15) +Defined in: [src/bot.ts:16](../src/bot.ts#L16) *** @@ -22,7 +22,7 @@ Defined in: [src/bot.ts:15](../src/bot.ts#L15) > `optional` **allowFiles?**: `boolean` -Defined in: [src/bot.ts:16](../src/bot.ts#L16) +Defined in: [src/bot.ts:17](../src/bot.ts#L17) *** @@ -30,7 +30,7 @@ Defined in: [src/bot.ts:16](../src/bot.ts#L16) > `optional` **commands?**: `ChatBotCommand`[] -Defined in: [src/bot.ts:17](../src/bot.ts#L17) +Defined in: [src/bot.ts:18](../src/bot.ts#L18) *** @@ -38,7 +38,7 @@ Defined in: [src/bot.ts:17](../src/bot.ts#L17) > `optional` **createAddress?**: `boolean` -Defined in: [src/bot.ts:12](../src/bot.ts#L12) +Defined in: [src/bot.ts:13](../src/bot.ts#L13) *** @@ -46,7 +46,7 @@ Defined in: [src/bot.ts:12](../src/bot.ts#L12) > `optional` **logContacts?**: `boolean` -Defined in: [src/bot.ts:19](../src/bot.ts#L19) +Defined in: [src/bot.ts:20](../src/bot.ts#L20) *** @@ -54,7 +54,7 @@ Defined in: [src/bot.ts:19](../src/bot.ts#L19) > `optional` **logNetwork?**: `boolean` -Defined in: [src/bot.ts:20](../src/bot.ts#L20) +Defined in: [src/bot.ts:21](../src/bot.ts#L21) *** @@ -62,7 +62,7 @@ Defined in: [src/bot.ts:20](../src/bot.ts#L20) > `optional` **updateAddress?**: `boolean` -Defined in: [src/bot.ts:13](../src/bot.ts#L13) +Defined in: [src/bot.ts:14](../src/bot.ts#L14) *** @@ -70,7 +70,7 @@ Defined in: [src/bot.ts:13](../src/bot.ts#L13) > `optional` **updateProfile?**: `boolean` -Defined in: [src/bot.ts:14](../src/bot.ts#L14) +Defined in: [src/bot.ts:15](../src/bot.ts#L15) *** @@ -78,4 +78,4 @@ Defined in: [src/bot.ts:14](../src/bot.ts#L14) > `optional` **useBotProfile?**: `boolean` -Defined in: [src/bot.ts:18](../src/bot.ts#L18) +Defined in: [src/bot.ts:19](../src/bot.ts#L19) diff --git a/packages/simplex-chat-nodejs/docs/bot.TypeAlias.BotDbOpts.md b/packages/simplex-chat-nodejs/docs/bot.TypeAlias.BotDbOpts.md index b035f41355..5aeb298a09 100644 --- a/packages/simplex-chat-nodejs/docs/bot.TypeAlias.BotDbOpts.md +++ b/packages/simplex-chat-nodejs/docs/bot.TypeAlias.BotDbOpts.md @@ -15,3 +15,7 @@ Defined in: [src/bot.ts:7](../src/bot.ts#L7) ### confirmMigrations? > `optional` **confirmMigrations?**: [`MigrationConfirmation`](core.Enumeration.MigrationConfirmation.md) + +### queueSize? + +> `optional` **queueSize?**: `number` diff --git a/packages/simplex-chat-nodejs/docs/core.Class.ChatAPIError.md b/packages/simplex-chat-nodejs/docs/core.Class.ChatAPIError.md index 5bd0722f0c..3953d777da 100644 --- a/packages/simplex-chat-nodejs/docs/core.Class.ChatAPIError.md +++ b/packages/simplex-chat-nodejs/docs/core.Class.ChatAPIError.md @@ -6,7 +6,7 @@ # Class: ChatAPIError -Defined in: [src/core.ts:92](../src/core.ts#L92) +Defined in: [src/core.ts:95](../src/core.ts#L95) ## Extends @@ -18,7 +18,7 @@ Defined in: [src/core.ts:92](../src/core.ts#L92) > **new ChatAPIError**(`message`, `chatError?`): `ChatAPIError` -Defined in: [src/core.ts:93](../src/core.ts#L93) +Defined in: [src/core.ts:96](../src/core.ts#L96) #### Parameters @@ -44,7 +44,7 @@ Defined in: [src/core.ts:93](../src/core.ts#L93) > **chatError**: `ChatError` \| `undefined` = `undefined` -Defined in: [src/core.ts:93](../src/core.ts#L93) +Defined in: [src/core.ts:96](../src/core.ts#L96) *** @@ -52,7 +52,7 @@ Defined in: [src/core.ts:93](../src/core.ts#L93) > **message**: `string` -Defined in: [src/core.ts:93](../src/core.ts#L93) +Defined in: [src/core.ts:96](../src/core.ts#L96) #### Inherited from diff --git a/packages/simplex-chat-nodejs/docs/core.Class.ChatInitError.md b/packages/simplex-chat-nodejs/docs/core.Class.ChatInitError.md index 0feceae4fd..649090e3ac 100644 --- a/packages/simplex-chat-nodejs/docs/core.Class.ChatInitError.md +++ b/packages/simplex-chat-nodejs/docs/core.Class.ChatInitError.md @@ -6,7 +6,7 @@ # Class: ChatInitError -Defined in: [src/core.ts:116](../src/core.ts#L116) +Defined in: [src/core.ts:119](../src/core.ts#L119) ## Extends @@ -18,7 +18,7 @@ Defined in: [src/core.ts:116](../src/core.ts#L116) > **new ChatInitError**(`message`, `dbMigrationError`): `ChatInitError` -Defined in: [src/core.ts:117](../src/core.ts#L117) +Defined in: [src/core.ts:120](../src/core.ts#L120) #### Parameters @@ -44,7 +44,7 @@ Defined in: [src/core.ts:117](../src/core.ts#L117) > **dbMigrationError**: [`DBMigrationError`](core.TypeAlias.DBMigrationError.md) -Defined in: [src/core.ts:117](../src/core.ts#L117) +Defined in: [src/core.ts:120](../src/core.ts#L120) *** @@ -52,7 +52,7 @@ Defined in: [src/core.ts:117](../src/core.ts#L117) > **message**: `string` -Defined in: [src/core.ts:117](../src/core.ts#L117) +Defined in: [src/core.ts:120](../src/core.ts#L120) #### Inherited from diff --git a/packages/simplex-chat-nodejs/docs/core.DBMigrationError.Interface.ErrorMigration.md b/packages/simplex-chat-nodejs/docs/core.DBMigrationError.Interface.ErrorMigration.md index 02cf84b763..fea7bfb531 100644 --- a/packages/simplex-chat-nodejs/docs/core.DBMigrationError.Interface.ErrorMigration.md +++ b/packages/simplex-chat-nodejs/docs/core.DBMigrationError.Interface.ErrorMigration.md @@ -6,7 +6,7 @@ # Interface: ErrorMigration -Defined in: [src/core.ts:144](../src/core.ts#L144) +Defined in: [src/core.ts:152](../src/core.ts#L152) ## Extends @@ -18,7 +18,7 @@ Defined in: [src/core.ts:144](../src/core.ts#L144) > **dbFile**: `string` -Defined in: [src/core.ts:146](../src/core.ts#L146) +Defined in: [src/core.ts:154](../src/core.ts#L154) *** @@ -26,7 +26,7 @@ Defined in: [src/core.ts:146](../src/core.ts#L146) > **migrationError**: [`MigrationError`](core.TypeAlias.MigrationError.md) -Defined in: [src/core.ts:147](../src/core.ts#L147) +Defined in: [src/core.ts:155](../src/core.ts#L155) *** @@ -34,7 +34,7 @@ Defined in: [src/core.ts:147](../src/core.ts#L147) > **type**: `"errorMigration"` -Defined in: [src/core.ts:145](../src/core.ts#L145) +Defined in: [src/core.ts:153](../src/core.ts#L153) #### Overrides diff --git a/packages/simplex-chat-nodejs/docs/core.DBMigrationError.Interface.ErrorNotADatabase.md b/packages/simplex-chat-nodejs/docs/core.DBMigrationError.Interface.ErrorNotADatabase.md index 18e2429081..eb0bfb11cb 100644 --- a/packages/simplex-chat-nodejs/docs/core.DBMigrationError.Interface.ErrorNotADatabase.md +++ b/packages/simplex-chat-nodejs/docs/core.DBMigrationError.Interface.ErrorNotADatabase.md @@ -6,7 +6,7 @@ # Interface: ErrorNotADatabase -Defined in: [src/core.ts:139](../src/core.ts#L139) +Defined in: [src/core.ts:147](../src/core.ts#L147) ## Extends @@ -18,7 +18,7 @@ Defined in: [src/core.ts:139](../src/core.ts#L139) > **dbFile**: `string` -Defined in: [src/core.ts:141](../src/core.ts#L141) +Defined in: [src/core.ts:149](../src/core.ts#L149) *** @@ -26,7 +26,7 @@ Defined in: [src/core.ts:141](../src/core.ts#L141) > **type**: `"errorNotADatabase"` -Defined in: [src/core.ts:140](../src/core.ts#L140) +Defined in: [src/core.ts:148](../src/core.ts#L148) #### Overrides diff --git a/packages/simplex-chat-nodejs/docs/core.DBMigrationError.Interface.ErrorSQL.md b/packages/simplex-chat-nodejs/docs/core.DBMigrationError.Interface.ErrorSQL.md index 4d85b04197..ffaa364f9b 100644 --- a/packages/simplex-chat-nodejs/docs/core.DBMigrationError.Interface.ErrorSQL.md +++ b/packages/simplex-chat-nodejs/docs/core.DBMigrationError.Interface.ErrorSQL.md @@ -6,7 +6,7 @@ # Interface: ErrorSQL -Defined in: [src/core.ts:150](../src/core.ts#L150) +Defined in: [src/core.ts:158](../src/core.ts#L158) ## Extends @@ -18,7 +18,7 @@ Defined in: [src/core.ts:150](../src/core.ts#L150) > **dbFile**: `string` -Defined in: [src/core.ts:152](../src/core.ts#L152) +Defined in: [src/core.ts:160](../src/core.ts#L160) *** @@ -26,7 +26,7 @@ Defined in: [src/core.ts:152](../src/core.ts#L152) > **migrationSQLError**: `string` -Defined in: [src/core.ts:153](../src/core.ts#L153) +Defined in: [src/core.ts:161](../src/core.ts#L161) *** @@ -34,7 +34,7 @@ Defined in: [src/core.ts:153](../src/core.ts#L153) > **type**: `"errorSQL"` -Defined in: [src/core.ts:151](../src/core.ts#L151) +Defined in: [src/core.ts:159](../src/core.ts#L159) #### Overrides diff --git a/packages/simplex-chat-nodejs/docs/core.DBMigrationError.Interface.InvalidConfirmation.md b/packages/simplex-chat-nodejs/docs/core.DBMigrationError.Interface.InvalidConfirmation.md index 34dea63aed..67711030da 100644 --- a/packages/simplex-chat-nodejs/docs/core.DBMigrationError.Interface.InvalidConfirmation.md +++ b/packages/simplex-chat-nodejs/docs/core.DBMigrationError.Interface.InvalidConfirmation.md @@ -6,7 +6,7 @@ # Interface: InvalidConfirmation -Defined in: [src/core.ts:135](../src/core.ts#L135) +Defined in: [src/core.ts:139](../src/core.ts#L139) ## Extends @@ -18,7 +18,7 @@ Defined in: [src/core.ts:135](../src/core.ts#L135) > **type**: `"invalidConfirmation"` -Defined in: [src/core.ts:136](../src/core.ts#L136) +Defined in: [src/core.ts:140](../src/core.ts#L140) #### Overrides diff --git a/packages/simplex-chat-nodejs/docs/core.DBMigrationError.Interface.InvalidQueueSize.md b/packages/simplex-chat-nodejs/docs/core.DBMigrationError.Interface.InvalidQueueSize.md new file mode 100644 index 0000000000..9093b5e9b1 --- /dev/null +++ b/packages/simplex-chat-nodejs/docs/core.DBMigrationError.Interface.InvalidQueueSize.md @@ -0,0 +1,25 @@ +[**simplex-chat**](README.md) + +*** + +[simplex-chat](README.md) / [core](Namespace.core.md) / [DBMigrationError](core.Namespace.DBMigrationError.md) / InvalidQueueSize + +# Interface: InvalidQueueSize + +Defined in: [src/core.ts:143](../src/core.ts#L143) + +## Extends + +- `Interface` + +## Properties + +### type + +> **type**: `"invalidQueueSize"` + +Defined in: [src/core.ts:144](../src/core.ts#L144) + +#### Overrides + +`Interface.type` diff --git a/packages/simplex-chat-nodejs/docs/core.DBMigrationError.TypeAlias.Tag.md b/packages/simplex-chat-nodejs/docs/core.DBMigrationError.TypeAlias.Tag.md index a3ef341601..b04f2f5af1 100644 --- a/packages/simplex-chat-nodejs/docs/core.DBMigrationError.TypeAlias.Tag.md +++ b/packages/simplex-chat-nodejs/docs/core.DBMigrationError.TypeAlias.Tag.md @@ -6,6 +6,6 @@ # Type Alias: Tag -> **Tag** = `"invalidConfirmation"` \| `"errorNotADatabase"` \| `"errorMigration"` \| `"errorSQL"` +> **Tag** = `"invalidConfirmation"` \| `"invalidQueueSize"` \| `"errorNotADatabase"` \| `"errorMigration"` \| `"errorSQL"` -Defined in: [src/core.ts:129](../src/core.ts#L129) +Defined in: [src/core.ts:133](../src/core.ts#L133) diff --git a/packages/simplex-chat-nodejs/docs/core.Enumeration.MigrationConfirmation.md b/packages/simplex-chat-nodejs/docs/core.Enumeration.MigrationConfirmation.md index 7dfd4991bf..48e250c7d4 100644 --- a/packages/simplex-chat-nodejs/docs/core.Enumeration.MigrationConfirmation.md +++ b/packages/simplex-chat-nodejs/docs/core.Enumeration.MigrationConfirmation.md @@ -6,7 +6,7 @@ # Enumeration: MigrationConfirmation -Defined in: [src/core.ts:101](../src/core.ts#L101) +Defined in: [src/core.ts:104](../src/core.ts#L104) Migration confirmation mode @@ -16,7 +16,7 @@ Migration confirmation mode > **Console**: `"console"` -Defined in: [src/core.ts:104](../src/core.ts#L104) +Defined in: [src/core.ts:107](../src/core.ts#L107) *** @@ -24,7 +24,7 @@ Defined in: [src/core.ts:104](../src/core.ts#L104) > **Error**: `"error"` -Defined in: [src/core.ts:105](../src/core.ts#L105) +Defined in: [src/core.ts:108](../src/core.ts#L108) *** @@ -32,7 +32,7 @@ Defined in: [src/core.ts:105](../src/core.ts#L105) > **YesUp**: `"yesUp"` -Defined in: [src/core.ts:102](../src/core.ts#L102) +Defined in: [src/core.ts:105](../src/core.ts#L105) *** @@ -40,4 +40,4 @@ Defined in: [src/core.ts:102](../src/core.ts#L102) > **YesUpDown**: `"yesUpDown"` -Defined in: [src/core.ts:103](../src/core.ts#L103) +Defined in: [src/core.ts:106](../src/core.ts#L106) diff --git a/packages/simplex-chat-nodejs/docs/core.Function.chatCloseStore.md b/packages/simplex-chat-nodejs/docs/core.Function.chatCloseStore.md index deeb3213fd..4b0324b92c 100644 --- a/packages/simplex-chat-nodejs/docs/core.Function.chatCloseStore.md +++ b/packages/simplex-chat-nodejs/docs/core.Function.chatCloseStore.md @@ -8,7 +8,7 @@ > **chatCloseStore**(`ctrl`): `Promise`\<`void`\> -Defined in: [src/core.ts:17](../src/core.ts#L17) +Defined in: [src/core.ts:20](../src/core.ts#L20) Close chat store diff --git a/packages/simplex-chat-nodejs/docs/core.Function.chatDecryptFile.md b/packages/simplex-chat-nodejs/docs/core.Function.chatDecryptFile.md index 434aeeaae8..b6786fe1cb 100644 --- a/packages/simplex-chat-nodejs/docs/core.Function.chatDecryptFile.md +++ b/packages/simplex-chat-nodejs/docs/core.Function.chatDecryptFile.md @@ -8,7 +8,7 @@ > **chatDecryptFile**(`fromPath`, `__namedParameters`, `toPath`): `Promise`\<`void`\> -Defined in: [src/core.ts:73](../src/core.ts#L73) +Defined in: [src/core.ts:76](../src/core.ts#L76) Decrypt file diff --git a/packages/simplex-chat-nodejs/docs/core.Function.chatEncryptFile.md b/packages/simplex-chat-nodejs/docs/core.Function.chatEncryptFile.md index 6aa0ad2923..9e30e55a23 100644 --- a/packages/simplex-chat-nodejs/docs/core.Function.chatEncryptFile.md +++ b/packages/simplex-chat-nodejs/docs/core.Function.chatEncryptFile.md @@ -8,7 +8,7 @@ > **chatEncryptFile**(`ctrl`, `fromPath`, `toPath`): `Promise`\<[`CryptoArgs`](core.Interface.CryptoArgs.md)\> -Defined in: [src/core.ts:65](../src/core.ts#L65) +Defined in: [src/core.ts:68](../src/core.ts#L68) Encrypt file diff --git a/packages/simplex-chat-nodejs/docs/core.Function.chatMigrateInit.md b/packages/simplex-chat-nodejs/docs/core.Function.chatMigrateInit.md index 9116026f56..dbe4912520 100644 --- a/packages/simplex-chat-nodejs/docs/core.Function.chatMigrateInit.md +++ b/packages/simplex-chat-nodejs/docs/core.Function.chatMigrateInit.md @@ -6,9 +6,9 @@ # Function: chatMigrateInit() -> **chatMigrateInit**(`dbPath`, `dbKey`, `confirm`): `Promise`\<`bigint`\> +> **chatMigrateInit**(`dbPath`, `dbKey`, `confirm`, `queueSize?`): `Promise`\<`bigint`\> -Defined in: [src/core.ts:7](../src/core.ts#L7) +Defined in: [src/core.ts:8](../src/core.ts#L8) Initialize chat controller @@ -26,6 +26,12 @@ Initialize chat controller [`MigrationConfirmation`](core.Enumeration.MigrationConfirmation.md) +### queueSize? + +`number` + +Size of internal queues, the core default is used when omitted. + ## Returns `Promise`\<`bigint`\> diff --git a/packages/simplex-chat-nodejs/docs/core.Function.chatReadFile.md b/packages/simplex-chat-nodejs/docs/core.Function.chatReadFile.md index 27de43e63c..f6713f22d8 100644 --- a/packages/simplex-chat-nodejs/docs/core.Function.chatReadFile.md +++ b/packages/simplex-chat-nodejs/docs/core.Function.chatReadFile.md @@ -6,9 +6,9 @@ # Function: chatReadFile() -> **chatReadFile**(`path`, `__namedParameters`): `Promise`\<`ArrayBuffer`\> +> **chatReadFile**(`path`, `__namedParameters`): `Promise`\<`Buffer`\<`ArrayBufferLike`\>\> -Defined in: [src/core.ts:58](../src/core.ts#L58) +Defined in: [src/core.ts:61](../src/core.ts#L61) Read buffer from encrypted file @@ -24,4 +24,4 @@ Read buffer from encrypted file ## Returns -`Promise`\<`ArrayBuffer`\> +`Promise`\<`Buffer`\<`ArrayBufferLike`\>\> diff --git a/packages/simplex-chat-nodejs/docs/core.Function.chatRecvMsgWait.md b/packages/simplex-chat-nodejs/docs/core.Function.chatRecvMsgWait.md index 9bf44d6523..719cf610a1 100644 --- a/packages/simplex-chat-nodejs/docs/core.Function.chatRecvMsgWait.md +++ b/packages/simplex-chat-nodejs/docs/core.Function.chatRecvMsgWait.md @@ -8,7 +8,7 @@ > **chatRecvMsgWait**(`ctrl`, `wait`): `Promise`\<`ChatEvent` \| `undefined`\> -Defined in: [src/core.ts:37](../src/core.ts#L37) +Defined in: [src/core.ts:40](../src/core.ts#L40) Receive chat event diff --git a/packages/simplex-chat-nodejs/docs/core.Function.chatSendCmd.md b/packages/simplex-chat-nodejs/docs/core.Function.chatSendCmd.md index 2dfcba45b4..4296a714cf 100644 --- a/packages/simplex-chat-nodejs/docs/core.Function.chatSendCmd.md +++ b/packages/simplex-chat-nodejs/docs/core.Function.chatSendCmd.md @@ -8,7 +8,7 @@ > **chatSendCmd**(`ctrl`, `cmd`): `Promise`\<`ChatResponse`\> -Defined in: [src/core.ts:25](../src/core.ts#L25) +Defined in: [src/core.ts:28](../src/core.ts#L28) Send chat command as string diff --git a/packages/simplex-chat-nodejs/docs/core.Function.chatWriteFile.md b/packages/simplex-chat-nodejs/docs/core.Function.chatWriteFile.md index 3b1d770fbb..4ca640d8c4 100644 --- a/packages/simplex-chat-nodejs/docs/core.Function.chatWriteFile.md +++ b/packages/simplex-chat-nodejs/docs/core.Function.chatWriteFile.md @@ -8,7 +8,7 @@ > **chatWriteFile**(`ctrl`, `path`, `buffer`): `Promise`\<[`CryptoArgs`](core.Interface.CryptoArgs.md)\> -Defined in: [src/core.ts:50](../src/core.ts#L50) +Defined in: [src/core.ts:53](../src/core.ts#L53) Write buffer to encrypted file @@ -24,7 +24,7 @@ Write buffer to encrypted file ### buffer -`ArrayBuffer` +`ArrayBuffer` \| `Uint8Array`\<`ArrayBufferLike`\> ## Returns diff --git a/packages/simplex-chat-nodejs/docs/core.Interface.APIResult.md b/packages/simplex-chat-nodejs/docs/core.Interface.APIResult.md index 8d18997ec4..2ede6d9ffb 100644 --- a/packages/simplex-chat-nodejs/docs/core.Interface.APIResult.md +++ b/packages/simplex-chat-nodejs/docs/core.Interface.APIResult.md @@ -6,7 +6,7 @@ # Interface: APIResult\ -Defined in: [src/core.ts:87](../src/core.ts#L87) +Defined in: [src/core.ts:90](../src/core.ts#L90) ## Type Parameters @@ -20,7 +20,7 @@ Defined in: [src/core.ts:87](../src/core.ts#L87) > `optional` **error?**: `ChatError` -Defined in: [src/core.ts:89](../src/core.ts#L89) +Defined in: [src/core.ts:92](../src/core.ts#L92) *** @@ -28,4 +28,4 @@ Defined in: [src/core.ts:89](../src/core.ts#L89) > `optional` **result?**: `R` -Defined in: [src/core.ts:88](../src/core.ts#L88) +Defined in: [src/core.ts:91](../src/core.ts#L91) diff --git a/packages/simplex-chat-nodejs/docs/core.Interface.CryptoArgs.md b/packages/simplex-chat-nodejs/docs/core.Interface.CryptoArgs.md index eddcb0bc5a..51a55b86b6 100644 --- a/packages/simplex-chat-nodejs/docs/core.Interface.CryptoArgs.md +++ b/packages/simplex-chat-nodejs/docs/core.Interface.CryptoArgs.md @@ -6,7 +6,7 @@ # Interface: CryptoArgs -Defined in: [src/core.ts:111](../src/core.ts#L111) +Defined in: [src/core.ts:114](../src/core.ts#L114) File encryption key and nonce @@ -16,7 +16,7 @@ File encryption key and nonce > **fileKey**: `string` -Defined in: [src/core.ts:112](../src/core.ts#L112) +Defined in: [src/core.ts:115](../src/core.ts#L115) *** @@ -24,4 +24,4 @@ Defined in: [src/core.ts:112](../src/core.ts#L112) > **fileNonce**: `string` -Defined in: [src/core.ts:113](../src/core.ts#L113) +Defined in: [src/core.ts:116](../src/core.ts#L116) diff --git a/packages/simplex-chat-nodejs/docs/core.Interface.UpMigration.md b/packages/simplex-chat-nodejs/docs/core.Interface.UpMigration.md index 32f6c267aa..9e1fca49b4 100644 --- a/packages/simplex-chat-nodejs/docs/core.Interface.UpMigration.md +++ b/packages/simplex-chat-nodejs/docs/core.Interface.UpMigration.md @@ -6,7 +6,7 @@ # Interface: UpMigration -Defined in: [src/core.ts:185](../src/core.ts#L185) +Defined in: [src/core.ts:193](../src/core.ts#L193) ## Properties @@ -14,7 +14,7 @@ Defined in: [src/core.ts:185](../src/core.ts#L185) > **upName**: `string` -Defined in: [src/core.ts:186](../src/core.ts#L186) +Defined in: [src/core.ts:194](../src/core.ts#L194) *** @@ -22,4 +22,4 @@ Defined in: [src/core.ts:186](../src/core.ts#L186) > **withDown**: `boolean` -Defined in: [src/core.ts:187](../src/core.ts#L187) +Defined in: [src/core.ts:195](../src/core.ts#L195) diff --git a/packages/simplex-chat-nodejs/docs/core.MTRError.Interface.MTREDifferent.md b/packages/simplex-chat-nodejs/docs/core.MTRError.Interface.MTREDifferent.md index 8dab81a3a3..f85fb492be 100644 --- a/packages/simplex-chat-nodejs/docs/core.MTRError.Interface.MTREDifferent.md +++ b/packages/simplex-chat-nodejs/docs/core.MTRError.Interface.MTREDifferent.md @@ -6,7 +6,7 @@ # Interface: MTREDifferent -Defined in: [src/core.ts:206](../src/core.ts#L206) +Defined in: [src/core.ts:214](../src/core.ts#L214) ## Extends @@ -18,7 +18,7 @@ Defined in: [src/core.ts:206](../src/core.ts#L206) > **downMigrations**: `string`[] -Defined in: [src/core.ts:208](../src/core.ts#L208) +Defined in: [src/core.ts:216](../src/core.ts#L216) *** @@ -26,7 +26,7 @@ Defined in: [src/core.ts:208](../src/core.ts#L208) > **type**: `"different"` -Defined in: [src/core.ts:207](../src/core.ts#L207) +Defined in: [src/core.ts:215](../src/core.ts#L215) #### Overrides diff --git a/packages/simplex-chat-nodejs/docs/core.MTRError.Interface.MTRENoDown.md b/packages/simplex-chat-nodejs/docs/core.MTRError.Interface.MTRENoDown.md index 1de634e40e..f031ac8982 100644 --- a/packages/simplex-chat-nodejs/docs/core.MTRError.Interface.MTRENoDown.md +++ b/packages/simplex-chat-nodejs/docs/core.MTRError.Interface.MTRENoDown.md @@ -6,7 +6,7 @@ # Interface: MTRENoDown -Defined in: [src/core.ts:201](../src/core.ts#L201) +Defined in: [src/core.ts:209](../src/core.ts#L209) ## Extends @@ -14,20 +14,20 @@ Defined in: [src/core.ts:201](../src/core.ts#L201) ## Properties +### dbMigrations + +> **dbMigrations**: `string`[] + +Defined in: [src/core.ts:211](../src/core.ts#L211) + +*** + ### type > **type**: `"noDown"` -Defined in: [src/core.ts:202](../src/core.ts#L202) +Defined in: [src/core.ts:210](../src/core.ts#L210) #### Overrides `Interface.type` - -*** - -### upMigrations - -> **upMigrations**: [`UpMigration`](core.Interface.UpMigration.md) - -Defined in: [src/core.ts:203](../src/core.ts#L203) diff --git a/packages/simplex-chat-nodejs/docs/core.MTRError.TypeAlias.Tag.md b/packages/simplex-chat-nodejs/docs/core.MTRError.TypeAlias.Tag.md index 768baa0068..59501b539a 100644 --- a/packages/simplex-chat-nodejs/docs/core.MTRError.TypeAlias.Tag.md +++ b/packages/simplex-chat-nodejs/docs/core.MTRError.TypeAlias.Tag.md @@ -8,4 +8,4 @@ > **Tag** = `"noDown"` \| `"different"` -Defined in: [src/core.ts:195](../src/core.ts#L195) +Defined in: [src/core.ts:203](../src/core.ts#L203) diff --git a/packages/simplex-chat-nodejs/docs/core.MigrationError.Interface.MEDowngrade.md b/packages/simplex-chat-nodejs/docs/core.MigrationError.Interface.MEDowngrade.md index a2742cbff2..f6202b9456 100644 --- a/packages/simplex-chat-nodejs/docs/core.MigrationError.Interface.MEDowngrade.md +++ b/packages/simplex-chat-nodejs/docs/core.MigrationError.Interface.MEDowngrade.md @@ -6,7 +6,7 @@ # Interface: MEDowngrade -Defined in: [src/core.ts:174](../src/core.ts#L174) +Defined in: [src/core.ts:182](../src/core.ts#L182) ## Extends @@ -18,7 +18,7 @@ Defined in: [src/core.ts:174](../src/core.ts#L174) > **downMigrations**: `string`[] -Defined in: [src/core.ts:176](../src/core.ts#L176) +Defined in: [src/core.ts:184](../src/core.ts#L184) *** @@ -26,7 +26,7 @@ Defined in: [src/core.ts:176](../src/core.ts#L176) > **type**: `"downgrade"` -Defined in: [src/core.ts:175](../src/core.ts#L175) +Defined in: [src/core.ts:183](../src/core.ts#L183) #### Overrides diff --git a/packages/simplex-chat-nodejs/docs/core.MigrationError.Interface.MEUpgrade.md b/packages/simplex-chat-nodejs/docs/core.MigrationError.Interface.MEUpgrade.md index 08fe7d56e3..7467895b74 100644 --- a/packages/simplex-chat-nodejs/docs/core.MigrationError.Interface.MEUpgrade.md +++ b/packages/simplex-chat-nodejs/docs/core.MigrationError.Interface.MEUpgrade.md @@ -6,7 +6,7 @@ # Interface: MEUpgrade -Defined in: [src/core.ts:169](../src/core.ts#L169) +Defined in: [src/core.ts:177](../src/core.ts#L177) ## Extends @@ -18,7 +18,7 @@ Defined in: [src/core.ts:169](../src/core.ts#L169) > **type**: `"upgrade"` -Defined in: [src/core.ts:170](../src/core.ts#L170) +Defined in: [src/core.ts:178](../src/core.ts#L178) #### Overrides @@ -28,6 +28,6 @@ Defined in: [src/core.ts:170](../src/core.ts#L170) ### upMigrations -> **upMigrations**: [`UpMigration`](core.Interface.UpMigration.md) +> **upMigrations**: [`UpMigration`](core.Interface.UpMigration.md)[] -Defined in: [src/core.ts:171](../src/core.ts#L171) +Defined in: [src/core.ts:179](../src/core.ts#L179) diff --git a/packages/simplex-chat-nodejs/docs/core.MigrationError.Interface.MigrationError.md b/packages/simplex-chat-nodejs/docs/core.MigrationError.Interface.MigrationError.md index cd811a5747..7b2546fcd5 100644 --- a/packages/simplex-chat-nodejs/docs/core.MigrationError.Interface.MigrationError.md +++ b/packages/simplex-chat-nodejs/docs/core.MigrationError.Interface.MigrationError.md @@ -6,7 +6,7 @@ # Interface: MigrationError -Defined in: [src/core.ts:179](../src/core.ts#L179) +Defined in: [src/core.ts:187](../src/core.ts#L187) ## Extends @@ -18,7 +18,7 @@ Defined in: [src/core.ts:179](../src/core.ts#L179) > **mtrError**: [`MTRError`](core.TypeAlias.MTRError.md) -Defined in: [src/core.ts:181](../src/core.ts#L181) +Defined in: [src/core.ts:189](../src/core.ts#L189) *** @@ -26,7 +26,7 @@ Defined in: [src/core.ts:181](../src/core.ts#L181) > **type**: `"migrationError"` -Defined in: [src/core.ts:180](../src/core.ts#L180) +Defined in: [src/core.ts:188](../src/core.ts#L188) #### Overrides diff --git a/packages/simplex-chat-nodejs/docs/core.MigrationError.TypeAlias.Tag.md b/packages/simplex-chat-nodejs/docs/core.MigrationError.TypeAlias.Tag.md index 5ef6e70b08..e2e1dcb33f 100644 --- a/packages/simplex-chat-nodejs/docs/core.MigrationError.TypeAlias.Tag.md +++ b/packages/simplex-chat-nodejs/docs/core.MigrationError.TypeAlias.Tag.md @@ -8,4 +8,4 @@ > **Tag** = `"upgrade"` \| `"downgrade"` \| `"migrationError"` -Defined in: [src/core.ts:163](../src/core.ts#L163) +Defined in: [src/core.ts:171](../src/core.ts#L171) diff --git a/packages/simplex-chat-nodejs/docs/core.Namespace.DBMigrationError.md b/packages/simplex-chat-nodejs/docs/core.Namespace.DBMigrationError.md index 95ddfa5b24..889a10ddba 100644 --- a/packages/simplex-chat-nodejs/docs/core.Namespace.DBMigrationError.md +++ b/packages/simplex-chat-nodejs/docs/core.Namespace.DBMigrationError.md @@ -12,6 +12,7 @@ - [ErrorNotADatabase](core.DBMigrationError.Interface.ErrorNotADatabase.md) - [ErrorSQL](core.DBMigrationError.Interface.ErrorSQL.md) - [InvalidConfirmation](core.DBMigrationError.Interface.InvalidConfirmation.md) +- [InvalidQueueSize](core.DBMigrationError.Interface.InvalidQueueSize.md) ## Type Aliases diff --git a/packages/simplex-chat-nodejs/docs/core.TypeAlias.DBMigrationError.md b/packages/simplex-chat-nodejs/docs/core.TypeAlias.DBMigrationError.md index 6473b3ef60..3f4797fb8e 100644 --- a/packages/simplex-chat-nodejs/docs/core.TypeAlias.DBMigrationError.md +++ b/packages/simplex-chat-nodejs/docs/core.TypeAlias.DBMigrationError.md @@ -6,6 +6,6 @@ # Type Alias: DBMigrationError -> **DBMigrationError** = [`InvalidConfirmation`](core.DBMigrationError.Interface.InvalidConfirmation.md) \| [`ErrorNotADatabase`](core.DBMigrationError.Interface.ErrorNotADatabase.md) \| [`ErrorMigration`](core.DBMigrationError.Interface.ErrorMigration.md) \| [`ErrorSQL`](core.DBMigrationError.Interface.ErrorSQL.md) +> **DBMigrationError** = [`InvalidConfirmation`](core.DBMigrationError.Interface.InvalidConfirmation.md) \| [`InvalidQueueSize`](core.DBMigrationError.Interface.InvalidQueueSize.md) \| [`ErrorNotADatabase`](core.DBMigrationError.Interface.ErrorNotADatabase.md) \| [`ErrorMigration`](core.DBMigrationError.Interface.ErrorMigration.md) \| [`ErrorSQL`](core.DBMigrationError.Interface.ErrorSQL.md) -Defined in: [src/core.ts:122](../src/core.ts#L122) +Defined in: [src/core.ts:125](../src/core.ts#L125) diff --git a/packages/simplex-chat-nodejs/docs/core.TypeAlias.MTRError.md b/packages/simplex-chat-nodejs/docs/core.TypeAlias.MTRError.md index 11aa5b7c24..66d6a092dc 100644 --- a/packages/simplex-chat-nodejs/docs/core.TypeAlias.MTRError.md +++ b/packages/simplex-chat-nodejs/docs/core.TypeAlias.MTRError.md @@ -8,4 +8,4 @@ > **MTRError** = [`MTRENoDown`](core.MTRError.Interface.MTRENoDown.md) \| [`MTREDifferent`](core.MTRError.Interface.MTREDifferent.md) -Defined in: [src/core.ts:190](../src/core.ts#L190) +Defined in: [src/core.ts:198](../src/core.ts#L198) diff --git a/packages/simplex-chat-nodejs/docs/core.TypeAlias.MigrationError.md b/packages/simplex-chat-nodejs/docs/core.TypeAlias.MigrationError.md index c15b679769..6c0ba77541 100644 --- a/packages/simplex-chat-nodejs/docs/core.TypeAlias.MigrationError.md +++ b/packages/simplex-chat-nodejs/docs/core.TypeAlias.MigrationError.md @@ -8,4 +8,4 @@ > **MigrationError** = [`MEUpgrade`](core.MigrationError.Interface.MEUpgrade.md) \| [`MEDowngrade`](core.MigrationError.Interface.MEDowngrade.md) \| [`MigrationError`](core.MigrationError.Interface.MigrationError.md) -Defined in: [src/core.ts:157](../src/core.ts#L157) +Defined in: [src/core.ts:165](../src/core.ts#L165) diff --git a/packages/simplex-chat-nodejs/src/api.ts b/packages/simplex-chat-nodejs/src/api.ts index 958304a8ea..8c11e9fde3 100644 --- a/packages/simplex-chat-nodejs/src/api.ts +++ b/packages/simplex-chat-nodejs/src/api.ts @@ -106,13 +106,15 @@ export class ChatApi { * Initializes the ChatApi. * @param {DbConfig} db - Database configuration (sqlite or postgres). * @param {core.MigrationConfirmation} [confirm=core.MigrationConfirmation.YesUp] - Migration confirmation mode. + * @param {number} [queueSize] - Size of internal queues, the core default is used when omitted. */ static async init( db: DbConfig, - confirm = core.MigrationConfirmation.YesUp + confirm = core.MigrationConfirmation.YesUp, + queueSize?: number ): Promise { const [path, key] = dbConfigToMigrateArgs(db) - const ctrl = await core.chatMigrateInit(path, key, confirm) + const ctrl = await core.chatMigrateInit(path, key, confirm, queueSize) return new ChatApi(ctrl) } @@ -120,39 +122,52 @@ export class ChatApi { * Start chat controller. Must be called with the existing user profile. */ async startChat(): Promise { + if (this.eventsLoop) throw new Error("chat already started") + const ctrl = this.ctrl this.receiveEvents = true this.eventsLoop = this.runEventsLoop() - const r = await this.sendChatCmd(CC.StartChat.cmdString({mainApp: true, enableSndFiles: true})) + let r: ChatResponse + try { + r = await core.chatSendCmd(ctrl, CC.StartChat.cmdString({mainApp: true, enableSndFiles: true, serviceRequests: false})) + } catch (e) { + await this.stopEventsLoop() + throw e + } if (r.type !== "chatStarted" && r.type !== "chatRunning") { + await this.stopEventsLoop() throw new ChatCommandError("error starting chat", r) } } - + /** * Stop chat controller. - * Must be called before closing the database. + * `close` calls it before closing the database. * Usually doesn't need to be called in chat bots. */ async stopChat(): Promise { const r = await this.sendChatCmd("/_stop") - if (r.type !== "chatStopped") throw new ChatCommandError("error starting chat", r) - this.receiveEvents = false - if (this.eventsLoop) await this.eventsLoop - this.eventsLoop = undefined + if (r.type !== "chatStopped") throw new ChatCommandError("error stopping chat", r) + await this.stopEventsLoop() } /** - * Close chat database. + * Stop chat controller and close chat database. + * The database is not closed if stopping fails. * Usually doesn't need to be called in chat bots. */ async close(): Promise { - this.receiveEvents = false - if (this.eventsLoop) await this.eventsLoop - this.eventsLoop = undefined + // a running controller keeps using the database connections that closing frees + await this.stopChat() await core.chatCloseStore(this.ctrl) this.ctrl_ = undefined } - + + private async stopEventsLoop(): Promise { + this.receiveEvents = false + if (this.eventsLoop) await this.eventsLoop + this.eventsLoop = undefined + } + private async runEventsLoop(): Promise { while (this.receiveEvents) { try { @@ -335,7 +350,7 @@ export class ChatApi { return await core.chatSendCmd(this.ctrl, cmd) } - async recvChatEvent(wait: number = 5_000_000): Promise { + async recvChatEvent(wait: number = 500_000): Promise { return await core.chatRecvMsgWait(this.ctrl, wait) } @@ -386,8 +401,10 @@ export class ChatApi { switch (r.type) { case "userProfileUpdated": return r.updateSummary + case "userProfileNoChange": + return {updateSuccesses: 0, updateFailures: 0, changedContacts: []} default: - throw new ChatCommandError("error loading user address", r) + throw new ChatCommandError("error setting profile address", r) } } @@ -460,7 +477,7 @@ export class ChatApi { updatedMessage: {msgContent, mentions: {}}, }) ) - if (r.type === "chatItemUpdated") return r.chatItem.chatItem + if (r.type === "chatItemUpdated" || r.type === "chatItemNotChanged") return r.chatItem.chatItem throw new ChatCommandError("error updating chat item", r) } @@ -499,10 +516,10 @@ export class ChatApi { chatItemId: number, add: boolean, reaction: T.MsgReaction - ) { + ): Promise { const r = await this.sendChatCmd(CC.APIChatItemReaction.cmdString({chatRef: {chatType, chatId}, chatItemId, add, reaction})) - if (r.type === "chatItemsDeleted") return r.chatItemDeletions - throw new ChatCommandError("error setting item reaction", r) + if (r.type === "chatItemReaction") return r.reaction + throw new ChatCommandError("error setting item reaction", r) } /** @@ -512,6 +529,7 @@ export class ChatApi { async apiReceiveFile(fileId: number): Promise { const r = await this.sendChatCmd(CC.ReceiveFile.cmdString({fileId, userApprovedRelays: true})) if (r.type === "rcvFileAccepted") return r.chatItem + if (r.type === "rcvFileAcceptedSndCancelled") throw new ChatCommandError("file cancelled by sender", r) throw new ChatCommandError("error receiving file", r) } @@ -698,7 +716,7 @@ export class ChatApi { * Connect via prepared SimpleX link. The link can be 1-time invitation link, contact address or group link * Network usage: interactive. */ - async apiConnect(userId: number, incognito: boolean, preparedLink?: T.CreatedConnLink): Promise { + async apiConnect(userId: number, incognito: boolean, preparedLink: T.CreatedConnLink): Promise { const r = await this.sendChatCmd(CC.APIConnect.cmdString({userId, incognito, preparedLink_: preparedLink})) return this.handleConnectResult(r) } @@ -740,7 +758,7 @@ export class ChatApi { * Network usage: no. */ async apiRejectContactRequest(contactReqId: number): Promise { - const r = await this.sendChatCmd(CC.APIRejectContact.cmdString({contactReqId})) + const r = await this.sendChatCmd(CC.APIRejectContact.cmdString({contactReqId, notify: false})) if (r.type === "contactRequestRejected") return throw new ChatCommandError("error rejecting contact request", r) } diff --git a/packages/simplex-chat-nodejs/src/bot.ts b/packages/simplex-chat-nodejs/src/bot.ts index f6cb753d27..68e787ca3e 100644 --- a/packages/simplex-chat-nodejs/src/bot.ts +++ b/packages/simplex-chat-nodejs/src/bot.ts @@ -6,6 +6,7 @@ import equal = require("fast-deep-equal") export type BotDbOpts = api.DbConfig & { confirmMigrations?: core.MigrationConfirmation + queueSize?: number } export interface BotOptions { @@ -45,10 +46,9 @@ export interface BotConfig { } export async function run({profile, dbOpts, options = defaultOpts, onMessage, onCommands = {}, events = {}}: BotConfig): Promise<[api.ChatApi, T.User, T.UserContactLink | undefined]> { - const bot = await api.ChatApi.init(dbOpts, dbOpts.confirmMigrations || core.MigrationConfirmation.YesUp) + const bot = await api.ChatApi.init(dbOpts, dbOpts.confirmMigrations || core.MigrationConfirmation.YesUp, dbOpts.queueSize) const opts = fullOptions(options) - if (onMessage) subscribeMessages(bot, onMessage) - if (Object.keys(onCommands).length > 0) subscribeCommands(bot, onCommands) + if (onMessage || Object.keys(onCommands).length > 0) subscribeChatItems(bot, onMessage, onCommands) if (Object.keys(events).length > 0) bot.on(events) subscribeLogEvents(bot, opts) const botProfile = mkBotProfile(profile, opts) @@ -105,40 +105,27 @@ function mkBotProfile(profile: T.Profile, opts: Required): T.Profile return profile } -function subscribeMessages(bot: api.ChatApi, onMessage: (chatItem: T.AChatItem, content: T.MsgContent) => void | Promise) { +export function subscribeChatItems( + bot: api.ChatApi, + onMessage: ((chatItem: T.AChatItem, content: T.MsgContent) => void | Promise) | undefined, + commands: {[K in string]?: ((chatItem: T.AChatItem, command: util.BotCommand) => void | Promise)} +) { bot.on("newChatItems", async ({chatItems}) => { for (const ci of chatItems) { - if (ci.chatItem.content.type === "rcvMsgContent") { - try { - const p = onMessage(ci, ci.chatItem.content.msgContent) - if (p instanceof Promise) await p - } catch (e) { - console.log("message processing error", e) - } + const content = ci.chatItem.content + if (content.type !== "rcvMsgContent") continue + const cmd = util.ciBotCommand(ci.chatItem) + const cmdFunc = cmd && (commands[cmd.keyword] || commands[""]) + try { + if (cmd && cmdFunc) await cmdFunc(ci, cmd) + else if (onMessage) await onMessage(ci, content.msgContent) + } catch (e) { + console.log(cmd && cmdFunc ? `${cmd.keyword} command processing error` : "message processing error", e) } } }) } -function subscribeCommands(bot: api.ChatApi, commands: {[K in string]?: ((chatItem: T.AChatItem, command: util.BotCommand) => void | Promise)}) { - bot.on("newChatItems", async (evt) => { - for (const ci of evt.chatItems) { - const cmd = util.ciBotCommand(ci.chatItem) - if (cmd) { - const cmdFunc = commands[cmd.keyword] || commands[""] - if (cmdFunc) { - try { - const p = cmdFunc(ci, cmd) - if (p instanceof Promise) await p - } catch(e) { - console.log(`${cmd} command processing error`, e) - } - } - } - } - }) -} - function subscribeLogEvents(bot: api.ChatApi, opts: Required) { if (opts.logContacts) { bot.on({ diff --git a/packages/simplex-chat-nodejs/src/core.ts b/packages/simplex-chat-nodejs/src/core.ts index 949c2356af..49664fed71 100644 --- a/packages/simplex-chat-nodejs/src/core.ts +++ b/packages/simplex-chat-nodejs/src/core.ts @@ -3,9 +3,12 @@ import * as simplex from "./simplex" /** * Initialize chat controller + * @param {number} [queueSize] - Size of internal queues, the core default is used when omitted. */ -export async function chatMigrateInit(dbPath: string, dbKey: string, confirm: MigrationConfirmation): Promise { - const [ctrl, res] = await simplex.chat_migrate_init(dbPath, dbKey, confirm) +export async function chatMigrateInit(dbPath: string, dbKey: string, confirm: MigrationConfirmation, queueSize?: number): Promise { + const [ctrl, res] = queueSize === undefined + ? await simplex.chat_migrate_init(dbPath, dbKey, confirm) + : await simplex.chat_migrate_init_queue(dbPath, dbKey, confirm, queueSize) const json = JSON.parse(res) if (json.type === 'ok') return ctrl throw new ChatInitError("Database or migration error (see dbMigrationError property)", json as DBMigrationError) @@ -47,7 +50,7 @@ export async function chatRecvMsgWait(ctrl: bigint, wait: number): Promise { +export async function chatWriteFile(ctrl: bigint, path: string, buffer: ArrayBuffer | Uint8Array): Promise { const res = await simplex.chat_write_file(ctrl, path, buffer) return cryptoArgsResult(res) } @@ -55,7 +58,7 @@ export async function chatWriteFile(ctrl: bigint, path: string, buffer: ArrayBuf /** * Read buffer from encrypted file */ -export async function chatReadFile(path: string, {fileKey, fileNonce}: CryptoArgs): Promise { +export async function chatReadFile(path: string, {fileKey, fileNonce}: CryptoArgs): Promise { return await simplex.chat_read_file(path, fileKey, fileNonce) } @@ -121,12 +124,13 @@ export class ChatInitError extends Error { export type DBMigrationError = | DBMigrationError.InvalidConfirmation + | DBMigrationError.InvalidQueueSize | DBMigrationError.ErrorNotADatabase // invalid/corrupt database file or incorrect encryption key | DBMigrationError.ErrorMigration | DBMigrationError.ErrorSQL export namespace DBMigrationError { - export type Tag = "invalidConfirmation" | "errorNotADatabase" | "errorMigration" | "errorSQL" + export type Tag = "invalidConfirmation" | "invalidQueueSize" | "errorNotADatabase" | "errorMigration" | "errorSQL" interface Interface { type: Tag @@ -136,6 +140,10 @@ export namespace DBMigrationError { type: "invalidConfirmation" } + export interface InvalidQueueSize extends Interface { + type: "invalidQueueSize" + } + export interface ErrorNotADatabase extends Interface { type: "errorNotADatabase" dbFile: string @@ -168,7 +176,7 @@ export namespace MigrationError { export interface MEUpgrade extends Interface { type: "upgrade" - upMigrations: UpMigration + upMigrations: UpMigration[] } export interface MEDowngrade extends Interface { @@ -200,7 +208,7 @@ export namespace MTRError { export interface MTRENoDown extends Interface { type: "noDown" - upMigrations: UpMigration + dbMigrations: string[] } export interface MTREDifferent extends Interface { diff --git a/packages/simplex-chat-nodejs/src/download-libs.js b/packages/simplex-chat-nodejs/src/download-libs.js index 0acff40daf..e0debebea8 100644 --- a/packages/simplex-chat-nodejs/src/download-libs.js +++ b/packages/simplex-chat-nodejs/src/download-libs.js @@ -6,6 +6,10 @@ const extract = require('extract-zip'); const GITHUB_REPO = 'simplex-chat/simplex-chat-libs'; const RELEASE_TAG = 'v7.1.0-beta.3'; const BACKEND = (process.env.SIMPLEX_BACKEND || process.env.npm_config_simplex_backend || 'sqlite').toLowerCase(); +// A locally built libsimplex, copied into libs/ rather than loaded in place: +// the addon's RUNPATH is $ORIGIN/../../libs. +const LIBS_DIR_OVERRIDE = process.env.SIMPLEX_LIBS_DIR || process.env.npm_config_simplex_libs_dir; +const LIB_NAMES = ['libsimplex.so', 'libsimplex.dylib', 'libsimplex.dll']; if (BACKEND !== 'sqlite' && BACKEND !== 'postgres') { console.error(`✗ Invalid SIMPLEX_BACKEND: "${BACKEND}". Must be "sqlite" or "postgres".`); @@ -83,8 +87,31 @@ function isAlreadyInstalled() { } } +// No version check: the files behind SIMPLEX_LIBS_DIR change on every rebuild. +function installFromOverride() { + if (!fs.existsSync(LIBS_DIR_OVERRIDE)) { + throw new Error(`SIMPLEX_LIBS_DIR does not exist: ${LIBS_DIR_OVERRIDE}`); + } + const lib = LIB_NAMES.find((name) => fs.existsSync(path.join(LIBS_DIR_OVERRIDE, name))); + if (!lib) { + throw new Error(`No ${LIB_NAMES.join(' / ')} in SIMPLEX_LIBS_DIR: ${LIBS_DIR_OVERRIDE}`); + } + console.log(`Using libraries from SIMPLEX_LIBS_DIR: ${LIBS_DIR_OVERRIDE}`); + cleanLibsDirectory(); + copyDirSync(LIBS_DIR_OVERRIDE, LIBS_DIR); + // Not a release tag: a later install without the variable sees a mismatch + // and replaces these files with the released ones. + fs.writeFileSync(INSTALLED_FILE, `${LIBS_DIR_OVERRIDE}:${BACKEND}`, 'utf-8'); + console.log(`✓ Installed ${lib} and its runtime libraries from ${LIBS_DIR_OVERRIDE}`); +} + async function install() { try { + if (LIBS_DIR_OVERRIDE) { + installFromOverride(); + return; + } + // Check if already installed if (isAlreadyInstalled()) { return; diff --git a/packages/simplex-chat-nodejs/src/simplex.d.ts b/packages/simplex-chat-nodejs/src/simplex.d.ts index 10c2f6608a..1e0ca825a6 100644 --- a/packages/simplex-chat-nodejs/src/simplex.d.ts +++ b/packages/simplex-chat-nodejs/src/simplex.d.ts @@ -1,10 +1,11 @@ // These functions are defined in CPP add-on ../cpp/simplex.cc export function chat_migrate_init(dbPath: string, dbKey: string, confirm: string): Promise<[bigint, string]> +export function chat_migrate_init_queue(dbPath: string, dbKey: string, confirm: string, queueSize: number): Promise<[bigint, string]> export function chat_close_store(ctrl: bigint): Promise export function chat_send_cmd(ctrl: bigint, cmd: string): Promise export function chat_recv_msg_wait(ctrl: bigint, wait: number): Promise -export function chat_write_file(ctrl: bigint, path: string, buffer: ArrayBuffer): Promise -export function chat_read_file(path: string, key: string, nonce: string): Promise +export function chat_write_file(ctrl: bigint, path: string, buffer: ArrayBuffer | Uint8Array): Promise +export function chat_read_file(path: string, key: string, nonce: string): Promise export function chat_encrypt_file(ctrl: bigint, fromPath: string, toPath: string): Promise export function chat_decrypt_file(fromPath: string, key: string, nonce: string, toPath: string): Promise diff --git a/packages/simplex-chat-nodejs/src/util.ts b/packages/simplex-chat-nodejs/src/util.ts index f7365e731c..dffb0ce1bc 100644 --- a/packages/simplex-chat-nodejs/src/util.ts +++ b/packages/simplex-chat-nodejs/src/util.ts @@ -78,7 +78,7 @@ export interface BotCommand { export function ciBotCommand(chatItem: T.ChatItem): BotCommand | undefined { const msg = ciContentText(chatItem)?.trim() if (msg) { - const r = msg.match(/^\/([^\s]+)(.*)/) + const r = msg.match(/^\/([^\s]+)([\s\S]*)/) if (r && r.length >= 3) { return {keyword: r[1], params: r[2].trim()} } diff --git a/packages/simplex-chat-nodejs/tests/api.test.ts b/packages/simplex-chat-nodejs/tests/api.test.ts index 99d511371c..e3ddca2440 100644 --- a/packages/simplex-chat-nodejs/tests/api.test.ts +++ b/packages/simplex-chat-nodejs/tests/api.test.ts @@ -58,8 +58,8 @@ describe("API tests (use preset servers)", () => { await bob.stopChat() await alice.close() await bob.close() - await expect(alice.startChat).rejects.toThrow() - await expect(bob.startChat).rejects.toThrow() + await expect(alice.startChat()).rejects.toThrow("chat api controller not initialized") + await expect(bob.startChat()).rejects.toThrow("chat api controller not initialized") expect(servers.length).toBe(2) expect(servers[0] !== servers[1]).toBe(true) expect(eventCount > 0).toBe(true) diff --git a/packages/simplex-chat-nodejs/tests/api.unit.test.ts b/packages/simplex-chat-nodejs/tests/api.unit.test.ts new file mode 100644 index 0000000000..c5a4367b11 --- /dev/null +++ b/packages/simplex-chat-nodejs/tests/api.unit.test.ts @@ -0,0 +1,102 @@ +import {ChatResponse, T} from "@simplex-chat/types" +import * as api from "../src/api" +import * as core from "../src/core" + +const user = {userId: 1} as T.User + +async function chatWithResponse(response: object): Promise { + jest.spyOn(core, "chatMigrateInit").mockResolvedValue(BigInt(1)) + jest.spyOn(core, "chatSendCmd").mockResolvedValue(response as ChatResponse) + return api.ChatApi.init({type: "sqlite", filePrefix: "unused"}) +} + +afterEach(() => jest.restoreAllMocks()) + +describe("documented success responses", () => { + it("apiChatItemReaction returns the reaction", async () => { + const reaction = {chatReaction: {reaction: {type: "emoji", emoji: "👍"}}} + const chat = await chatWithResponse({type: "chatItemReaction", user, added: true, reaction}) + await expect(chat.apiChatItemReaction(T.ChatType.Direct, 1, 2, true, {type: "emoji", emoji: "👍"})).resolves.toEqual(reaction) + }) + + it("apiUpdateChatItem accepts chatItemNotChanged", async () => { + const chatItem = {meta: {itemId: 2}} + const chat = await chatWithResponse({type: "chatItemNotChanged", user, chatItem: {chatItem}}) + await expect(chat.apiUpdateChatItem(T.ChatType.Direct, 1, 2, {type: "text", text: "same"}, false)).resolves.toEqual(chatItem) + }) + + it("apiSetProfileAddress accepts userProfileNoChange", async () => { + const chat = await chatWithResponse({type: "userProfileNoChange", user}) + await expect(chat.apiSetProfileAddress(1, true)).resolves.toEqual({updateSuccesses: 0, updateFailures: 0, changedContacts: []}) + }) + + it("apiReceiveFile reports a file cancelled by sender", async () => { + const chat = await chatWithResponse({type: "rcvFileAcceptedSndCancelled", user, rcvFileTransfer: {}}) + await expect(chat.apiReceiveFile(3)).rejects.toThrow("file cancelled by sender") + }) +}) + +describe("startChat lifecycle", () => { + function chatWithResponses(...responses: object[]): Promise { + jest.spyOn(core, "chatMigrateInit").mockResolvedValue(BigInt(1)) + jest.spyOn(core, "chatRecvMsgWait").mockImplementation(() => new Promise(resolve => setTimeout(() => resolve(undefined), 10))) + const send = jest.spyOn(core, "chatSendCmd") + for (const r of responses) send.mockResolvedValueOnce(r as ChatResponse) + return api.ChatApi.init({type: "sqlite", filePrefix: "unused"}) + } + + it("rejects a second start", async () => { + const chat = await chatWithResponses({type: "chatStarted"}, {type: "chatStopped"}) + await chat.startChat() + await expect(chat.startChat()).rejects.toThrow("chat already started") + await chat.stopChat() + }) + + it("stops the events loop when start fails", async () => { + const chat = await chatWithResponses({type: "chatCmdError"}) + await expect(chat.startChat()).rejects.toThrow("error starting chat") + expect(chat.started).toBe(false) + }) + + it("rejects start after close", async () => { + const chat = await chatWithResponses({type: "chatStopped"}) + jest.spyOn(core, "chatCloseStore").mockResolvedValue() + await chat.close() + await expect(chat.startChat()).rejects.toThrow("chat api controller not initialized") + }) + + it("stops the chat before closing the store", async () => { + const chat = await chatWithResponses() + const calls: string[] = [] + jest.mocked(core.chatSendCmd).mockImplementation(async (_ctrl, cmd) => { + calls.push(`send ${cmd}`) + return {type: "chatStopped"} as ChatResponse + }) + jest.spyOn(core, "chatCloseStore").mockImplementation(async () => { calls.push("closeStore") }) + await chat.close() + expect(calls).toEqual(["send /_stop", "closeStore"]) + expect(chat.initialized).toBe(false) + }) + + it("does not close the store when stopping fails", async () => { + const chat = await chatWithResponses({type: "chatCmdError"}) + const closeStore = jest.spyOn(core, "chatCloseStore").mockResolvedValue() + await expect(chat.close()).rejects.toThrow("error stopping chat") + expect(closeStore).not.toHaveBeenCalled() + expect(chat.initialized).toBe(true) + }) + + it("reports stop failures as stop errors", async () => { + const chat = await chatWithResponses({type: "chatStarted"}, {type: "chatCmdError"}, {type: "chatStopped"}) + await chat.startChat() + await expect(chat.stopChat()).rejects.toThrow("error stopping chat") + expect(chat.started).toBe(true) + await chat.stopChat() + }) + + it("receives with a 500 ms wait", async () => { + const chat = await chatWithResponses() + await chat.recvChatEvent() + expect(core.chatRecvMsgWait).toHaveBeenCalledWith(BigInt(1), 500_000) + }) +}) diff --git a/packages/simplex-chat-nodejs/tests/bot.unit.test.ts b/packages/simplex-chat-nodejs/tests/bot.unit.test.ts new file mode 100644 index 0000000000..39f9358996 --- /dev/null +++ b/packages/simplex-chat-nodejs/tests/bot.unit.test.ts @@ -0,0 +1,50 @@ +import {ChatEvent, T} from "@simplex-chat/types" +import * as api from "../src/api" +import {subscribeChatItems} from "../src/bot" + +type Handler = (evt: ChatEvent) => Promise + +function fakeBot(): {bot: api.ChatApi, deliver: (items: T.AChatItem[]) => Promise} { + let handler: Handler | undefined + const bot = {on: (_event: string, h: Handler) => { handler = h }} as unknown as api.ChatApi + const deliver = (chatItems: T.AChatItem[]) => handler!({type: "newChatItems", chatItems} as unknown as ChatEvent) + return {bot, deliver} +} + +function item(type: "rcvMsgContent" | "sndMsgContent", text: string): T.AChatItem { + return {chatItem: {content: {type, msgContent: {type: "text", text}}}} as unknown as T.AChatItem +} + +describe("subscribeChatItems", () => { + it("sends a known command only to its handler", async () => { + const {bot, deliver} = fakeBot() + const calls: string[] = [] + subscribeChatItems(bot, async () => { calls.push("message") }, {help: async () => { calls.push("help") }}) + await deliver([item("rcvMsgContent", "/help")]) + expect(calls).toEqual(["help"]) + }) + + it("sends an unknown command to the fallback handler", async () => { + const {bot, deliver} = fakeBot() + const calls: string[] = [] + subscribeChatItems(bot, async () => { calls.push("message") }, {"": async (_ci, cmd) => { calls.push(`fallback:${cmd.keyword}`) }}) + await deliver([item("rcvMsgContent", "/unknown")]) + expect(calls).toEqual(["fallback:unknown"]) + }) + + it("sends unhandled commands and text to onMessage", async () => { + const {bot, deliver} = fakeBot() + const calls: string[] = [] + subscribeChatItems(bot, async (_ci, content) => { calls.push(`message:${(content as T.MsgContent & {text: string}).text}`) }, {help: async () => { calls.push("help") }}) + await deliver([item("rcvMsgContent", "/unknown"), item("rcvMsgContent", "hello")]) + expect(calls).toEqual(["message:/unknown", "message:hello"]) + }) + + it("ignores sent items", async () => { + const {bot, deliver} = fakeBot() + const calls: string[] = [] + subscribeChatItems(bot, async () => { calls.push("message") }, {help: async () => { calls.push("help") }}) + await deliver([item("sndMsgContent", "/help"), item("sndMsgContent", "hello")]) + expect(calls).toEqual([]) + }) +}) diff --git a/packages/simplex-chat-nodejs/tests/commands.test.ts b/packages/simplex-chat-nodejs/tests/commands.test.ts new file mode 100644 index 0000000000..61ce43bd63 --- /dev/null +++ b/packages/simplex-chat-nodejs/tests/commands.test.ts @@ -0,0 +1,13 @@ +import {CC} from "@simplex-chat/types" + +describe("APIConnect.cmdString", () => { + const preparedLink_ = {connFullLink: "L"} + + it("renders incognito=on", () => { + expect(CC.APIConnect.cmdString({userId: 1, incognito: true, preparedLink_})).toBe("/_connect 1 incognito=on L") + }) + + it("omits incognito when off", () => { + expect(CC.APIConnect.cmdString({userId: 1, incognito: false, preparedLink_})).toBe("/_connect 1 L") + }) +}) diff --git a/packages/simplex-chat-nodejs/tests/core.test.ts b/packages/simplex-chat-nodejs/tests/core.test.ts index 141f35746d..8eb106a5b5 100644 --- a/packages/simplex-chat-nodejs/tests/core.test.ts +++ b/packages/simplex-chat-nodejs/tests/core.test.ts @@ -1,3 +1,4 @@ +import {execFile, spawnSync} from "child_process"; import * as fs from "fs"; import * as path from "path"; import {core} from "../src/index"; @@ -9,17 +10,34 @@ describe("Core tests", () => { beforeEach(() => fs.mkdirSync(tmpDir, {recursive: true})); afterEach(() => fs.rmSync(tmpDir, {recursive: true, force: true})); + async function stopAndClose(ctrl: bigint): Promise { + await expect(core.chatSendCmd(ctrl, "/_stop")).resolves.toMatchObject({type: "chatStopped"}); + await core.chatCloseStore(ctrl); + } + it("should initialize chat controller", async () => { const ctrl = await core.chatMigrateInit(dbPath, "key", core.MigrationConfirmation.YesUp); expect(typeof ctrl).toBe("bigint"); - await expect(core.chatCloseStore(ctrl)).resolves.toBe(undefined); + await expect(stopAndClose(ctrl)).resolves.toBe(undefined); await expect(core.chatMigrateInit(dbPath, "wrong_key", core.MigrationConfirmation.YesUp)).rejects.toMatchObject({ message: "Database or migration error (see dbMigrationError property)", dbMigrationError: expect.objectContaining({type: "errorNotADatabase"}) }); }); - + + it("should initialize chat controller with queue size", async () => { + const ctrl = await core.chatMigrateInit(dbPath, "key", core.MigrationConfirmation.YesUp, 65536); + expect(typeof ctrl).toBe("bigint"); + await expect(stopAndClose(ctrl)).resolves.toBe(undefined); + + await expect(core.chatMigrateInit(dbPath, "key", core.MigrationConfirmation.YesUp, 0)).rejects.toMatchObject({ + dbMigrationError: {type: "invalidQueueSize"} + }); + await expect(core.chatMigrateInit(dbPath, "key", core.MigrationConfirmation.YesUp, 2 ** 31)).rejects.toThrow("Expected 32-bit integer queue size"); + await expect(core.chatMigrateInit(dbPath, "key", core.MigrationConfirmation.YesUp, 1.5)).rejects.toThrow("Expected 32-bit integer queue size"); + }); + it("should send command and receive event", async () => { const ctrl = await core.chatMigrateInit(dbPath, "key", core.MigrationConfirmation.YesUp); @@ -41,7 +59,7 @@ describe("Core tests", () => { chatError: expect.objectContaining({type: "error"}) }); - await core.chatCloseStore(ctrl); + await stopAndClose(ctrl); }); it("should write/read encrypted file from/to buffer", async () => { @@ -59,7 +77,25 @@ describe("Core tests", () => { await expect(core.chatWriteFile(ctrl, path.join(tmpDir, "unknown", "unknown.txt"), buffer)).rejects.toThrow(); await expect(core.chatReadFile(path.join(tmpDir, "unknown.txt"), cryptoArgs)).rejects.toThrow(); - await core.chatCloseStore(ctrl); + await stopAndClose(ctrl); + }); + + it("should write the view of a Uint8Array and read an empty file", async () => { + const ctrl = await core.chatMigrateInit(dbPath, "key", core.MigrationConfirmation.YesUp); + + const viewPath = path.join(tmpDir, "view.txt"); + const viewArgs = await core.chatWriteFile(ctrl, viewPath, Buffer.from("xxabcxx").subarray(2, 5)); + const view = await core.chatReadFile(viewPath, viewArgs); + expect(Buffer.isBuffer(view)).toBe(true); + expect(view.toString()).toBe("abc"); + + const emptyPath = path.join(tmpDir, "empty.txt"); + const emptyArgs = await core.chatWriteFile(ctrl, emptyPath, new Uint8Array(0)); + const empty = await core.chatReadFile(emptyPath, emptyArgs); + expect(Buffer.isBuffer(empty)).toBe(true); + expect(empty.length).toBe(0); + + await stopAndClose(ctrl); }); it("should encrypt/decrypt file", async () => { @@ -80,6 +116,118 @@ describe("Core tests", () => { await expect(core.chatEncryptFile(ctrl, path.join(tmpDir, "unknown.txt"), encryptedPath)).rejects.toThrow(); await expect(core.chatDecryptFile(path.join(tmpDir, "unknown.txt"), cryptoArgs, decryptedPath)).rejects.toThrow(); - await core.chatCloseStore(ctrl); + await stopAndClose(ctrl); }); + + it("should not block the libuv pool while receiving", async () => { + const ctrl = await core.chatMigrateInit(dbPath, "key", core.MigrationConfirmation.YesUp); + const receives = [1, 2, 3, 4].map(() => core.chatRecvMsgWait(ctrl, 2_000_000)); + const start = Date.now(); + await fs.promises.stat(tmpDir); + expect(Date.now() - start).toBeLessThan(200); + await Promise.all(receives); + await stopAndClose(ctrl); + }, 10000); + + const itOnLinux = process.platform === "linux" ? it : it.skip; + + // Thread count is read from /proc/self/task, which only exists on Linux. + itOnLinux("should keep the thread count constant while receiving", async () => { + const ctrl = await core.chatMigrateInit(dbPath, "key", core.MigrationConfirmation.YesUp); + for (let i = 0; i < 10; i++) await core.chatRecvMsgWait(ctrl, 1); + const threadCount = () => fs.readdirSync("/proc/self/task").length; + const warmCount = threadCount(); + const counts = new Set(); + for (let i = 0; i < 200; i++) { + await core.chatRecvMsgWait(ctrl, 1); + counts.add(threadCount()); + } + expect({warmCount, counts: [...counts]}).toEqual({warmCount, counts: [warmCount]}); + await stopAndClose(ctrl); + }, 30000); + + it("should receive on two controllers concurrently", async () => { + const ctrlA = await core.chatMigrateInit(path.join(tmpDir, "simplex_a"), "key", core.MigrationConfirmation.YesUp); + const ctrlB = await core.chatMigrateInit(path.join(tmpDir, "simplex_b"), "key", core.MigrationConfirmation.YesUp); + const start = Date.now(); + await expect(Promise.all([core.chatRecvMsgWait(ctrlA, 1_000_000), core.chatRecvMsgWait(ctrlB, 1_000_000)])) + .resolves.toEqual([undefined, undefined]); + const elapsed = Date.now() - start; + expect(elapsed).toBeGreaterThanOrEqual(900); + expect(elapsed).toBeLessThan(1800); + await stopAndClose(ctrlA); + await stopAndClose(ctrlB); + }, 10000); + + it("should receive events of one controller in order", async () => { + const ctrl = await core.chatMigrateInit(dbPath, "key", core.MigrationConfirmation.YesUp); + for (const action of ["first", "second"]) { + await expect(core.chatSendCmd(ctrl, `/debug event {"type": "timedAction", "action": "${action}", "durationMilliseconds": 1}`)) + .resolves.toMatchObject({type: "cmdOk"}); + } + const events = await Promise.all([core.chatRecvMsgWait(ctrl, 500_000), core.chatRecvMsgWait(ctrl, 500_000)]); + expect(events).toMatchObject([ + {type: "timedAction", action: "first"}, + {type: "timedAction", action: "second"} + ]); + await stopAndClose(ctrl); + }, 10000); + + it("should close the store while a receive is in flight", async () => { + const ctrl = await core.chatMigrateInit(dbPath, "key", core.MigrationConfirmation.YesUp); + // starts the receiver thread, so the next request only has to wake it + await core.chatRecvMsgWait(ctrl, 1); + const settle = (p: Promise) => p.then((event) => ({event}), (e: Error) => ({error: e.message})); + const receives = Promise.all([settle(core.chatRecvMsgWait(ctrl, 3_000_000)), settle(core.chatRecvMsgWait(ctrl, 3_000_000))]); + // the thread enters the first receive within this margin even under load, so the second one is still queued at close + await new Promise((resolve) => setTimeout(resolve, 500)); + await expect(core.chatSendCmd(ctrl, "/_stop")).resolves.toMatchObject({type: "chatStopped"}); + let closed = false; + const close = core.chatCloseStore(ctrl).then(() => { closed = true; }); + const timerStart = Date.now(); + const timerDelay = await new Promise((resolve) => setTimeout(() => resolve(Date.now() - timerStart), 10)); + expect({closed, timerDelayBelow100ms: timerDelay < 100}).toEqual({closed: false, timerDelayBelow100ms: true}); + await close; + expect(await receives).toEqual([{event: undefined}, {error: "chat receiver stopped"}]); + }, 10000); + + it("should let the process exit while a receiver is idle", () => { + const childDbPath = path.resolve(tmpDir, "simplex_child"); + const script = ` + const simplex = require("./build/Release/simplex.node"); + simplex.chat_migrate_init(${JSON.stringify(childDbPath)}, "key", "yesUp") + .then(([ctrl]) => simplex.chat_recv_msg_wait(ctrl, 1)) + .then((res) => console.log("received " + JSON.stringify(res))); + `; + const child = spawnSync(process.execPath, ["-e", script], {cwd: path.join(__dirname, ".."), timeout: 10000, encoding: "utf8"}); + if (child.status !== 0 || child.signal !== null) console.log("child stderr:", child.stderr); + expect({status: child.status, signal: child.signal, stdout: child.stdout.trim()}) + .toEqual({status: 0, signal: null, stdout: 'received ""'}); + }, 15000); + + it("should not crash when closing stopped controllers repeatedly", async () => { + const script = ` + const fs = require("fs"), path = require("path"); + const simplex = require("./build/Release/simplex.node"); + (async () => { + for (let i = 0; i < 40; i++) { + const dir = fs.mkdtempSync(path.join(${JSON.stringify(path.resolve(tmpDir))}, "close-")); + const [ctrl] = await simplex.chat_migrate_init(path.join(dir, "simplex"), "key", "yesUp"); + await simplex.chat_send_cmd(ctrl, "/v"); + await simplex.chat_send_cmd(ctrl, "/_stop"); + const res = await simplex.chat_close_store(ctrl); + fs.rmSync(dir, {recursive: true, force: true}); + if (res !== "") throw new Error("close failed: " + res); + } + })(); + `; + const runChild = () => new Promise<{code: number | null, signal: NodeJS.Signals | null, stderr: string}>((resolve) => { + const child = execFile(process.execPath, ["-e", script], {cwd: path.join(__dirname, ".."), timeout: 150000}, (_error, _stdout, stderr) => + resolve({code: child.exitCode, signal: child.signalCode, stderr})); + }); + const childCount = 3; + const results = await Promise.all(Array.from({length: childCount}, runChild)); + for (const r of results) if (r.code !== 0 || r.signal !== null) console.log("child stderr:", r.stderr); + expect(results.map(({code, signal}) => ({code, signal}))).toEqual(Array(childCount).fill({code: 0, signal: null})); + }, 180000); }); diff --git a/packages/simplex-chat-nodejs/tests/util.test.ts b/packages/simplex-chat-nodejs/tests/util.test.ts index 4fe3140edc..29224cf1cc 100644 --- a/packages/simplex-chat-nodejs/tests/util.test.ts +++ b/packages/simplex-chat-nodejs/tests/util.test.ts @@ -34,4 +34,8 @@ describe("ciBotCommand", () => { const ci = {content: {type: "rcvDeleted"}} as T.ChatItem expect(ciBotCommand(ci)).toBeUndefined() }) + + it("keeps multi-line params", () => { + expect(ciBotCommand(rcvText("/review line1\nline2"))).toEqual({keyword: "review", params: "line1\nline2"}) + }) }) diff --git a/packages/simplex-chat-python/src/simplex_chat/_native.py b/packages/simplex-chat-python/src/simplex_chat/_native.py index 4c408479bb..009ff9a1e0 100644 --- a/packages/simplex-chat-python/src/simplex_chat/_native.py +++ b/packages/simplex-chat-python/src/simplex_chat/_native.py @@ -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 diff --git a/packages/simplex-chat-python/src/simplex_chat/api.py b/packages/simplex-chat-python/src/simplex_chat/api.py index e3d36c45df..876230ef39 100644 --- a/packages/simplex-chat-python/src/simplex_chat/api.py +++ b/packages/simplex-chat-python/src/simplex_chat/api.py @@ -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: diff --git a/packages/simplex-chat-python/src/simplex_chat/bot.py b/packages/simplex-chat-python/src/simplex_chat/bot.py index b3e5b5ec03..2fa0ed39b4 100644 --- a/packages/simplex-chat-python/src/simplex_chat/bot.py +++ b/packages/simplex-chat-python/src/simplex_chat/bot.py @@ -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, diff --git a/packages/simplex-chat-python/src/simplex_chat/client.py b/packages/simplex-chat-python/src/simplex_chat/client.py index 8ec955b54a..476dd70ac3 100644 --- a/packages/simplex-chat-python/src/simplex_chat/client.py +++ b/packages/simplex-chat-python/src/simplex_chat/client.py @@ -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: diff --git a/packages/simplex-chat-python/src/simplex_chat/core.py b/packages/simplex-chat-python/src/simplex_chat/core.py index 4fc847f7de..adebde1b56 100644 --- a/packages/simplex-chat-python/src/simplex_chat/core.py +++ b/packages/simplex-chat-python/src/simplex_chat/core.py @@ -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) diff --git a/packages/simplex-chat-python/src/simplex_chat/types/_commands.py b/packages/simplex-chat-python/src/simplex_chat/types/_commands.py index fd4f561c3d..e6155c9ce7 100644 --- a/packages/simplex-chat-python/src/simplex_chat/types/_commands.py +++ b/packages/simplex-chat-python/src/simplex_chat/types/_commands.py @@ -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 diff --git a/packages/simplex-chat-python/src/simplex_chat/types/_types.py b/packages/simplex-chat-python/src/simplex_chat/types/_types.py index 6a8fd96c87..ab06a24f53 100644 --- a/packages/simplex-chat-python/src/simplex_chat/types/_types.py +++ b/packages/simplex-chat-python/src/simplex_chat/types/_types.py @@ -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"] diff --git a/packages/simplex-chat-python/src/simplex_chat/util.py b/packages/simplex-chat-python/src/simplex_chat/util.py index e5fbbf3fab..92f2fa52cf 100644 --- a/packages/simplex-chat-python/src/simplex_chat/util.py +++ b/packages/simplex-chat-python/src/simplex_chat/util.py @@ -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: diff --git a/packages/simplex-chat-python/tests/test_api.py b/packages/simplex-chat-python/tests/test_api.py index 09b5ce4c03..1b20ec0639 100644 --- a/packages/simplex-chat-python/tests/test_api.py +++ b/packages/simplex-chat-python/tests/test_api.py @@ -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 diff --git a/packages/simplex-chat-python/tests/test_client_and_waiters.py b/packages/simplex-chat-python/tests/test_client_and_waiters.py index d40c74a0eb..1ecf146ec3 100644 --- a/packages/simplex-chat-python/tests/test_client_and_waiters.py +++ b/packages/simplex-chat-python/tests/test_client_and_waiters.py @@ -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" diff --git a/packages/simplex-chat-python/tests/test_codegen.py b/packages/simplex-chat-python/tests/test_codegen.py index c5842f5d56..20a9400d18 100644 --- a/packages/simplex-chat-python/tests/test_codegen.py +++ b/packages/simplex-chat-python/tests/test_codegen.py @@ -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" diff --git a/packages/simplex-chat-python/tests/test_core_migrate_init.py b/packages/simplex-chat-python/tests/test_core_migrate_init.py new file mode 100644 index 0000000000..f18b6d34f0 --- /dev/null +++ b/packages/simplex-chat-python/tests/test_core_migrate_init.py @@ -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] diff --git a/packages/simplex-chat-python/tests/test_native_cache.py b/packages/simplex-chat-python/tests/test_native_cache.py index c2938ee3e4..6c4f2a923d 100644 --- a/packages/simplex-chat-python/tests/test_native_cache.py +++ b/packages/simplex-chat-python/tests/test_native_cache.py @@ -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"] diff --git a/packages/simplex-chat-python/tests/test_recv_executor.py b/packages/simplex-chat-python/tests/test_recv_executor.py new file mode 100644 index 0000000000..ef160d5cf9 --- /dev/null +++ b/packages/simplex-chat-python/tests/test_recv_executor.py @@ -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() diff --git a/packages/simplex-chat-python/tests/test_util.py b/packages/simplex-chat-python/tests/test_util.py index 3ea0d87e6d..d5a43eabaa 100644 --- a/packages/simplex-chat-python/tests/test_util.py +++ b/packages/simplex-chat-python/tests/test_util.py @@ -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) == "🎉" diff --git a/src/Simplex/Chat/Mobile.hs b/src/Simplex/Chat/Mobile.hs index d2e1d8460d..085df84956 100644 --- a/src/Simplex/Chat/Mobile.hs +++ b/src/Simplex/Chat/Mobile.hs @@ -12,6 +12,7 @@ module Simplex.Chat.Mobile where import Control.Concurrent.STM import Control.Exception (SomeException, catch) +import Control.Monad import Control.Monad.Except import Control.Monad.Reader import Data.Aeson (ToJSON (..)) @@ -35,6 +36,7 @@ import Foreign.Ptr import Foreign.StablePtr import Foreign.Storable (poke) import GHC.IO.Encoding (setFileSystemEncoding, setForeignEncoding, setLocaleEncoding) +import Numeric.Natural (Natural) import Simplex.Chat import Simplex.Chat.Badges.Code (badgeCodeText, parseBadgeCode) import Simplex.Chat.Controller @@ -73,6 +75,7 @@ import qualified Simplex.Messaging.Agent.Store.DB as DB data DBMigrationResult = DBMOk | DBMInvalidConfirmation + | DBMInvalidQueueSize | DBMErrorNotADatabase {dbFile :: String} | DBMErrorMigration {dbFile :: String, migrationError :: MigrationError} | DBMErrorSQL {dbFile :: String, migrationSQLError :: String} @@ -113,6 +116,8 @@ foreign export ccall "chat_migrate_init" cChatMigrateInit :: CString -> CString foreign export ccall "chat_migrate_init_key" cChatMigrateInitKey :: CString -> CString -> CInt -> CString -> CInt -> Ptr (StablePtr ChatController) -> IO CJSONString +foreign export ccall "chat_migrate_init_queue" cChatMigrateInitQueue :: CString -> CString -> CString -> CInt -> Ptr (StablePtr ChatController) -> IO CJSONString + foreign export ccall "chat_close_store" cChatCloseStore :: StablePtr ChatController -> IO CString foreign export ccall "chat_reopen_store" cChatReopenStore :: StablePtr ChatController -> IO CString @@ -166,7 +171,14 @@ cChatMigrateInit fp key conf = cChatMigrateInitKey fp key 0 conf 0 -- For postgres first param is schema prefix, second param is database connection string. cChatMigrateInitKey :: CString -> CString -> CInt -> CString -> CInt -> Ptr (StablePtr ChatController) -> IO CJSONString -cChatMigrateInitKey fp key keepKey conf background ctrl = do +cChatMigrateInitKey fp key keepKey conf background = cChatMigrateInit_ fp key (keepKey /= 0) conf (background /= 0) mobileQueueSize + +-- | queueSize is the size of internal queues, same as terminal option --queue-size +cChatMigrateInitQueue :: CString -> CString -> CString -> CInt -> Ptr (StablePtr ChatController) -> IO CJSONString +cChatMigrateInitQueue fp key conf queueSize = cChatMigrateInit_ fp key False conf False (fromIntegral queueSize) + +cChatMigrateInit_ :: CString -> CString -> Bool -> CString -> Bool -> Int -> Ptr (StablePtr ChatController) -> IO CJSONString +cChatMigrateInit_ fp key keepKey conf background queueSize ctrl = do -- ensure we are set to UTF-8; iOS does not have locale, and will default to -- US-ASCII all the time. setLocaleEncoding utf8 @@ -176,7 +188,7 @@ cChatMigrateInitKey fp key keepKey conf background ctrl = do chatDbOpts <- mobileDbOpts fp key confirm <- peekCAString conf r <- - chatMigrateInitKey chatDbOpts (keepKey /= 0) confirm (background /= 0) >>= \case + chatMigrateInitKey chatDbOpts keepKey confirm background queueSize >>= \case Right cc -> (newStablePtr cc >>= poke ctrl) $> DBMOk Left e -> pure e newCStringFromLazyBS $ J.encode r @@ -254,8 +266,11 @@ cChatParseBadgeCode cCode = do cChatJsonLength :: CString -> IO CInt cChatJsonLength s = fromIntegral . subtract 2 . LB.length . J.encode . safeDecodeUtf8 <$> B.packCString s -mobileChatOpts :: ChatDbOpts -> ChatOpts -mobileChatOpts dbOptions = +mobileQueueSize :: Int +mobileQueueSize = 4096 + +mobileChatOpts :: ChatDbOpts -> Natural -> ChatOpts +mobileChatOpts dbOptions tbqSize = ChatOpts { coreOptions = CoreChatOpts @@ -268,7 +283,7 @@ mobileChatOpts dbOptions = logServerHosts = True, logAgent = Nothing, logFile = Nothing, - tbqSize = 4096, + tbqSize, maxChats = 5000, deviceName = Nothing, chatRelay = False, @@ -313,18 +328,19 @@ getActiveUser_ st = find activeUser <$> withTransaction st getUsers chatMigrateInit :: String -> ScrubbedBytes -> String -> IO (Either DBMigrationResult ChatController) chatMigrateInit dbFilePrefix dbKey confirm = do let chatDBOpts = ChatDbOpts {dbFilePrefix, dbKey, trackQueries = DB.TQSlow 5000, vacuumOnMigration = True} - chatMigrateInitKey chatDBOpts False confirm False + chatMigrateInitKey chatDBOpts False confirm False mobileQueueSize #endif -chatMigrateInitKey :: ChatDbOpts -> Bool -> String -> Bool -> IO (Either DBMigrationResult ChatController) -chatMigrateInitKey chatDbOpts keepKey confirm backgroundMode = runExceptT $ do +chatMigrateInitKey :: ChatDbOpts -> Bool -> String -> Bool -> Int -> IO (Either DBMigrationResult ChatController) +chatMigrateInitKey chatDbOpts keepKey confirm backgroundMode queueSize = runExceptT $ do + unless (queueSize > 0) $ throwError DBMInvalidQueueSize confirmMigrations <- liftEitherWith (const DBMInvalidConfirmation) $ strDecode $ B.pack confirm let migrationConfig = MigrationConfig confirmMigrations (Just "") chatStore <- migrate createChatStore (toDBOpts chatDbOpts chatSuffix keepKey chatDBFunctions) migrationConfig agentStore <- migrate createAgentStore (toDBOpts chatDbOpts agentSuffix keepKey []) migrationConfig ExceptT $ initialize chatStore ChatDatabase {chatStore, agentStore} where - opts = mobileChatOpts $ removeDbKey chatDbOpts + opts = mobileChatOpts (removeDbKey chatDbOpts) (fromIntegral queueSize) initialize st db = do user_ <- liftIO $ getActiveUser_ st first DBMAgentError <$> newChatController db user_ defaultMobileConfig opts backgroundMode diff --git a/tests/MobileTests.hs b/tests/MobileTests.hs index e6aecfd295..b93e1c8c8c 100644 --- a/tests/MobileTests.hs +++ b/tests/MobileTests.hs @@ -25,7 +25,7 @@ import qualified Data.ByteString.Lazy.Char8 as LB import Data.Time.Clock (getCurrentTime) import Data.Word (Word8, Word32) import Foreign.C -import Foreign.Marshal.Alloc (mallocBytes) +import Foreign.Marshal.Alloc (alloca, mallocBytes) import Foreign.Marshal.Utils (copyBytes) import Foreign.Ptr import Foreign.StablePtr @@ -34,7 +34,7 @@ import GHC.IO.Encoding (setLocaleEncoding, setFileSystemEncoding, setForeignEnco import JSONFixtures import Simplex.Chat import Simplex.Chat.Badges (BadgeInfo (..), BadgeRequest (..), BadgeType (..), generateMasterKey, verifyCredential) -import Simplex.Chat.Controller (ChatController (..), ChatDatabase (..)) +import Simplex.Chat.Controller (ChatConfig (..), ChatController (..), ChatDatabase (..)) import Simplex.Chat.Mobile hiding (error) import Simplex.Chat.Mobile.Badges hiding (error) import Simplex.Chat.Mobile.File @@ -44,6 +44,8 @@ import Simplex.Chat.Options.DB import Simplex.Chat.Store import Simplex.Chat.Store.Profiles import Simplex.Chat.Types (AgentUserId (..), Profile (..)) +import Simplex.Messaging.Agent.Client (AgentClient (..)) +import Simplex.Messaging.Agent.Env.SQLite (AgentConfig (..), Env (..)) import Simplex.Messaging.Agent.Store.Shared (MigrationConfig (..), MigrationConfirmation (..)) import qualified Simplex.Messaging.Agent.Store.SQLite.DB as DB import qualified Simplex.Messaging.Crypto as C @@ -65,6 +67,7 @@ mobileTests = do setForeignEncoding utf8 it "start new chat without user" testChatApiNoUser it "start new chat with existing user" testChatApi + it "should set queue size via C API" testChatMigrateInitQueueCApi it "should encrypt/decrypt WebRTC frames" testMediaApi it "should encrypt/decrypt WebRTC frames via C API" testMediaCApi describe "should read/write encrypted files via C API" $ do @@ -165,6 +168,22 @@ testChatApi ps = do chatParseMarkdown "hello" `shouldBe` "{}" chatParseMarkdown "*hello*" `shouldBe` parsedMarkdown +testChatMigrateInitQueueCApi :: TestParams -> IO () +testChatMigrateInitQueueCApi ps = do + cPath <- newCString $ tmpPath ps "1" + cKey <- newCString "" + cConfirm <- newCString "yesUp" + alloca $ \ctrlPtr -> do + let migrateInit queueSize = peekCAString =<< cChatMigrateInitQueue cPath cKey cConfirm queueSize ctrlPtr + migrateInit 0 `shouldReturn` jsonStr DBMInvalidQueueSize + migrateInit (-1) `shouldReturn` jsonStr DBMInvalidQueueSize + migrateInit 65536 `shouldReturn` jsonStr DBMOk + ChatController {config = ChatConfig {tbqSize}, smpAgent = AgentClient {agentEnv = Env {config = AgentConfig {tbqSize = agentQSize}}}} <- deRefStablePtr =<< peek ctrlPtr + tbqSize `shouldBe` 65536 + agentQSize `shouldBe` 65536 + where + jsonStr = LB.unpack . J.encode + testMediaApi :: HasCallStack => TestParams -> IO () testMediaApi ps = do let tmp = tmpPath ps