diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cff30e5..fe09bb6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -14,13 +14,13 @@ concurrency: env: CMAKE_BUILD_PARALLEL_LEVEL: 8 - LD_LIBRARY_PATH: ${{ github.workspace }}/_stage/aisuite/lib:${{ github.workspace }}/_stage/snodec/lib - LDFLAGS: -Wl,-rpath-link,${{ github.workspace }}/_stage/aisuite/lib -Wl,-rpath-link,${{ github.workspace }}/_stage/snodec/lib jobs: gcc-15-debug: runs-on: ubuntu-24.04 - container: gcc:15.3.0-trixie + container: + image: gcc:15.3.0-trixie + options: --cap-add=NET_ADMIN --sysctl net.ipv6.conf.all.disable_ipv6=0 timeout-minutes: 45 steps: @@ -32,18 +32,18 @@ jobs: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }} - - name: Check out SNode.C master + - name: Check out pinned SNode.C uses: actions/checkout@v5 with: repository: SNodeC/snode.c - ref: master + ref: 212bd4f8a16637405ab850a52adb9da8555dca3d path: _deps/snodec - - name: Check out canonical AISuite Codex + - name: Check out paired AISuite Codex uses: actions/checkout@v5 with: repository: SNodeC/AISuite - ref: master + ref: 60c81c7f1fb7938e03725cdea4a87e617efae57e path: _deps/aisuite - name: Install build dependencies @@ -51,6 +51,7 @@ jobs: apt-get update apt-get install -y \ cmake \ + iproute2 \ libbluetooth-dev \ libmagic-dev \ libssl-dev \ @@ -61,6 +62,15 @@ jobs: git config --global --add safe.directory "$GITHUB_WORKSPACE" git config --global --add safe.directory "$GITHUB_WORKSPACE/_deps/snodec" git config --global --add safe.directory "$GITHUB_WORKSPACE/_deps/aisuite" + getent group snodec >/dev/null || groupadd --system snodec + + - name: Enable IPv6 transport coverage + run: | + ip link add codexui-ci-v6 type dummy + ip link set codexui-ci-v6 up + ip -6 address add fd00::1/128 dev codexui-ci-v6 + ip -6 address show dev codexui-ci-v6 + getent ahostsv6 ::1 - name: Verify CodexUI source revision shell: bash @@ -82,15 +92,21 @@ jobs: cmake --build _build/snodec --target all cmake --install _build/snodec - - name: Build and install canonical AISuite Codex + - name: Build, test, and install paired AISuite Codex run: | cmake -S _deps/aisuite -B _build/aisuite -G Ninja \ -DCMAKE_BUILD_TYPE=Debug \ -DCMAKE_INSTALL_PREFIX="$GITHUB_WORKSPACE/_stage/aisuite" \ -DCMAKE_PREFIX_PATH="$GITHUB_WORKSPACE/_stage/snodec" \ - -DAISUITE_BUILD_CODEX_TESTS=OFF \ + -DAISUITE_BUILD_CODEX_TESTS=ON \ -DAISUITE_BUILD_APPS=ON cmake --build _build/aisuite --target all --parallel 2 + install -d -m 0770 -g snodec \ + /etc/snode.c \ + /var/log/snode.c \ + /var/run/snode.c + LD_LIBRARY_PATH="$GITHUB_WORKSPACE/_stage/snodec/lib" \ + ctest --test-dir _build/aisuite --output-on-failure cmake --install _build/aisuite - name: Configure CodexUI @@ -102,6 +118,11 @@ jobs: - name: Build CodexUI run: cmake --build build --target all + - name: Test CodexUI + run: | + LD_LIBRARY_PATH="$GITHUB_WORKSPACE/_stage/aisuite/lib:$GITHUB_WORKSPACE/_stage/snodec/lib" \ + ctest --test-dir build --output-on-failure + - name: Check changed lines shell: bash env: diff --git a/docs/codex-architecture.md b/docs/codex-architecture.md index c8eea69..f55d269 100644 --- a/docs/codex-architecture.md +++ b/docs/codex-architecture.md @@ -113,6 +113,17 @@ generated experimental feature types and typed list/enablement operations available through the frontend proxy SDK. CodexUI does not perform a second provider initialization. +Without endpoint configuration, CodexUI selects AISuite's shared per-user +runtime path (`XDG_RUNTIME_DIR` when private, otherwise +`/tmp/codex-bridge-/codex-bridge.sock`), so it discovers a default +`codex-bridge` instance without a configuration file. + +Bridge provider lifecycle is normalized as `connection.provider` with an +independent provider generation. Disconnect or generation change completes all +outstanding UI operations exactly once, clears provider-scoped presentation +state, and rehydrates the selected thread after the new provider reports +`ready`. Late results from a retired generation are ignored. + ## 4. Inter-Thread Socketpair One unnamed full-duplex Unix socketpair is the only cross-thread transport: @@ -132,6 +143,10 @@ The implementation uses: - bounded socket and application write queues; - exclusive endpoint ownership and deterministic close behavior. +The Qt endpoint retains queued output as independently owned chunks, releases +each consumed chunk immediately, and limits read and write work per notifier +activation. Both endpoints treat framing or dispatch failure as terminal. + The socket buffers are both the bounded queues and the readiness mechanism. No parallel in-memory queue, condition variable, eventfd, or other wakeup descriptor is added. @@ -215,7 +230,7 @@ The v1 command catalog used by the application is: | Action | Result | Meaning | | --- | --- | --- | -| `runtime.shutdown` | no | Orderly SNode.C runtime shutdown | +| `runtime.shutdown` | yes | Acknowledge and drain, then stop the SNode.C runtime | | `connection.connect` | no | Connect the selected configured frontend transport | | `connection.disconnect` | no | Explicitly disconnect the selected frontend transport | | `connection.reconnect` | no | Explicit bridge transport reconnect | @@ -270,8 +285,9 @@ Results preserve their originating `action` and `correlationId`. The currently reduced result payloads are: - `threads.list`: `threads`, `nextCursor`, and `backwardsCursor`, with `merge`; -- `thread.read`: returned `thread`, with `merge` because the current app-server - read projection can omit live-only Plan, Agent, command, and Changes detail; +- `thread.read`: returned `thread`, with `replace` when no newer presentation + event arrived after the read began, otherwise `merge` so a late snapshot + cannot erase newer live Plan, Agent, command, or Changes detail; - `thread.create`, `thread.resume`, and `thread.fork`: returned `thread`, with `merge`; - `thread.rename`, `thread.archive`, `thread.unarchive`, and `thread.delete`: diff --git a/docs/ui-behavior.md b/docs/ui-behavior.md index 4651a8a..1595603 100644 --- a/docs/ui-behavior.md +++ b/docs/ui-behavior.md @@ -111,6 +111,9 @@ content above or below it changed size. Protocol updates that do not change a card's visible projection do not rebuild that card. Multiple visible card changes from one refresh are applied as one paint-suppressed layout transaction with one anchor restoration, including streaming Command execution updates. +Incoming deltas are coalesced to at most one reconcile per display interval; +growing text and Command execution output are appended in place instead of +being recopied and rebuilt for every delta. New authoritative cards are inserted at their server-ordered position without reconstructing retained cards. While following is paused, the effective history window expands with incoming cards so its visible anchor is not evicted; the diff --git a/src/codex/ClientRuntime.cpp b/src/codex/ClientRuntime.cpp index 1caffd8..86124f4 100644 --- a/src/codex/ClientRuntime.cpp +++ b/src/codex/ClientRuntime.cpp @@ -14,9 +14,11 @@ #include #endif #include +#include #include #include #include +#include #include #include #include @@ -36,6 +38,7 @@ #include #include #include +#include #include #include #include @@ -66,13 +69,15 @@ void dispatchRequest(codex::frontend::CodexBridge &sdk, const nlohmann::json ¶meters, std::string action, std::string correlationId, ProtocolNormalizer &normalizer) { + const std::uint64_t startedAtSequence = normalizer.sequence(); sdk.request( typename Operation::Params{parameters}, [action = std::move(action), correlationId = std::move(correlationId), - context = parameters, + context = parameters, startedAtSequence, &normalizer](typename Operation::Response &response) mutable { normalizer.operationResult(std::move(action), std::move(correlationId), - std::move(context), response.getRaw()); + std::move(context), response.getRaw(), + startedAtSequence); }); } @@ -109,6 +114,10 @@ int runClientRuntime(int socketPairDescriptor, Configuration &configuration, std::function requestReconnect; std::function requestShutdown; + normalizer.setDeliveryFailureHandler([&requestShutdown] { + if (requestShutdown) + requestShutdown(); + }); std::string expectedDisconnectReason; bool desiredConnected = connectBridge; @@ -165,7 +174,8 @@ int runClientRuntime(int socketPairDescriptor, Configuration &configuration, net::un::stream::legacy::SocketClient unixClient("codex-ui-unix", connection, std::size_t(maximumFrameBytes)); - unixClient.getConfig()->Remote::setSunPath("/tmp/codex-bridge.sock"); + unixClient.getConfig()->Remote::setSunPath( + codex::protocol::defaultFrontendSocketPath()); configureStreamClient(unixClient, false); net::in::stream::legacy::SocketClient continueTransition; std::function terminatingFlowTerminated; std::function pendingSelection; + std::chrono::steady_clock::time_point transitionDeadline; + std::chrono::steady_clock::time_point shutdownDrainDeadline; + std::function finishShutdownAfterDrain; const auto selectClient = [&](auto &configuredClient, std::string transport, std::string label) { @@ -366,7 +380,13 @@ int runClientRuntime(int socketPairDescriptor, Configuration &configuration, return; if ((terminatingFlowTerminated && !terminatingFlowTerminated()) || connection.attached()) { - core::EventReceiver::atNextTick(continueTransition); + if (std::chrono::steady_clock::now() >= transitionDeadline) { + normalizer.transportEvent("failure", "connection transition timed out"); + requestShutdown(); + return; + } + static_cast(core::timer::Timer::singleshotTimer( + continueTransition, utils::Timeval({0, 10000}))); return; } transitionPending = false; @@ -403,11 +423,14 @@ int runClientRuntime(int socketPairDescriptor, Configuration &configuration, return; } transitionPending = true; + transitionDeadline = + std::chrono::steady_clock::now() + std::chrono::seconds(5); expectedDisconnectReason = connection.attached() ? std::move(disconnectReason) : std::string{}; connection.disconnect("CodexUI connection transition"); terminateSelected(); - core::EventReceiver::atNextTick(continueTransition); + static_cast(core::timer::Timer::singleshotTimer( + continueTransition, utils::Timeval({0, 10000}))); }; requestReconnect = [&] { beginTransition(true, {}, "local-user-reconnect"); }; @@ -437,10 +460,24 @@ int runClientRuntime(int socketPairDescriptor, Configuration &configuration, connection.shutdown(); if (terminateSelected) terminateSelected(); + if (ipcEndpoint) + ipcEndpoint->close(); if (eventLoopRunning) core::SNodeC::stop(); }; + finishShutdownAfterDrain = [&] { + if (shutdownRequested) + return; + if (ipcEndpoint && ipcEndpoint->queuedBytes() != 0 && + std::chrono::steady_clock::now() < shutdownDrainDeadline) { + static_cast(core::timer::Timer::singleshotTimer( + finishShutdownAfterDrain, utils::Timeval({0, 10000}))); + return; + } + requestShutdown(); + }; + const auto dispatchCommand = [&](nlohmann::json command) { if (!presentation::isPresentationFrame(command) || presentation::stringMember(command, "kind") != "command") { @@ -455,8 +492,21 @@ int runClientRuntime(int socketPairDescriptor, Configuration &configuration, const nlohmann::json parameters = presentation::member(command, "data", nlohmann::json::object()); + if (!parameters.is_object()) { + normalizer.operationRejected(action, correlationId, -32602, + "presentation command data must be an object"); + return; + } + if (action == "runtime.shutdown") { - requestShutdown(); + if (!shutdownDraining) { + shutdownDraining = true; + normalizer.localOperationResult(action, correlationId, true, + nlohmann::json::object()); + shutdownDrainDeadline = + std::chrono::steady_clock::now() + std::chrono::milliseconds(500); + finishShutdownAfterDrain(); + } return; } if (action == "connection.reconnect") { @@ -648,8 +698,8 @@ int runClientRuntime(int socketPairDescriptor, Configuration &configuration, if (parameters.contains("error")) response["error"] = parameters["error"]; else - response["result"] = - parameters.value("result", nlohmann::json::object()); + response["result"] = presentation::member( + parameters, "result", nlohmann::json::object()); if (requestId.is_null() || !sdk.sendRawJson(response)) normalizer.transportEvent("failure", "server-request response was rejected"); @@ -854,14 +904,25 @@ int runClientRuntime(int socketPairDescriptor, Configuration &configuration, }; ipcEndpoint->setOnData([&](const char *data, std::size_t size) { - const bool accepted = ipcFramer.consume( - std::string_view(data, size), dispatchCommand, - [&normalizer, &requestShutdown](std::string message) { - normalizer.transportEvent("failure", std::move(message)); - requestShutdown(); - }); - if (!accepted) + try { + const bool accepted = ipcFramer.consume( + std::string_view(data, size), dispatchCommand, + [&normalizer, &requestShutdown](std::string message) { + normalizer.transportEvent("failure", std::move(message)); + requestShutdown(); + }); + if (!accepted) + requestShutdown(); + } catch (const std::exception &exception) { + normalizer.transportEvent( + "failure", std::string("presentation command dispatch failed: ") + + exception.what()); requestShutdown(); + } catch (...) { + normalizer.transportEvent("failure", + "presentation command dispatch failed"); + requestShutdown(); + } }); ipcEndpoint->setOnError([&normalizer, &requestShutdown](int errorNumber) { normalizer.transportEvent("failure", std::string("socketpair failure: ") + diff --git a/src/codex/FrontendSession.cpp b/src/codex/FrontendSession.cpp index ae51d75..92b508c 100644 --- a/src/codex/FrontendSession.cpp +++ b/src/codex/FrontendSession.cpp @@ -9,6 +9,11 @@ #include +#include +#include +#include +#include + #include #include #include @@ -37,20 +42,35 @@ FrontendSession::FrontendSession(Configuration &configuration) pair.releaseFirstEndpoint(), MaximumWriteQueueBytes); clientDescriptor = pair.releaseSecondEndpoint(); endpoint->setOnData([this](const char *data, std::size_t size) { - framer->consume( - std::string_view(data, size), - [this](nlohmann::json message) { receiveMessage(std::move(message)); }, - [this](std::string message) { reportLocalError(std::move(message)); }); + std::string framingError; + try { + const bool accepted = framer->consume( + std::string_view(data, size), + [this](nlohmann::json message) { receiveMessage(std::move(message)); }, + [&framingError](std::string message) { + framingError = std::move(message); + }); + if (!accepted) + terminalFailure(framingError.empty() ? "CodexUI IPC framing failed" + : std::move(framingError)); + } catch (const std::exception &exception) { + terminalFailure(std::string("CodexUI IPC dispatch failed: ") + + exception.what()); + } catch (...) { + terminalFailure("CodexUI IPC dispatch failed with an unknown exception"); + } }); endpoint->setOnError([this](int errorNumber) { - reportLocalError(std::string("Qt socketpair failure: ") + - std::strerror(errorNumber)); + terminalFailure(std::string("Qt socketpair failure: ") + + std::strerror(errorNumber)); }); endpoint->setOnClosed([this] { + failAllPending(-32020, stopping ? "CodexUI is shutting down" + : "SNode.C client thread disconnected"); if (!stopping) { - reportLocalError("SNode.C client thread disconnected"); - if (runtimeStoppedHandler) - runtimeStoppedHandler(); + if (!terminal) + reportLocalError("SNode.C client thread disconnected"); + notifyRuntimeStopped(); } }); } @@ -76,9 +96,25 @@ void FrontendSession::wait() { void FrontendSession::shutdown() { if (stopping) return; - if (started) + if (started && endpoint && endpoint->isOpen() && + QCoreApplication::instance() && + endpoint->thread() == QThread::currentThread()) { + QEventLoop acknowledgementLoop; + const std::string requestId = + request("runtime.shutdown", nlohmann::json::object(), + [&acknowledgementLoop](const nlohmann::json &) { + acknowledgementLoop.quit(); + }); + QTimer::singleShot(750, &acknowledgementLoop, &QEventLoop::quit); + acknowledgementLoop.exec(); + // A timeout must not leave a callback capturing the completed nested loop. + pending.erase(requestId); + outstanding.erase(requestId); + } else if (started) { static_cast(sendMessage(presentation::command("runtime.shutdown"))); + } stopping = true; + failAllPending(-32800, "CodexUI is shutting down"); if (endpoint) endpoint->close(); if (clientDescriptor >= 0) { @@ -86,7 +122,6 @@ void FrontendSession::shutdown() { clientDescriptor = -1; } wait(); - pending.clear(); } void FrontendSession::setEventHandler(EventHandler handler) { @@ -101,6 +136,7 @@ std::string FrontendSession::request(std::string operation, nlohmann::json parameters, ResponseHandler handler) { const std::string requestId = "ui-request-" + std::to_string(nextOperation++); + outstanding.insert(requestId); if (handler) pending.emplace(requestId, std::move(handler)); if (!sendMessage(presentation::command(std::move(operation), @@ -109,15 +145,19 @@ std::string FrontendSession::request(std::string operation, if (iterator != pending.end()) { ResponseHandler failed = std::move(iterator->second); pending.erase(iterator); - failed({{"protocol", presentation::ProtocolName}, - {"version", presentation::ProtocolVersion}, - {"kind", "result"}, - {"correlationId", requestId}, - {"ok", false}, - {"error", - {{"code", -32020}, - {"message", "CodexUI IPC rejected operation"}}}}); + try { + failed({{"protocol", presentation::ProtocolName}, + {"version", presentation::ProtocolVersion}, + {"kind", "result"}, + {"correlationId", requestId}, + {"ok", false}, + {"error", + {{"code", -32020}, + {"message", "CodexUI IPC rejected operation"}}}}); + } catch (...) { + } } + outstanding.erase(requestId); } return requestId; } @@ -338,27 +378,138 @@ void FrontendSession::receiveMessage(nlohmann::json message) { "SNode.C client emitted an incompatible presentation frame"); return; } + const auto generation = message.find("generation"); + if (generation != message.end()) { + if (!generation->is_number_unsigned()) { + terminalFailure("presentation frame has an invalid generation"); + return; + } + const std::uint64_t incoming = generation->get(); + if (activeGeneration != 0 && incoming < activeGeneration) + return; + if (activeGeneration != 0 && incoming > activeGeneration) { + failAllPending(-32020, "bridge connection generation changed"); + lastSequenceReceived = 0; + } + activeGeneration = incoming; + } + const auto sequence = message.find("sequence"); + if (sequence != message.end()) { + if (!sequence->is_number_unsigned()) { + terminalFailure("presentation frame has an invalid sequence"); + return; + } + const std::uint64_t incoming = sequence->get(); + if (incoming != 0 && lastSequenceReceived != 0 && + incoming != lastSequenceReceived + 1) { + terminalFailure("presentation frame sequence gap detected"); + return; + } + if (incoming != 0) + lastSequenceReceived = incoming; + } if (presentation::stringMember(message, "kind") == "result") { const std::string requestId = presentation::stringMember(message, "correlationId"); + if (outstanding.erase(requestId) == 0) + return; const auto iterator = pending.find(requestId); if (iterator != pending.end()) { ResponseHandler handler = std::move(iterator->second); pending.erase(iterator); - if (handler) - handler(message); + if (handler) { + try { + handler(message); + } catch (...) { + } + } + } + } + if (presentation::stringMember(message, "kind") == "event") { + const std::string type = presentation::stringMember(message, "type"); + const nlohmann::json data = presentation::member( + message, "data", nlohmann::json::object()); + if (type == "connection.lifecycle") { + const std::string state = presentation::stringMember(data, "state"); + if (state == "disconnected" || state == "failure") + failAllPending(-32020, "bridge connection was lost"); + } else if (type == "connection.provider") { + const auto provider = data.find("generation"); + if (provider == data.end() || !provider->is_number_unsigned()) { + terminalFailure("provider lifecycle event has an invalid generation"); + return; + } + const std::uint64_t incoming = provider->get(); + if (incoming < providerGeneration) + return; + if (providerGeneration != 0 && incoming > providerGeneration) + failAllPending(-32002, "app-server provider generation changed"); + providerGeneration = incoming; + if (presentation::stringMember(data, "state") == "disconnected") + failAllPending(-32002, "app-server provider was restarted"); + } + } + if (eventHandler) { + try { + eventHandler(message); + } catch (...) { } } - if (eventHandler) - eventHandler(message); } void FrontendSession::reportLocalError(std::string message) { - if (eventHandler) - eventHandler(presentation::event(0, 0, "system.local-diagnostic", - {{"source", "qt"}, - {"code", "local-ipc-error"}, - {"message", std::move(message)}})); + if (eventHandler) { + try { + eventHandler(presentation::event(0, activeGeneration, + "system.local-diagnostic", + {{"source", "qt"}, + {"code", "local-ipc-error"}, + {"message", std::move(message)}})); + } catch (...) { + } + } +} + +void FrontendSession::terminalFailure(std::string message) { + if (terminal || stopping) + return; + terminal = true; + failAllPending(-32020, message); + reportLocalError(std::move(message)); + if (endpoint && endpoint->isOpen()) + endpoint->close(); + notifyRuntimeStopped(); +} + +void FrontendSession::failAllPending(int code, std::string message) noexcept { + outstanding.clear(); + auto failed = std::move(pending); + pending.clear(); + for (auto &[correlationId, handler] : failed) { + if (!handler) + continue; + try { + handler({{"protocol", presentation::ProtocolName}, + {"version", presentation::ProtocolVersion}, + {"kind", "result"}, + {"correlationId", correlationId}, + {"ok", false}, + {"error", {{"code", code}, {"message", message}}}}); + } catch (...) { + } + } +} + +void FrontendSession::notifyRuntimeStopped() noexcept { + if (runtimeStopReported) + return; + runtimeStopReported = true; + if (runtimeStoppedHandler) { + try { + runtimeStoppedHandler(); + } catch (...) { + } + } } } // namespace codexui::codex diff --git a/src/codex/FrontendSession.h b/src/codex/FrontendSession.h index 872c116..7cdbcdc 100644 --- a/src/codex/FrontendSession.h +++ b/src/codex/FrontendSession.h @@ -11,6 +11,7 @@ #include #include #include +#include namespace ai::openai::codex::protocol { class JsonLineFramer; @@ -113,6 +114,9 @@ class FrontendSession final { bool sendMessage(const nlohmann::json &message); void receiveMessage(nlohmann::json message); void reportLocalError(std::string message); + void terminalFailure(std::string message); + void failAllPending(int code, std::string message) noexcept; + void notifyRuntimeStopped() noexcept; std::unique_ptr endpoint; std::unique_ptr framer; @@ -120,10 +124,16 @@ class FrontendSession final { int clientDescriptor = -1; std::uint64_t nextOperation = 1; std::unordered_map pending; + std::unordered_set outstanding; EventHandler eventHandler; RuntimeStoppedHandler runtimeStoppedHandler; bool started = false; bool stopping = false; + bool terminal = false; + bool runtimeStopReported = false; + std::uint64_t activeGeneration = 0; + std::uint64_t providerGeneration = 0; + std::uint64_t lastSequenceReceived = 0; Configuration &configuration; }; diff --git a/src/codex/PresentationModel.cpp b/src/codex/PresentationModel.cpp index d60fd4f..8d103e4 100644 --- a/src/codex/PresentationModel.cpp +++ b/src/codex/PresentationModel.cpp @@ -29,6 +29,16 @@ nlohmann::json memberValue(const nlohmann::json &object, const char *key, return iterator == object.end() ? std::move(fallback) : *iterator; } +bool boolValue(const nlohmann::json &object, const char *key, + bool fallback = false) { + if (!object.is_object()) + return fallback; + const auto iterator = object.find(key); + return iterator != object.end() && iterator->is_boolean() + ? iterator->get() + : fallback; +} + std::string statusValue(const nlohmann::json &value) { if (value.is_string()) return value.get(); @@ -97,9 +107,11 @@ void appendText(nlohmann::json &item, const char *field, const std::string delta = stringValue(params, "delta"); if (delta.empty()) return; - std::string existing = stringValue(item, field); + nlohmann::json &stored = item[field]; + if (!stored.is_string()) + stored = ""; + std::string &existing = stored.get_ref(); existing += delta; - item[field] = std::move(existing); } void appendIndexedText(nlohmann::json &item, const char *field, @@ -120,7 +132,8 @@ void appendIndexedText(nlohmann::json &item, const char *field, std::string delta = stringValue(params, "delta"); if (delta.empty()) delta = stringValue(params, "text"); - parts[position] = parts[position].get() + delta; + std::string &existing = parts[position].get_ref(); + existing += delta; } void applyDomainAuthority( @@ -142,48 +155,74 @@ void applyDomainAuthority( } // namespace -void PresentationModel::applyEvent(const nlohmann::json &event) { +void PresentationModel::applyEvent(const nlohmann::json &event) noexcept { + try { + applyValidatedEvent(event); + } catch (...) { + // Presentation mutation is an untrusted-data boundary. No malformed event + // may escape through Qt dispatch. + } +} + +void PresentationModel::applyValidatedEvent(const nlohmann::json &event) { if (!presentation::isPresentationFrame(event)) return; - const std::uint64_t sequence = event.value("sequence", 0ULL); + const std::string kind = presentation::stringMember(event, "kind"); + const auto generationMember = event.find("generation"); + const std::uint64_t generation = + generationMember != event.end() && generationMember->is_number_unsigned() + ? generationMember->get() + : 0; + if (connectionState.generation != 0 && generation != 0 && + generation < connectionState.generation) + return; + if (generation > connectionState.generation) { + connectionState.generation = generation; + lastSequence = 0; + pendingRequests.clear(); + } + const auto sequenceMember = event.find("sequence"); + const std::uint64_t sequence = + sequenceMember != event.end() && sequenceMember->is_number_unsigned() + ? sequenceMember->get() + : 0; if (sequence != 0) { if (sequence <= lastSequence) return; lastSequence = sequence; } - - const std::string kind = presentation::stringMember(event, "kind"); const nlohmann::json data = presentation::member(event, "data", nlohmann::json::object()); const nlohmann::json scope = presentation::member(event, "scope", nlohmann::json::object()); if (kind == "result") { - if (!event.value("ok", false)) + if (!boolValue(event, "ok")) return; const std::string action = presentation::stringMember(event, "action"); if (action == "threads.list") { const nlohmann::json threads = - data.value("threads", nlohmann::json::array()); + memberValue(data, "threads", nlohmann::json::array()); mergeThreadList(threads); } else if (action == "thread.read") { const nlohmann::json thread = - data.value("thread", nlohmann::json::object()); + memberValue(data, "thread", nlohmann::json::object()); ThreadPresentation &hydrated = upsertThread(thread, stringValue(event, "authority") == "replace"); correlateAgentThread(hydrated.id); } else if (action == "thread.create" || action == "thread.resume" || action == "thread.fork") { - upsertThread(data.value("thread", nlohmann::json::object()), false); + upsertThread(memberValue(data, "thread", nlohmann::json::object()), + false); } else if (action == "turn.start") { const std::string threadId = stringValue(scope, "threadId"); const auto thread = threads.find(threadId); if (thread != threads.end()) - upsertTurn(thread->second, data.value("turn", nlohmann::json::object()), - false); + upsertTurn(thread->second, + memberValue(data, "turn", nlohmann::json::object()), false); } else if (action == "models.list") { const nlohmann::json listedModels = - data.value("models", nlohmann::json::array()); + memberValue(data, "models", nlohmann::json::array()); if (listedModels.is_array()) models = listedModels; } else { @@ -201,11 +240,11 @@ void PresentationModel::applyEvent(const nlohmann::json &event) { if (retainedTelemetry.size() == MaximumRetainedTelemetry) retainedTelemetry.erase(retainedTelemetry.begin()); retainedTelemetry.push_back(TelemetryPresentation{ - sequence, event.value("generation", 0ULL), type, data, scope}); + sequence, generation, type, data, scope}); } if (type == "connection.lifecycle") { connectionState.generation = - event.value("generation", connectionState.generation); + generation; const std::string lifecycle = stringValue(data, "state"); if (lifecycle == "connected") { connectionState.connected = true; @@ -245,12 +284,30 @@ void PresentationModel::applyEvent(const nlohmann::json &event) { : "observer"; return; } + if (type == "connection.provider") { + const auto providerGeneration = data.find("generation"); + if (providerGeneration == data.end() || + !providerGeneration->is_number_unsigned()) + return; + const std::uint64_t incoming = providerGeneration->get(); + if (incoming < connectionState.providerGeneration) + return; + const std::string state = stringValue(data, "state"); + if ((connectionState.providerGeneration != 0 && + incoming > connectionState.providerGeneration) || + state == "disconnected") + clearProviderState(); + connectionState.providerGeneration = incoming; + connectionState.providerState = state; + connectionState.providerDetail = stringValue(data, "reason"); + return; + } if (type == "connection.settings.changed") { connectionState.settings = data; return; } if (type == "thread.upsert") { - upsertThread(data.value("thread", nlohmann::json::object()), false); + upsertThread(memberValue(data, "thread", nlohmann::json::object()), false); return; } if (type == "thread.name.changed") { @@ -296,7 +353,7 @@ void PresentationModel::applyEvent(const nlohmann::json &event) { const std::string key = requestKey(*id); pendingRequests[key] = PendingRequestPresentation{ key, stringValue(data, "category"), stringValue(scope, "threadId"), - event.value("generation", 0ULL), memberValue(data, "request")}; + generation, memberValue(data, "request")}; return; } if (type == "pending-request.removed") { @@ -326,7 +383,8 @@ void PresentationModel::applyEvent(const nlohmann::json &event) { presentation::stringMember(event, "authority")); if (type == "turn.upsert") { - upsertTurn(thread, data.value("turn", nlohmann::json::object()), false); + upsertTurn(thread, memberValue(data, "turn", nlohmann::json::object()), + false); correlateAgentThread(threadId); return; } @@ -334,8 +392,9 @@ void PresentationModel::applyEvent(const nlohmann::json &event) { const std::string turnId = stringValue(scope, "turnId"); nlohmann::json minimalTurn{{"id", turnId}}; TurnPresentation &turn = upsertTurn(thread, minimalTurn, false); - turn.plan = {{"explanation", memberValue(data, "explanation")}, - {"steps", data.value("steps", nlohmann::json::array())}}; + turn.plan = { + {"explanation", memberValue(data, "explanation")}, + {"steps", memberValue(data, "steps", nlohmann::json::array())}}; return; } if (type == "conversation.item.upsert") { @@ -349,8 +408,9 @@ void PresentationModel::applyEvent(const nlohmann::json &event) { return; } if (type == "agents.activity.upsert") { - upsertAgentActivity(thread, scope, - data.value("activity", nlohmann::json::object())); + upsertAgentActivity( + thread, scope, + memberValue(data, "activity", nlohmann::json::object())); return; } if (type == "conversation.reasoning.part-added") { @@ -395,7 +455,7 @@ void PresentationModel::applyEvent(const nlohmann::json &event) { return; nlohmann::json identity = scope; - identity["delta"] = data.value("text", std::string{}); + identity["delta"] = stringValue(data, "text"); ItemPresentation *item = findItem(identity); if (!item) return; @@ -525,7 +585,7 @@ ThreadPresentation &PresentationModel::upsertThread(const nlohmann::json &raw, const auto status = raw.find("status"); if (status != raw.end()) result.status = statusValue(*status); - result.archived = raw.value("archived", result.archived); + result.archived = boolValue(raw, "archived", result.archived); const auto turns = raw.find("turns"); if (turns != raw.end() && turns->is_array()) { @@ -714,6 +774,14 @@ void PresentationModel::removeThread(const std::string &threadId) { std::erase(orderedThreads, threadId); } +void PresentationModel::clearProviderState() { + orderedThreads.clear(); + threads.clear(); + pendingRequests.clear(); + models = nlohmann::json::array(); + retainedGlobalDomains.clear(); +} + void PresentationModel::retainDomainEvent(const std::string &type, const nlohmann::json &data, const nlohmann::json &scope, diff --git a/src/codex/PresentationModel.h b/src/codex/PresentationModel.h index ca39ac5..0477add 100644 --- a/src/codex/PresentationModel.h +++ b/src/codex/PresentationModel.h @@ -70,6 +70,9 @@ struct ConnectionPresentation { std::string role; std::string controllerConnectionId; std::string detail; + std::uint64_t providerGeneration = 0; + std::string providerState; + std::string providerDetail; nlohmann::json settings = nlohmann::json::object(); }; @@ -83,7 +86,7 @@ struct TelemetryPresentation { class PresentationModel final { public: - void applyEvent(const nlohmann::json &event); + void applyEvent(const nlohmann::json &event) noexcept; [[nodiscard]] const std::vector &threadOrder() const noexcept; [[nodiscard]] const ThreadPresentation * @@ -104,6 +107,7 @@ class PresentationModel final { pendingRequestPresentations() const noexcept; private: + void applyValidatedEvent(const nlohmann::json &event); void mergeThreadList(const nlohmann::json &listedThreads); ThreadPresentation &upsertThread(const nlohmann::json &raw, bool replaceTurns); @@ -117,6 +121,7 @@ class PresentationModel final { const nlohmann::json &activity, bool live = true); void correlateAgentThread(const std::string &childThreadId); void removeThread(const std::string &threadId); + void clearProviderState(); void retainDomainEvent(const std::string &type, const nlohmann::json &data, const nlohmann::json &scope, const std::string &authority); diff --git a/src/codex/PresentationProtocol.cpp b/src/codex/PresentationProtocol.cpp index 9c771d5..9e0c21a 100644 --- a/src/codex/PresentationProtocol.cpp +++ b/src/codex/PresentationProtocol.cpp @@ -74,8 +74,59 @@ nlohmann::json event(std::uint64_t sequence, std::uint64_t generation, } bool isPresentationFrame(const nlohmann::json &value) noexcept { - return value.is_object() && stringMember(value, "protocol") == ProtocolName && - value.value("version", 0U) == ProtocolVersion; + if (!value.is_object()) + return false; + const auto protocol = value.find("protocol"); + if (protocol == value.end() || !protocol->is_string() || + protocol->get_ref() != ProtocolName) + return false; + const auto version = value.find("version"); + if (version == value.end() || !version->is_number_unsigned() || + version->get_ref() != + ProtocolVersion) + return false; + const auto kind = value.find("kind"); + if (kind == value.end() || !kind->is_string()) + return false; + const std::string &kindValue = + kind->get_ref(); + const auto stringField = [&value](const char *name) { + const auto member = value.find(name); + return member != value.end() && member->is_string() && + !member->get_ref().empty(); + }; + if (kindValue == "command") { + const auto data = value.find("data"); + return stringField("action") && data != value.end() && data->is_object(); + } + if (kindValue != "event" && kindValue != "result") + return false; + const auto sequence = value.find("sequence"); + const auto generation = value.find("generation"); + const auto authority = value.find("authority"); + if (sequence == value.end() || !sequence->is_number_unsigned() || + generation == value.end() || !generation->is_number_unsigned() || + authority == value.end() || !authority->is_string()) + return false; + const std::string &authorityValue = + authority->get_ref(); + if (authorityValue != "none" && authorityValue != "merge" && + authorityValue != "replace" && authorityValue != "remove") + return false; + const auto scope = value.find("scope"); + if (scope != value.end() && !scope->is_object()) + return false; + if (kindValue == "event") { + const auto data = value.find("data"); + return stringField("type") && data != value.end() && data->is_object(); + } + const auto ok = value.find("ok"); + if (!stringField("action") || !stringField("correlationId") || + ok == value.end() || !ok->is_boolean()) + return false; + return ok->get_ref() + ? value.contains("data") && !value.contains("error") + : value.contains("error") && !value.contains("data"); } std::string stringMember(const nlohmann::json &value, const char *name) { diff --git a/src/codex/ProtocolNormalizer.cpp b/src/codex/ProtocolNormalizer.cpp index 8912e58..cb88773 100644 --- a/src/codex/ProtocolNormalizer.cpp +++ b/src/codex/ProtocolNormalizer.cpp @@ -206,6 +206,11 @@ std::optional remainingNotification(std::string_view method) { ProtocolNormalizer::ProtocolNormalizer(Sink sink) : sink(std::move(sink)) {} +void ProtocolNormalizer::setDeliveryFailureHandler( + std::function handler) { + deliveryFailureHandler = std::move(handler); +} + void ProtocolNormalizer::transportEvent(std::string_view eventName, std::string detail) { if (eventName == "connected") @@ -233,9 +238,9 @@ void ProtocolNormalizer::bridgeEvent(const nlohmann::json &value) { const std::string kind = presentation::stringMember(value, "kind"); if (kind == "bridge.connection") { emitEvent("connection.bridge", - {{"state", value.value("event", std::string{})}, - {"connectionId", value.value("connectionId", std::string{})}, - {"role", value.value("role", std::string{})}}); + {{"state", presentation::stringMember(value, "event")}, + {"connectionId", presentation::stringMember(value, "connectionId")}, + {"role", presentation::stringMember(value, "role")}}); return; } if (kind == "bridge.controller") { @@ -245,10 +250,25 @@ void ProtocolNormalizer::bridgeEvent(const nlohmann::json &value) { Authority::Replace); return; } + if (kind == "bridge.provider") { + const auto generation = value.find("providerGeneration"); + if (generation == value.end() || !generation->is_number_unsigned()) { + diagnostic("bridge", "invalid-provider-event", + "provider event has no unsigned generation", value); + return; + } + nlohmann::json data{{"state", presentation::stringMember(value, "state")}, + {"generation", generation->get()}}; + const std::string reason = presentation::stringMember(value, "reason"); + if (!reason.empty()) + data["reason"] = reason; + emitEvent("connection.provider", std::move(data), Authority::Replace); + return; + } if (kind == "bridge.diagnostic") { - diagnostic("bridge", value.value("code", std::string{}), - value.value("message", std::string{}), - value.value("details", nlohmann::json::object())); + diagnostic("bridge", presentation::stringMember(value, "code"), + presentation::stringMember(value, "message"), + presentation::member(value, "details", nlohmann::json::object())); return; } diagnostic("bridge", "unknown-event", kind, value); @@ -259,7 +279,8 @@ void ProtocolNormalizer::serverNotification(std::string_view method, const nlohmann::json scope = stableScope(params); if (method == "thread/started") { emitEvent("thread.upsert", - {{"thread", params.value("thread", nlohmann::json::object())}}, + {{"thread", presentation::member( + params, "thread", nlohmann::json::object())}}, Authority::Merge); } else if (method == "thread/status/changed") { emitEvent("thread.status.changed", @@ -282,15 +303,18 @@ void ProtocolNormalizer::serverNotification(std::string_view method, emitEvent( "turn.upsert", {{"lifecycle", method == "turn/started" ? "started" : "completed"}, - {"turn", params.value("turn", nlohmann::json::object())}}, + {"turn", presentation::member(params, "turn", + nlohmann::json::object())}}, Authority::Merge, scope); } else if (method == "turn/plan/updated") { emitEvent("plan.replaced", {{"explanation", presentation::member(params, "explanation")}, - {"steps", params.value("plan", nlohmann::json::array())}}, + {"steps", presentation::member( + params, "plan", nlohmann::json::array())}}, Authority::Replace, scope); } else if (method == "item/started" || method == "item/completed") { - const nlohmann::json item = params.value("item", nlohmann::json::object()); + const nlohmann::json item = + presentation::member(params, "item", nlohmann::json::object()); nlohmann::json itemScope = scope; if (!itemScope.contains("itemId") && item.contains("id") && !item["id"].is_null()) @@ -300,7 +324,7 @@ void ProtocolNormalizer::serverNotification(std::string_view method, {{"lifecycle", method == "item/started" ? "started" : "completed"}, {"item", item}}, Authority::Merge, itemScope); - const std::string itemType = item.value("type", std::string{}); + const std::string itemType = presentation::stringMember(item, "type"); if (itemType == "collabAgentToolCall" || itemType == "subAgentActivity") { emitEvent( "agents.activity.upsert", @@ -321,7 +345,7 @@ void ProtocolNormalizer::serverNotification(std::string_view method, else if (method == "item/reasoning/textDelta") field = "content"; nlohmann::json data{{"field", std::move(field)}, - {"text", params.value("delta", std::string{})}}; + {"text", presentation::stringMember(params, "delta")}}; if (params.contains("summaryIndex")) data["summaryIndex"] = params["summaryIndex"]; if (params.contains("contentIndex")) @@ -341,7 +365,8 @@ void ProtocolNormalizer::serverNotification(std::string_view method, } else if (method == "thread/tokenUsage/updated") { emitEvent( "thread.token-usage.changed", - {{"tokenUsage", params.value("tokenUsage", nlohmann::json::object())}}, + {{"tokenUsage", presentation::member( + params, "tokenUsage", nlohmann::json::object())}}, Authority::Replace, scope); } else if (method == "account/updated") { emitEvent("account.changed", {{"account", params}}, Authority::Replace); @@ -380,7 +405,9 @@ void ProtocolNormalizer::observeRawInbound(const nlohmann::json &message) { void ProtocolNormalizer::operationResult(std::string action, std::string correlationId, nlohmann::json context, - const nlohmann::json &response) { + const nlohmann::json &response, + std::optional + startedAtSequence) { const bool ok = response.is_object() && response.contains("result"); nlohmann::json data; Authority authority = Authority::None; @@ -389,24 +416,29 @@ void ProtocolNormalizer::operationResult(std::string action, const nlohmann::json &value = response["result"]; if (action == "threads.list") { data = { - {"threads", value.value("data", nlohmann::json::array())}, + {"threads", presentation::member(value, "data", + nlohmann::json::array())}, {"nextCursor", presentation::member(value, "nextCursor")}, {"backwardsCursor", presentation::member(value, "backwardsCursor")}}; authority = Authority::Merge; } else if (action == "thread.read") { const nlohmann::json thread = - value.value("thread", nlohmann::json::object()); + presentation::member(value, "thread", nlohmann::json::object()); data = {{"thread", thread}}; - authority = Authority::Merge; + authority = startedAtSequence && *startedAtSequence == nextSequence + ? Authority::Replace + : Authority::Merge; const std::string threadId = presentation::stringMember(thread, "id"); if (!threadId.empty()) scope["threadId"] = threadId; } else if (action == "thread.create" || action == "thread.resume" || action == "thread.fork") { - data = {{"thread", value.value("thread", nlohmann::json::object())}}; + data = {{"thread", presentation::member( + value, "thread", nlohmann::json::object())}}; authority = Authority::Merge; } else if (action == "models.list") { - data = {{"models", value.value("data", nlohmann::json::array())}, + data = {{"models", presentation::member(value, "data", + nlohmann::json::array())}, {"nextCursor", presentation::member(value, "nextCursor")}}; authority = Authority::Replace; } else if (action == "model-provider-capabilities.read" || @@ -428,7 +460,8 @@ void ProtocolNormalizer::operationResult(std::string action, data = value; authority = Authority::Replace; } else if (action == "turn.start") { - data = {{"turn", value.value("turn", nlohmann::json::object())}}; + data = {{"turn", presentation::member( + value, "turn", nlohmann::json::object())}}; authority = Authority::Merge; } else { data = value; @@ -449,8 +482,15 @@ void ProtocolNormalizer::operationRejected(std::string action, {{"code", code}, {"message", std::move(message)}})); } -bool ProtocolNormalizer::emit(nlohmann::json frame) const { - return sink && sink(frame); +bool ProtocolNormalizer::emit(nlohmann::json frame) { + if (deliveryFailed) + return false; + if (sink && sink(frame)) + return true; + deliveryFailed = true; + if (deliveryFailureHandler) + deliveryFailureHandler(); + return false; } bool ProtocolNormalizer::emitEvent(std::string type, nlohmann::json data, @@ -485,4 +525,8 @@ bool ProtocolNormalizer::knownServerMethod(std::string_view method) const { return false; } +std::uint64_t ProtocolNormalizer::sequence() const noexcept { + return nextSequence; +} + } // namespace codexui::codex diff --git a/src/codex/ProtocolNormalizer.h b/src/codex/ProtocolNormalizer.h index af66664..12c0fc1 100644 --- a/src/codex/ProtocolNormalizer.h +++ b/src/codex/ProtocolNormalizer.h @@ -8,6 +8,7 @@ #include "codex/PresentationProtocol.h" #include +#include #include #include @@ -18,6 +19,7 @@ class ProtocolNormalizer final { using Sink = std::function; explicit ProtocolNormalizer(Sink sink); + void setDeliveryFailureHandler(std::function handler); void transportEvent(std::string_view event, std::string detail = {}); void connectionSettings(nlohmann::json settings); @@ -31,12 +33,15 @@ class ProtocolNormalizer final { void observeRawInbound(const nlohmann::json &message); void operationResult(std::string action, std::string correlationId, - nlohmann::json context, const nlohmann::json &response); + nlohmann::json context, const nlohmann::json &response, + std::optional startedAtSequence = + std::nullopt); void operationRejected(std::string action, std::string correlationId, int code, std::string message); + [[nodiscard]] std::uint64_t sequence() const noexcept; private: - bool emit(nlohmann::json frame) const; + bool emit(nlohmann::json frame); bool emitEvent(std::string type, nlohmann::json data = nlohmann::json::object(), presentation::Authority authority = presentation::Authority::None, @@ -46,6 +51,8 @@ class ProtocolNormalizer final { bool knownServerMethod(std::string_view method) const; Sink sink; + std::function deliveryFailureHandler; + bool deliveryFailed = false; std::uint64_t connectionGeneration = 0; std::uint64_t nextSequence = 1; }; diff --git a/src/codex/ipc/QtSocketPairEndpoint.cpp b/src/codex/ipc/QtSocketPairEndpoint.cpp index 16fe6c3..e40c7ec 100644 --- a/src/codex/ipc/QtSocketPairEndpoint.cpp +++ b/src/codex/ipc/QtSocketPairEndpoint.cpp @@ -3,7 +3,9 @@ #include "codex/ipc/QtSocketPairEndpoint.h" #include +#include +#include #include #include #include @@ -14,9 +16,13 @@ namespace codexui::codex::ipc { QtSocketPairEndpoint::QtSocketPairEndpoint(int descriptor, std::size_t maximumQueuedBytes, + std::size_t maximumReadBytesPerActivation, + std::size_t maximumWriteBytesPerActivation, QObject *parent) : QObject(parent), descriptor(descriptor), - maximumQueuedBytes(maximumQueuedBytes) { + maximumQueuedBytes(maximumQueuedBytes), + maximumReadBytesPerActivation(maximumReadBytesPerActivation), + maximumWriteBytesPerActivation(maximumWriteBytesPerActivation) { readNotifier = new QSocketNotifier(descriptor, QSocketNotifier::Read, this); writeNotifier = new QSocketNotifier(descriptor, QSocketNotifier::Write, this); writeNotifier->setEnabled(false); @@ -26,20 +32,26 @@ QtSocketPairEndpoint::QtSocketPairEndpoint(int descriptor, [this] { writeReady(); }); } -QtSocketPairEndpoint::~QtSocketPairEndpoint() { close(); } +QtSocketPairEndpoint::~QtSocketPairEndpoint() { + destroying = true; + onData = {}; + onError = {}; + onClosed = {}; + closeTransport(); +} bool QtSocketPairEndpoint::send(const char *data, std::size_t size) { if (!isOpen() || size > maximumQueuedBytes || queuedBytes() > maximumQueuedBytes - size) return false; - if (writeOffset != 0 && writeOffset == writeBuffer.size()) { - writeBuffer.clear(); - writeOffset = 0; + if (size != 0) { + writeChunks.emplace_back(data, size); + queuedWriteBytes += size; } - writeBuffer.append(data, size); + QPointer guard(this); writeReady(); - return isOpen(); + return guard && guard->isOpen(); } bool QtSocketPairEndpoint::send(const std::string &data) { @@ -47,7 +59,14 @@ bool QtSocketPairEndpoint::send(const std::string &data) { } std::size_t QtSocketPairEndpoint::queuedBytes() const noexcept { - return writeBuffer.size() - writeOffset; + return queuedWriteBytes; +} + +std::size_t QtSocketPairEndpoint::retainedWriteBytes() const noexcept { + std::size_t retained = 0; + for (const std::string &chunk : writeChunks) + retained += chunk.capacity(); + return retained; } bool QtSocketPairEndpoint::isOpen() const noexcept { @@ -67,6 +86,20 @@ void QtSocketPairEndpoint::setOnClosed(ClosedHandler handler) { } void QtSocketPairEndpoint::close() noexcept { + if (closing) + return; + ClosedHandler closed = std::move(onClosed); + onClosed = {}; + closeTransport(); + if (!destroying && closed) { + try { + closed(); + } catch (...) { + } + } +} + +void QtSocketPairEndpoint::closeTransport() noexcept { if (closing) return; closing = true; @@ -79,20 +112,33 @@ void QtSocketPairEndpoint::close() noexcept { ::close(descriptor); descriptor = -1; } - writeBuffer.clear(); - writeOffset = 0; - if (onClosed) - onClosed(); + writeChunks.clear(); + firstChunkOffset = 0; + queuedWriteBytes = 0; } void QtSocketPairEndpoint::readReady() { std::array buffer{}; - while (isOpen()) { + std::size_t totalRead = 0; + while (isOpen() && totalRead < maximumReadBytesPerActivation) { + const std::size_t requested = + std::min(buffer.size(), maximumReadBytesPerActivation - totalRead); const ssize_t received = - ::recv(descriptor, buffer.data(), buffer.size(), 0); + ::recv(descriptor, buffer.data(), requested, 0); if (received > 0) { - if (onData) - onData(buffer.data(), static_cast(received)); + totalRead += static_cast(received); + if (onData) { + QPointer guard(this); + try { + onData(buffer.data(), static_cast(received)); + } catch (...) { + if (guard) + guard->fail(EPROTO); + return; + } + if (!guard) + return; + } continue; } if (received == 0) { @@ -109,11 +155,24 @@ void QtSocketPairEndpoint::readReady() { } void QtSocketPairEndpoint::writeReady() { - while (isOpen() && queuedBytes() != 0) { - const ssize_t sent = ::send(descriptor, writeBuffer.data() + writeOffset, - queuedBytes(), MSG_NOSIGNAL); + std::size_t totalWritten = 0; + while (isOpen() && queuedBytes() != 0 && + totalWritten < maximumWriteBytesPerActivation) { + const std::string &chunk = writeChunks.front(); + const std::size_t requested = std::min( + chunk.size() - firstChunkOffset, + maximumWriteBytesPerActivation - totalWritten); + const ssize_t sent = ::send(descriptor, chunk.data() + firstChunkOffset, + requested, MSG_NOSIGNAL); if (sent > 0) { - writeOffset += static_cast(sent); + const std::size_t size = static_cast(sent); + firstChunkOffset += size; + queuedWriteBytes -= size; + totalWritten += size; + if (firstChunkOffset == chunk.size()) { + writeChunks.pop_front(); + firstChunkOffset = 0; + } continue; } if (sent < 0 && errno == EINTR) @@ -126,16 +185,37 @@ void QtSocketPairEndpoint::writeReady() { return; } - writeBuffer.clear(); - writeOffset = 0; - if (writeNotifier) - writeNotifier->setEnabled(false); + if (queuedBytes() == 0) { + writeChunks.clear(); + firstChunkOffset = 0; + if (writeNotifier) + writeNotifier->setEnabled(false); + } else if (writeNotifier) { + writeNotifier->setEnabled(true); + } } void QtSocketPairEndpoint::fail(int errorNumber) noexcept { - if (onError) - onError(errorNumber); - close(); + if (closing) + return; + ErrorHandler error = std::move(onError); + ClosedHandler closed = std::move(onClosed); + onError = {}; + onClosed = {}; + closeTransport(); + QPointer guard(this); + if (!destroying && error) { + try { + error(errorNumber); + } catch (...) { + } + } + if (guard && !destroying && closed) { + try { + closed(); + } catch (...) { + } + } } } // namespace codexui::codex::ipc diff --git a/src/codex/ipc/QtSocketPairEndpoint.h b/src/codex/ipc/QtSocketPairEndpoint.h index d877712..b928c09 100644 --- a/src/codex/ipc/QtSocketPairEndpoint.h +++ b/src/codex/ipc/QtSocketPairEndpoint.h @@ -6,6 +6,7 @@ #include #include +#include #include #include @@ -20,6 +21,10 @@ class QtSocketPairEndpoint final : public QObject { using ClosedHandler = std::function; explicit QtSocketPairEndpoint(int descriptor, std::size_t maximumQueuedBytes, + std::size_t maximumReadBytesPerActivation = + 256U * 1024U, + std::size_t maximumWriteBytesPerActivation = + 256U * 1024U, QObject *parent = nullptr); ~QtSocketPairEndpoint() override; @@ -29,6 +34,7 @@ class QtSocketPairEndpoint final : public QObject { [[nodiscard]] bool send(const char *data, std::size_t size); [[nodiscard]] bool send(const std::string &data); [[nodiscard]] std::size_t queuedBytes() const noexcept; + [[nodiscard]] std::size_t retainedWriteBytes() const noexcept; [[nodiscard]] bool isOpen() const noexcept; void setOnData(DataHandler handler); @@ -40,17 +46,22 @@ class QtSocketPairEndpoint final : public QObject { void readReady(); void writeReady(); void fail(int errorNumber) noexcept; + void closeTransport() noexcept; int descriptor = -1; std::size_t maximumQueuedBytes; - std::string writeBuffer; - std::size_t writeOffset = 0; + std::size_t maximumReadBytesPerActivation; + std::size_t maximumWriteBytesPerActivation; + std::deque writeChunks; + std::size_t firstChunkOffset = 0; + std::size_t queuedWriteBytes = 0; QSocketNotifier *readNotifier = nullptr; QSocketNotifier *writeNotifier = nullptr; DataHandler onData; ErrorHandler onError; ClosedHandler onClosed; bool closing = false; + bool destroying = false; }; } // namespace codexui::codex::ipc diff --git a/src/codex/ipc/SNodeSocketPairEndpoint.cpp b/src/codex/ipc/SNodeSocketPairEndpoint.cpp index d047ad8..b0611c5 100644 --- a/src/codex/ipc/SNodeSocketPairEndpoint.cpp +++ b/src/codex/ipc/SNodeSocketPairEndpoint.cpp @@ -42,12 +42,16 @@ SNodeSocketPairEndpoint::create(int descriptor, std::size_t maximumQueuedBytes, const bool writeEnabled = readEnabled && endpoint->WriteEventReceiver::enable(descriptor); if (!readEnabled || !writeEnabled) { - if (readEnabled) - endpoint->ReadEventReceiver::disable(); - if (writeEnabled) - endpoint->WriteEventReceiver::disable(); + endpoint->closing = true; endpoint->closeDescriptor(); - delete endpoint; + if (!readEnabled) { + delete endpoint; + } else { + // disable() is deferred by SNode.C. Once the registered read receiver is + // actually unobserved, unobservedEvent() owns destruction. + endpoint->initializing = false; + endpoint->ReadEventReceiver::disable(); + } return nullptr; } @@ -132,8 +136,14 @@ void SNodeSocketPairEndpoint::readEvent() { if (result > 0) { const std::size_t size = static_cast(result); totalRead += size; - if (onData) - onData(chunk.data(), size); + if (onData) { + try { + onData(chunk.data(), size); + } catch (...) { + reportError(EPROTO); + return; + } + } continue; } if (result == 0) { @@ -184,8 +194,12 @@ void SNodeSocketPairEndpoint::unobservedEvent() { if (initializing) return; closeDescriptor(); - if (onClosed) - onClosed(); + if (onClosed) { + try { + onClosed(); + } catch (...) { + } + } delete this; } @@ -206,8 +220,14 @@ void SNodeSocketPairEndpoint::closeDescriptor() noexcept { } void SNodeSocketPairEndpoint::reportError(int errorNumber) { - if (onError) - onError(errorNumber); + ErrorHandler error = std::move(onError); + onError = {}; + if (error) { + try { + error(errorNumber); + } catch (...) { + } + } close(); } diff --git a/src/greenfield/codex/ShellWidget.cpp b/src/greenfield/codex/ShellWidget.cpp index 14a2fd9..2ba5344 100644 --- a/src/greenfield/codex/ShellWidget.cpp +++ b/src/greenfield/codex/ShellWidget.cpp @@ -168,6 +168,7 @@ struct ShellWidget::Impl final { void buildUi(); void connectUi(); void handleEvent(const nlohmann::json &event); + void scheduleRender(); void render(); void renderConversation(); void refreshSettings(); @@ -229,8 +230,10 @@ struct ShellWidget::Impl final { std::unordered_set promptRecoveryAttempted; std::unordered_map historyWindows; std::uint64_t observedConnectionGeneration = 0; + std::uint64_t observedProviderGeneration = 0; QByteArray settingsSnapshot; QByteArray statusSnapshot; + bool renderScheduled = false; middle::MiddleRegionWidget *middleRegion = nullptr; QPushButton *restoreSidebarButton = nullptr; @@ -455,11 +458,25 @@ void ShellWidget::Impl::handleEvent(const nlohmann::json &event) { operationReadyThreads.clear(); dispatchScheduledThreads.clear(); } + if (connection.providerGeneration != observedProviderGeneration) { + observedProviderGeneration = connection.providerGeneration; + hydration.clear(); + readRevisions.clear(); + operationReadyThreads.clear(); + dispatchScheduledThreads.clear(); + } const std::string type = stringValue(event, "type"); const nlohmann::json data = event.value("data", nlohmann::json::object()); const nlohmann::json scope = event.value("scope", nlohmann::json::object()); const std::string eventThreadId = stringValue(scope, "threadId"); + if (kind == "event" && type == "connection.provider" && + stringValue(data, "state") == "disconnected") { + hydration.clear(); + readRevisions.clear(); + operationReadyThreads.clear(); + dispatchScheduledThreads.clear(); + } if (kind == "result" && !event.value("ok", false) && action != "turn.start" && action != "turn.steer" && action != "thread.read" && @@ -522,10 +539,31 @@ void ShellWidget::Impl::handleEvent(const nlohmann::json &event) { prompts.compactResolved(eventThreadId, QDateTime::currentMSecsSinceEpoch()); } + } else if (kind == "event" && type == "connection.provider" && + stringValue(data, "state") == "ready") { + session.listThreads(); + session.listModels(); + readThread(selectedThreadId, true); } hydrateHistoricalAgents(); - render(); + scheduleRender(); +} + +void ShellWidget::Impl::scheduleRender() { + if (renderScheduled) + return; + renderScheduled = true; + const auto token = alive; + // A streamed response may deliver many deltas in one display interval. + // Reconcile once per frame instead of rebuilding rich text and layout for + // every transport chunk. + QTimer::singleShot(16, Qt::PreciseTimer, owner, [this, token] { + if (!*token) + return; + renderScheduled = false; + render(); + }); } void ShellWidget::Impl::render() { diff --git a/src/greenfield/codex/middle/ConversationCards.cpp b/src/greenfield/codex/middle/ConversationCards.cpp index 37351e8..544dfc3 100644 --- a/src/greenfield/codex/middle/ConversationCards.cpp +++ b/src/greenfield/codex/middle/ConversationCards.cpp @@ -11,6 +11,7 @@ #include #include #include +#include #include #include #include @@ -213,13 +214,22 @@ bool CommandOutputView::followsLatest() const noexcept { } bool CommandOutputView::setOutput(const QString &output) { - if (toPlainText() == output) + if (currentOutput_ == output) return false; const bool retainedFollow = followsLatest_; const int retainedValue = preservedScrollValue_; + const bool appendOnly = !currentOutput_.isEmpty() && + output.startsWith(currentOutput_); programmaticScroll_ = true; - setPlainText(output); + if (appendOnly) { + QTextCursor cursor = textCursor(); + cursor.movePosition(QTextCursor::End); + cursor.insertText(output.sliced(currentOutput_.size())); + } else { + setPlainText(output); + } + currentOutput_ = output; followsLatest_ = retainedFollow; preservedScrollValue_ = retainedValue; programmaticScroll_ = false; diff --git a/src/greenfield/codex/middle/ConversationCards.h b/src/greenfield/codex/middle/ConversationCards.h index 769b648..1cad853 100644 --- a/src/greenfield/codex/middle/ConversationCards.h +++ b/src/greenfield/codex/middle/ConversationCards.h @@ -56,6 +56,7 @@ class CommandOutputView final : public QPlainTextEdit { bool settlingScroll_ = false; int preservedScrollValue_ = 0; int preferredHeight_ = 0; + QString currentOutput_; }; class ConversationCard : public QFrame { diff --git a/tests/codex/GreenfieldShellIntegrationTest.cpp b/tests/codex/GreenfieldShellIntegrationTest.cpp index e81b56e..0659bed 100644 --- a/tests/codex/GreenfieldShellIntegrationTest.cpp +++ b/tests/codex/GreenfieldShellIntegrationTest.cpp @@ -60,6 +60,7 @@ bool expect(bool condition, const char *message) { } void spin(int milliseconds = 0) { + milliseconds = std::max(milliseconds, 20); QElapsedTimer timer; timer.start(); do { diff --git a/tests/codex/PresentationPipelineTest.cpp b/tests/codex/PresentationPipelineTest.cpp index bd5d4eb..30dd8a4 100644 --- a/tests/codex/PresentationPipelineTest.cpp +++ b/tests/codex/PresentationPipelineTest.cpp @@ -205,5 +205,19 @@ int main() { "incomplete thread reads preserve live plan and inspector state"); passed &= expect(!model.activeTurnId("thread-1").has_value(), "completed stream leaves no active turn"); + normalizer.bridgeEvent({{"kind", "bridge.provider"}, + {"state", "disconnected"}, + {"providerGeneration", std::uint64_t{1}}, + {"reason", "test provider restart"}}); + passed &= expect(model.thread("thread-1") == nullptr && + model.connection().providerGeneration == 1 && + model.connection().providerState == "disconnected", + "provider loss clears provider-scoped presentation state"); + normalizer.bridgeEvent({{"kind", "bridge.provider"}, + {"state", "ready"}, + {"providerGeneration", std::uint64_t{2}}}); + passed &= expect(model.connection().providerGeneration == 2 && + model.connection().providerState == "ready", + "a new provider generation is accepted for rehydration"); return passed ? 0 : 1; } diff --git a/tests/codex/SocketPairContractTest.cpp b/tests/codex/SocketPairContractTest.cpp index 2b5b234..90b0ac0 100644 --- a/tests/codex/SocketPairContractTest.cpp +++ b/tests/codex/SocketPairContractTest.cpp @@ -13,12 +13,15 @@ #include #include +#include #include #include #include #include #include +#include #include +#include namespace { @@ -33,12 +36,50 @@ bool expect(bool condition, const char *message) { return condition; } +bool qtPartialWritesRetainOnlyQueuedBytes() { + constexpr std::size_t QueueLimit = 32U * 1024U; + codexui::codex::ipc::SocketPair pair; + if (!pair.isValid()) + return false; + const int qtDescriptor = pair.releaseFirstEndpoint(); + const int peerDescriptor = pair.releaseSecondEndpoint(); + int socketBytes = 4096; + static_cast(::setsockopt(qtDescriptor, SOL_SOCKET, SO_SNDBUF, + &socketBytes, sizeof(socketBytes))); + codexui::codex::ipc::QtSocketPairEndpoint endpoint( + qtDescriptor, QueueLimit, 64U * 1024U, 257); + const std::string chunk(2048, 'q'); + std::array drain{}; + bool bounded = true; + for (int round = 0; round < 512; ++round) { + if (!endpoint.send(chunk)) { + while (::recv(peerDescriptor, drain.data(), drain.size(), MSG_DONTWAIT) > + 0) { + } + QCoreApplication::processEvents(); + static_cast(endpoint.send(chunk)); + } + bounded &= endpoint.retainedWriteBytes() <= QueueLimit + chunk.size(); + if (round % 4 == 0) { + static_cast( + ::recv(peerDescriptor, drain.data(), drain.size(), MSG_DONTWAIT)); + QCoreApplication::processEvents(); + } + } + endpoint.close(); + ::close(peerDescriptor); + return bounded; +} + } // namespace int main(int argc, char *argv[]) { QCoreApplication application(argc, argv); core::SNodeC::init(argc, argv); + bool passed = expect(qtPartialWritesRetainOnlyQueuedBytes(), + "Qt partial writes retain only bounded queued bytes"); + codexui::codex::ipc::SocketPair pair; if (!expect(pair.isValid(), "nonblocking Unix socketpair is created")) return 1; @@ -132,7 +173,6 @@ int main(int argc, char *argv[]) { qtEndpoint.close(); snodeThread.join(); - bool passed = true; passed &= expect(snodeCreated, "SNode.C endpoint is created"); passed &= expect(qtBounded && snodeBounded, "both endpoints reject writes beyond their queue bound");