From 5381855daacc827d71360c4c6a7c56f283d9ec72 Mon Sep 17 00:00:00 2001 From: KeviM Date: Fri, 25 Sep 2026 14:35:26 -0700 Subject: [PATCH 1/2] fix: harden macOS support and shorten the README - WebSocketClient's network thread blocks SIGPIPE. OpenSSL sends with write(), so a reset peer raised SIGPIPE on every POSIX platform once an application restored the default handler that libwebsockets turns off. - KALSHI_NATIVE_ARCH probes -march=native at configure time and stops when the compiler rejects it for the target, as universal builds do. - make lint/format/tidy find Homebrew's keg-only llvm@18. - CI builds macOS against 13.3, the minimum deployment target that std::to_chars(double) requires. - README is shorter: an Errors section explains Result/std::expected after the first example, and the setup states the tested platforms. --- .github/workflows/ci.yml | 3 +- CHANGELOG.md | 14 +++ CMakeLists.txt | 17 +++- CONTRIBUTING.md | 6 +- Makefile | 5 +- README.md | 160 ++++++++++++++++------------------- include/kalshi/websocket.hpp | 5 +- src/ws/websocket.cpp | 13 +++ tests/test_ws_client.cpp | 31 +++++++ 9 files changed, 158 insertions(+), 96 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0da8dd3..b4dfa85 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -64,10 +64,11 @@ jobs: - uses: hendrikmuhs/ccache-action@f09c25b45002a07be2955cbe52e8cee55643f89d # v1.2.24 with: key: macos-release + # 13.3 is the documented minimum; std::to_chars(double) needs it. - name: Build run: >- make build CMAKE_ARGS="-G Ninja -DCMAKE_CXX_COMPILER_LAUNCHER=ccache - -DKALSHI_WARNINGS_AS_ERRORS=ON" + -DKALSHI_WARNINGS_AS_ERRORS=ON -DCMAKE_OSX_DEPLOYMENT_TARGET=13.3" - name: Test run: make test diff --git a/CHANGELOG.md b/CHANGELOG.md index d4dd899..993cba8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,20 @@ uses [Semantic Versioning](https://semver.org/spec/v2.0.0.html). view over the start of a longer buffer was counted from the bytes after it. Counting also no longer copies each order: 20 orders now take 6 allocations instead of 26. +- `WebSocketClient`'s network thread blocks SIGPIPE, so a connection the peer + reset can't end the process. It relied on libwebsockets ignoring SIGPIPE + process-wide, which an application that restores the default handler undoes. + Callbacks that run on that thread see SIGPIPE blocked. + +### Changed + +- `KALSHI_NATIVE_ARCH` stops at configure time when the compiler rejects + `-march=native` for the target, as in universal macOS builds, instead of + failing mid-build. +- `make lint`, `make format`, and `make tidy` find Homebrew's keg-only + `llvm@18` without `PATH` changes. +- README is shorter and states the macOS 13.3 minimum deployment target, which + CI now builds against. ## [0.6.1] - 2026-09-25 diff --git a/CMakeLists.txt b/CMakeLists.txt index f7afa8d..6d436dd 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -21,7 +21,7 @@ option(KALSHI_BUILD_EXAMPLES "Build the examples" ${PROJECT_IS_TOP_LEVEL}) option(KALSHI_BUILD_BENCHMARKS "Build Google Benchmark targets" OFF) option(KALSHI_USE_SYSTEM_GLAZE "Use an installed Glaze instead of fetching it" OFF) option(KALSHI_ENABLE_LTO "Enable interprocedural optimization for project targets" OFF) -option(KALSHI_NATIVE_ARCH "Compile project targets with -march=native" OFF) +option(KALSHI_NATIVE_ARCH "Tune project targets for this machine's CPU (-march=native)" OFF) option(KALSHI_ENABLE_CLANG_TIDY "Run clang-tidy while compiling project targets" OFF) option(KALSHI_WARNINGS_AS_ERRORS "Treat project warnings as errors" OFF) option(KALSHI_ENABLE_SANITIZERS "Enable AddressSanitizer and UndefinedBehaviorSanitizer" OFF) @@ -32,8 +32,21 @@ if(KALSHI_ENABLE_SANITIZERS AND KALSHI_ENABLE_THREAD_SANITIZER) message(FATAL_ERROR "AddressSanitizer and ThreadSanitizer require separate builds") endif() +# -march=native names the build machine's CPU, so compilers reject it when +# targeting another architecture, as cross and universal macOS builds do. +if(KALSHI_NATIVE_ARCH AND NOT MSVC) + include(CheckCXXCompilerFlag) + check_cxx_compiler_flag(-march=native KALSHI_MARCH_NATIVE_WORKS) + if(NOT KALSHI_MARCH_NATIVE_WORKS) + message(FATAL_ERROR "KALSHI_NATIVE_ARCH needs a build for this machine's CPU, " + "but the compiler rejects -march=native for this target") + endif() +endif() + if(KALSHI_ENABLE_CLANG_TIDY) - find_program(KALSHI_CLANG_TIDY_EXECUTABLE NAMES clang-tidy-18 clang-tidy REQUIRED) + # CI uses clang-tidy 18. Homebrew's llvm@18 is keg-only, so look there too. + find_program(KALSHI_CLANG_TIDY_EXECUTABLE NAMES clang-tidy-18 clang-tidy + HINTS /opt/homebrew/opt/llvm@18/bin /usr/local/opt/llvm@18/bin REQUIRED) endif() if(KALSHI_ENABLE_LTO) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 5bb71b3..5e77010 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -19,13 +19,13 @@ pipx install cmake && pipx ensurepath # then open a new shell make test ``` -On macOS, Homebrew's `llvm@18` provides clang-format 18 without putting it on -`PATH`, and PyYAML goes in a virtual environment: +On macOS, Homebrew's `llvm@18` provides clang-format and clang-tidy 18, which +`make` finds on its own. PyYAML goes in a virtual environment: ```bash brew install cmake ninja pkg-config openssl libwebsockets llvm@18 python3 -m venv .venv && .venv/bin/pip install pyyaml -export CLANG_FORMAT="$(brew --prefix llvm@18)/bin/clang-format" PYTHON=.venv/bin/python +export PYTHON=.venv/bin/python make test lint ``` diff --git a/Makefile b/Makefile index e23f243..8da2f26 100644 --- a/Makefile +++ b/Makefile @@ -5,7 +5,10 @@ BUILD_TYPE ?= Release CMAKE_ARGS ?= JOBS ?= $(shell getconf _NPROCESSORS_ONLN 2>/dev/null || echo 4) PYTHON ?= python3 -CLANG_FORMAT ?= $(shell command -v clang-format-18 2>/dev/null || command -v clang-format 2>/dev/null) +# Homebrew's llvm@18 is keg-only, so it is not on PATH. +CLANG_FORMAT ?= $(firstword $(shell command -v clang-format-18 2>/dev/null) \ + $(wildcard /opt/homebrew/opt/llvm@18/bin/clang-format /usr/local/opt/llvm@18/bin/clang-format) \ + $(shell command -v clang-format 2>/dev/null)) CLANG_FORMAT_MAJOR := 18 # Tracked and new C++ sources that exist on disk (deleted files are skipped). CPP_SOURCES = git ls-files -z --cached --others --exclude-standard '*.cpp' '*.hpp' | \ diff --git a/README.md b/README.md index 1688e38..d5295fe 100644 --- a/README.md +++ b/README.md @@ -3,36 +3,31 @@ [![CI](https://github.com/Reddimus/kalshi-cpp/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/Reddimus/kalshi-cpp/actions/workflows/ci.yml) [![Release](https://img.shields.io/github/v/release/Reddimus/kalshi-cpp)](https://github.com/Reddimus/kalshi-cpp/releases) -A C++23 client for Kalshi's Predictions API. It covers every REST operation in -Kalshi's OpenAPI document and streams market data over WebSockets. Requests are -signed with Ed25519 or RSA-PSS keys, prices stay exact fixed-point strings, and -every call returns `std::expected` instead of throwing. +An unofficial C++23 client for Kalshi's Predictions API. It covers every +[REST operation](docs/operations.md) and [WebSocket channel](docs/channels.md). +A generator builds it from Kalshi's OpenAPI and AsyncAPI documents in +[`spec/`](https://github.com/Reddimus/kalshi-cpp/tree/main/spec), so type and +field names match [Kalshi's API docs](https://docs.kalshi.com). Kalshi's +separate Margin and Perpetuals API is out of scope. The +[API reference](https://reddimus.github.io/kalshi-cpp/) lists every type and method. -The client is generated from Kalshi's OpenAPI and AsyncAPI documents in -[`spec/`](https://github.com/Reddimus/kalshi-cpp/tree/main/spec), so type and field names match -[Kalshi's API reference](https://docs.kalshi.com). The -[API reference for this library](https://reddimus.github.io/kalshi-cpp/) is -built from its headers. Kalshi's separate Margin and Perpetuals API is out of -scope. - -## Build +## Install You need a C++23 compiler, CMake 3.31+, OpenSSL 3, libcurl, and libwebsockets. +CI tests GCC 13 on Ubuntu 24.04, Apple Clang on Apple silicon, and MSVC on +Windows, where [`vcpkg.json`](https://github.com/Reddimus/kalshi-cpp/blob/main/vcpkg.json) +supplies the dependencies. On macOS, the deployment target must be 13.3 or later. ```bash # macOS -brew install cmake openssl curl libwebsockets pkg-config +brew install cmake openssl libwebsockets pkg-config # Ubuntu 24.04, whose apt CMake is older than 3.31 sudo apt install build-essential pkg-config pipx libssl-dev libcurl4-openssl-dev libwebsockets-dev pipx install cmake && pipx ensurepath # then open a new shell - -cmake -S . -B build -DCMAKE_BUILD_TYPE=Release -cmake --build build --parallel -ctest --test-dir build --output-on-failure ``` -## Use it from CMake +Add the library to your CMake project: ```cmake include(FetchContent) @@ -44,7 +39,8 @@ target_link_libraries(myapp PRIVATE kalshi::kalshi) ``` After `cmake --install`, `find_package(kalshi CONFIG REQUIRED)` provides the -same `kalshi::kalshi` target. +same target. Until 1.0, a minor release may break the API. +[CHANGELOG.md](CHANGELOG.md) has migration notes. ## Read market data @@ -58,7 +54,7 @@ int main() { kalshi::KalshiClient client{kalshi::HttpClient{}}; kalshi::GetMarketsParams params; - params.series_ticker = "KXHIGHNY"; + params.series_ticker = "KXHIGHNY"; // New York City's daily high temperature params.status = kalshi::GetMarketsStatus::Open; kalshi::Result page = client.get_markets(params); @@ -74,20 +70,21 @@ int main() { `kalshi::collect_pages` follows cursors when you want every page. -## Authenticate +## Errors -Create a key under API keys at and save -the private key file Kalshi gives you. Kalshi recommends Ed25519 keys. If the -page offers to use your own public key, you can generate the pair locally so -the private key never leaves your machine: +Calls that can fail return `kalshi::Result`, which is +`std::expected`, instead of throwing. `Error::code` classifies +the failure, such as `RateLimited` or `NetworkError`. When Kalshi rejects a +request, `Error::http_status` and `Error::api_code` hold its HTTP status and +error code. -```bash -openssl genpkey -algorithm ed25519 -out kalshi.key -openssl pkey -in kalshi.key -pubout # paste this public key into Kalshi -``` +## Authenticate + +Create an API key at . Save the private key +file and note the key ID. `Signer` loads unencrypted RSA and Ed25519 PEM keys. ```cpp -kalshi::Result signer = kalshi::Signer::from_pem_file(key_id, "kalshi.key"); +kalshi::Result signer = kalshi::Signer::from_pem_file("your-key-id", "kalshi.key"); if (!signer) { std::cerr << signer.error().message << '\n'; return 1; @@ -96,49 +93,30 @@ kalshi::KalshiClient client{kalshi::HttpClient{*signer}}; // Signer is cheap t kalshi::Result balance = client.get_balance(); ``` -`ClientConfig::for_environment(kalshi::Environment::Demo)` points the client at -Kalshi's demo exchange, which uses separate keys and play money. +Kalshi's demo exchange uses play money and its own keys. To use it, pass +`kalshi::ClientConfig::for_environment(kalshi::Environment::Demo)` to +`HttpClient` after the signer. `kalshi::WsConfig::for_environment` does the same +for `WebSocketClient`. ## Place an order +This places a real order unless `client` points at the demo exchange. + ```cpp kalshi::CreateOrderV2Request order; order.ticker = "KXHIGHNY-26SEP25-T70"; -order.side = kalshi::BookSide::Bid; -order.count = "10.00"; -order.price = "0.5600"; +order.side = kalshi::BookSide::Bid; // Bid buys Yes, Ask sells Yes +order.count = "10.00"; // contracts +order.price = "0.5600"; // dollars order.time_in_force = kalshi::TimeInForce::GoodTillCanceled; order.self_trade_prevention_type = kalshi::SelfTradePreventionType::TakerAtCross; kalshi::Result placed = client.create_order(order); ``` -The client checks required fields and fixed-point strings before sending, so -`order.price = "56c"` fails locally with `InvalidRequest` instead of reaching the -exchange. - -## Errors - -`Error::code` says what went wrong: `InvalidRequest`, `AuthenticationError`, -`NotFound`, `RateLimited`, `ServerError`, `NetworkError`, `ParseError`, -`SigningError`, or `InvalidKey`. `Error::http_status` and `Error::api_code` carry -Kalshi's status and error code, and `Error::message` includes its explanation. - -## Retries and rate limits - -Both are transports you stack under the client: - -```cpp -std::shared_ptr http = std::make_shared(*signer); -std::shared_ptr paced = - std::make_shared(http, kalshi::RateLimitConfig{}); -kalshi::KalshiClient client{std::make_shared(paced)}; -``` - -`RetryingTransport` repeats a write only after a 429, so an order is never sent -twice. `RateLimitConfig` defaults to Kalshi's Basic tier. For your account's -budgets, pass the results of `get_account_api_limits()` and -`get_account_endpoint_costs()` to `rate_limit_config()`. +Counts and prices are fixed-point strings, not doubles. The client checks them +and the required fields before sending, so `order.price = "56c"` fails locally +with `InvalidRequest`. ## Stream updates @@ -155,35 +133,43 @@ kalshi::Result book = ws.subscribe( kalshi::Result connected = ws.connect(); ``` -The client reconnects after a dropped connection and resubscribes with each -subscription's current markets. A `ws::Subscription` handle stays the same -throughout, and every `ws::Update` names the subscription it belongs to. When -an order book sequence number is skipped, the client reports the gap to -`on_error` and requests fresh snapshots. [docs/channels.md](docs/channels.md) -lists each channel's message types. +`connect()` waits until the connection opens or fails. Callbacks then run on the +client's network thread until you call `disconnect()` or destroy `ws`, so keep +your program running. After a dropped connection, the client reconnects, +resubscribes, and keeps your `ws::Subscription` handles valid. When it sees a +skipped order book sequence number, it reports the gap to `on_error` and +requests fresh snapshots. [docs/channels.md](docs/channels.md) lists each +channel's message types. -## Examples +## Retries and rate limits -| Program | What it does | -| --- | --- | -| [`market_data`](https://github.com/Reddimus/kalshi-cpp/blob/main/examples/market_data.cpp) | Markets, an order book, and candlesticks, without a key | -| [`portfolio`](https://github.com/Reddimus/kalshi-cpp/blob/main/examples/portfolio.cpp) | Balance, positions, and resting orders | -| [`place_and_cancel_order`](https://github.com/Reddimus/kalshi-cpp/blob/main/examples/place_and_cancel_order.cpp) | A resting order and its cancel, on the demo exchange only | -| [`stream_orderbook`](https://github.com/Reddimus/kalshi-cpp/blob/main/examples/stream_orderbook.cpp) | A live local order book that recovers from gaps and reconnects | +Both are transports you stack under the client: -Put `KALSHI_API_KEY_ID`, `KALSHI_API_KEY_FILE`, and optionally `KALSHI_ENV=demo` -in `.env`, then run `make run-portfolio`. +```cpp +std::shared_ptr http = std::make_shared(*signer); +std::shared_ptr paced = + std::make_shared(http, kalshi::RateLimitConfig{}); +kalshi::KalshiClient client{std::make_shared(paced)}; +``` -## Develop +By default, `RetryingTransport` repeats a POST, PUT, or DELETE only after a 429, +so it never places an order twice. `RateLimitConfig{}` matches Kalshi's Basic +tier. To match your account's limits, pass the results of +`get_account_api_limits()` and `get_account_endpoint_costs()` to +`kalshi::rate_limit_config()`. -```bash -make format lint test # before every commit -make sanitize tsan tidy # ASan/UBSan, ThreadSanitizer, clang-tidy -make consumers bench # packaging check, benchmarks -make codegen # after updating a spec in spec/ -make docs # API reference in build-docs/html -``` +## Examples + +[`examples/`](https://github.com/Reddimus/kalshi-cpp/tree/main/examples) has +four programs: public market data, your portfolio, an order placed and canceled +on the demo exchange, and a live order book. From a clone, +`make run-market_data` runs the first one without a key. For the others, +`make run-` loads your key from `.env`, as the +[examples README](https://github.com/Reddimus/kalshi-cpp/blob/main/examples/README.md) +shows. + +## Contributing -[CONTRIBUTING.md](CONTRIBUTING.md) covers the workflow and release steps. -[CHANGELOG.md](CHANGELOG.md) lists changes and migration notes. -Report security issues as described in [SECURITY.md](SECURITY.md). +[CONTRIBUTING.md](CONTRIBUTING.md) covers building from source, tests, code +generation, and releases. Report security issues as +[SECURITY.md](SECURITY.md) describes. diff --git a/include/kalshi/websocket.hpp b/include/kalshi/websocket.hpp index 0cac2df..9d87ac5 100644 --- a/include/kalshi/websocket.hpp +++ b/include/kalshi/websocket.hpp @@ -81,8 +81,9 @@ struct WsConfig { /// /// Subscriptions survive reconnects: the client resubscribes with the same /// parameters and keeps each `ws::Subscription` handle, while the server's -/// `sid` changes. Callbacks run on the client's network thread, except the -/// Disconnected state change from `disconnect()`, which runs on the caller's. +/// `sid` changes. Callbacks run on the client's network thread, which blocks +/// SIGPIPE, except the Disconnected state change from `disconnect()`, which +/// runs on the caller's. /// They may call any method, including destroying the client, but should /// return quickly. Every method is thread-safe. class WebSocketClient { diff --git a/src/ws/websocket.cpp b/src/ws/websocket.cpp index 9d216fd..8ed1113 100644 --- a/src/ws/websocket.cpp +++ b/src/ws/websocket.cpp @@ -24,6 +24,10 @@ #include "subscriptions.hpp" #include "ws_endpoint.hpp" +#ifndef _WIN32 +#include +#endif + namespace kalshi { namespace { @@ -379,6 +383,15 @@ struct WebSocketClient::Impl : std::enable_shared_from_this { } void run() { +#ifndef _WIN32 + // OpenSSL sends with write(), which raises SIGPIPE once the peer has reset the + // connection. libwebsockets ignores SIGPIPE process-wide, but the app may restore + // the default, which ends the process, so block it on this thread. + sigset_t pipe{}; + sigemptyset(&pipe); + sigaddset(&pipe, SIGPIPE); + pthread_sigmask(SIG_BLOCK, &pipe, nullptr); +#endif lws_context* active = nullptr; { const std::lock_guard lock(mutex); diff --git a/tests/test_ws_client.cpp b/tests/test_ws_client.cpp index 103820b..74d73e4 100644 --- a/tests/test_ws_client.cpp +++ b/tests/test_ws_client.cpp @@ -21,6 +21,8 @@ #include "test_signer_fixture.hpp" #ifndef _WIN32 +#include +#include #include #include #include @@ -217,6 +219,35 @@ TEST_F(WsClient, ConnectTimesOutWhenTheHandshakeNeverFinishes) { } #endif +#ifndef _WIN32 +TEST_F(WsClient, NetworkThreadBlocksSigpipe) { + // The network thread inherits this thread's mask, so start with SIGPIPE unblocked. + sigset_t pipe{}; + sigemptyset(&pipe); + sigaddset(&pipe, SIGPIPE); + ASSERT_EQ(pthread_sigmask(SIG_UNBLOCK, &pipe, nullptr), 0); + + std::promise blocked; + std::future result = blocked.get_future(); + std::once_flag once; + kalshi::WebSocketClient ws(kalshi::test::make_signer(), local(server)); + ws.on_message([&](const kalshi::WsMessage& /*message*/) { + std::call_once(once, [&] { + sigset_t current{}; + pthread_sigmask(SIG_BLOCK, nullptr, ¤t); + blocked.set_value(sigismember(¤t, SIGPIPE) == 1); + }); + }); + ASSERT_TRUE(ws.connect().has_value()); + const kalshi::Result id = ws.list_subscriptions(); + ASSERT_TRUE(id.has_value()); + ASSERT_TRUE(server.next_command().has_value()); + server.send(R"({"id":)" + std::to_string(*id) + R"(,"type":"ok","msg":[]})"); + ASSERT_EQ(result.wait_for(5s), std::future_status::ready); + EXPECT_TRUE(result.get()); +} +#endif + TEST_F(WsClient, OpenSslStillWorksAfterASessionEnds) { { kalshi::WebSocketClient ws(kalshi::test::make_signer(), local(server)); From e8cca086e219b13b2549ccfc764ef29139a5f351 Mon Sep 17 00:00:00 2001 From: KeviM Date: Fri, 25 Sep 2026 15:01:25 -0700 Subject: [PATCH 2/2] fix: set SO_NOSIGPIPE where signals go to the process; re-run the arch probe Review of everything since v0.6.1: - macOS and the BSDs send a write's SIGPIPE to the whole process, so the network thread's mask protected only Linux. Sockets now also set SO_NOSIGPIPE in LWS_CALLBACK_CONNECTING where the OS defines it. A local wss server that resets connections killed the mask-only build with SIGPIPE; the fixed build survives on macOS and Linux. - check_cxx_compiler_flag cached the -march=native result, so a reconfigure with different architectures reused a stale answer. - LoopReport counted the allocation made by recording the instructions counter on macOS. --- CHANGELOG.md | 12 ++++++++---- CMakeLists.txt | 2 ++ benchmarks/benchmarks.cpp | 5 ++++- include/kalshi/websocket.hpp | 4 ++-- src/ws/websocket.cpp | 15 ++++++++++++++- tests/support/ws_test_server.hpp | 2 ++ tests/test_ws_client.cpp | 25 +++++++++++++++++++++++++ 7 files changed, 57 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 993cba8..26b2253 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,10 +13,14 @@ uses [Semantic Versioning](https://semver.org/spec/v2.0.0.html). view over the start of a longer buffer was counted from the bytes after it. Counting also no longer copies each order: 20 orders now take 6 allocations instead of 26. -- `WebSocketClient`'s network thread blocks SIGPIPE, so a connection the peer - reset can't end the process. It relied on libwebsockets ignoring SIGPIPE - process-wide, which an application that restores the default handler undoes. - Callbacks that run on that thread see SIGPIPE blocked. +- A connection the peer reset can no longer end the process with SIGPIPE. + `WebSocketClient` relied on libwebsockets ignoring SIGPIPE process-wide, + which an application that restores the default handler undoes. Its sockets + now set `SO_NOSIGPIPE` where the OS has it (macOS and the BSDs, which signal + the whole process), and its network thread blocks SIGPIPE (Linux, which + signals the writing thread). Callbacks on that thread see SIGPIPE blocked. +- On macOS, the benchmarks' `allocs` and `alloc_bytes` no longer count the + allocation that recording the `instructions` counter makes. ### Changed diff --git a/CMakeLists.txt b/CMakeLists.txt index 6d436dd..0012267 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -36,6 +36,8 @@ endif() # targeting another architecture, as cross and universal macOS builds do. if(KALSHI_NATIVE_ARCH AND NOT MSVC) include(CheckCXXCompilerFlag) + # Probe on every configure: the answer changes with CMAKE_OSX_ARCHITECTURES. + unset(KALSHI_MARCH_NATIVE_WORKS CACHE) check_cxx_compiler_flag(-march=native KALSHI_MARCH_NATIVE_WORKS) if(NOT KALSHI_MARCH_NATIVE_WORKS) message(FATAL_ERROR "KALSHI_NATIVE_ARCH needs a build for this machine's CPU, " diff --git a/benchmarks/benchmarks.cpp b/benchmarks/benchmarks.cpp index 17e08eb..2ca93a9 100644 --- a/benchmarks/benchmarks.cpp +++ b/benchmarks/benchmarks.cpp @@ -59,14 +59,17 @@ std::uint64_t instructions_retired() noexcept { class LoopReport { public: void report(benchmark::State& state) const { + // Read both before touching state.counters, whose map allocates. const std::uint64_t instructions = instructions_retired(); +#ifdef KALSHI_COUNT_ALLOCATIONS + const kalshi::test::AllocationCount used = kalshi::test::allocations() - start_; +#endif if (instructions_ != 0 && instructions > instructions_) { state.counters["instructions"] = benchmark::Counter(static_cast(instructions - instructions_), benchmark::Counter::kAvgIterations); } #ifdef KALSHI_COUNT_ALLOCATIONS - const kalshi::test::AllocationCount used = kalshi::test::allocations() - start_; state.counters["allocs"] = benchmark::Counter(static_cast(used.count), benchmark::Counter::kAvgIterations); state.counters["alloc_bytes"] = diff --git a/include/kalshi/websocket.hpp b/include/kalshi/websocket.hpp index 9d87ac5..8c8f803 100644 --- a/include/kalshi/websocket.hpp +++ b/include/kalshi/websocket.hpp @@ -82,8 +82,8 @@ struct WsConfig { /// Subscriptions survive reconnects: the client resubscribes with the same /// parameters and keeps each `ws::Subscription` handle, while the server's /// `sid` changes. Callbacks run on the client's network thread, which blocks -/// SIGPIPE, except the Disconnected state change from `disconnect()`, which -/// runs on the caller's. +/// SIGPIPE on POSIX systems, except the Disconnected state change from +/// `disconnect()`, which runs on the caller's. /// They may call any method, including destroying the client, but should /// return quickly. Every method is thread-safe. class WebSocketClient { diff --git a/src/ws/websocket.cpp b/src/ws/websocket.cpp index 8ed1113..a34d352 100644 --- a/src/ws/websocket.cpp +++ b/src/ws/websocket.cpp @@ -8,6 +8,7 @@ #include #include #include +#include #include #include #include @@ -26,6 +27,7 @@ #ifndef _WIN32 #include +#include #endif namespace kalshi { @@ -386,7 +388,9 @@ struct WebSocketClient::Impl : std::enable_shared_from_this { #ifndef _WIN32 // OpenSSL sends with write(), which raises SIGPIPE once the peer has reset the // connection. libwebsockets ignores SIGPIPE process-wide, but the app may restore - // the default, which ends the process, so block it on this thread. + // the default, which ends the process. Linux sends that signal to the writing + // thread, so block it here. macOS and the BSDs send it to the whole process, so + // there each socket also sets SO_NOSIGPIPE (see LWS_CALLBACK_CONNECTING). sigset_t pipe{}; sigemptyset(&pipe); sigaddset(&pipe, SIGPIPE); @@ -513,6 +517,15 @@ struct WebSocketClient::Impl : std::enable_shared_from_this { int on_event(lws* connection, lws_callback_reasons reason, void* in, std::size_t len) { switch (reason) { +#if defined(SO_NOSIGPIPE) + case LWS_CALLBACK_CONNECTING: { // just before connect(); see run() + const int enabled = 1; + const lws_sockfd_type fd = + static_cast(reinterpret_cast(in)); + setsockopt(fd, SOL_SOCKET, SO_NOSIGPIPE, &enabled, sizeof(enabled)); + return 0; + } +#endif case LWS_CALLBACK_CLIENT_APPEND_HANDSHAKE_HEADER: return append_headers(connection, in, len); case LWS_CALLBACK_CLIENT_ESTABLISHED: diff --git a/tests/support/ws_test_server.hpp b/tests/support/ws_test_server.hpp index e741f0e..6b06f95 100644 --- a/tests/support/ws_test_server.hpp +++ b/tests/support/ws_test_server.hpp @@ -58,6 +58,8 @@ class WsTestServer { [[nodiscard]] bool running() const { return context_ != nullptr && port_ > 0; } + [[nodiscard]] int port() const { return port_; } + [[nodiscard]] std::string url() const { return "ws://127.0.0.1:" + std::to_string(port_) + "/trade-api/ws/v2"; } diff --git a/tests/test_ws_client.cpp b/tests/test_ws_client.cpp index 74d73e4..a78f510 100644 --- a/tests/test_ws_client.cpp +++ b/tests/test_ws_client.cpp @@ -248,6 +248,31 @@ TEST_F(WsClient, NetworkThreadBlocksSigpipe) { } #endif +#if defined(SO_NOSIGPIPE) +// Where a write's SIGPIPE goes to the whole process, blocking it on one thread isn't +// enough, so the socket itself must opt out. +TEST_F(WsClient, SocketsSetNoSigpipe) { + kalshi::WebSocketClient ws(kalshi::test::make_signer(), local(server)); + ASSERT_TRUE(ws.connect().has_value()); + // The client's socket is the only one whose peer is the server's port. Descriptors + // are handed out lowest first, so it is far below 1024. + int client = -1; + for (int fd = 0; fd < 1024 && client < 0; ++fd) { + sockaddr_in peer{}; + socklen_t length = sizeof(peer); + if (::getpeername(fd, reinterpret_cast(&peer), &length) == 0 && + peer.sin_family == AF_INET && ntohs(peer.sin_port) == server.port()) { + client = fd; + } + } + ASSERT_GE(client, 0); + int enabled = 0; + socklen_t size = sizeof(enabled); + ASSERT_EQ(::getsockopt(client, SOL_SOCKET, SO_NOSIGPIPE, &enabled, &size), 0); + EXPECT_NE(enabled, 0); +} +#endif + TEST_F(WsClient, OpenSslStillWorksAfterASessionEnds) { { kalshi::WebSocketClient ws(kalshi::test::make_signer(), local(server));