diff --git a/docs/windows-native-window.md b/docs/windows-native-window.md index 964b9b93..7189c3b1 100644 --- a/docs/windows-native-window.md +++ b/docs/windows-native-window.md @@ -87,6 +87,30 @@ auto id = api.increment_async( Shared State has the same generated shape, such as `get_counter_async(...)` and `set_counter_async(value, ...)`. +### Polling the backend + +Every RPC also gains a bounded-wait companion, `name_within(arguments, +std::chrono::milliseconds)`. Its completion resolves a promise, so the call +waits up to the timeout and **cancels the pending request on a miss**; Void +RPCs return `bool` (false = timed out), other results return +`std::optional`. Backend and decoding errors still throw from the final +get, matching the blocking call's contract. + +```cpp +// A 400 ms UI tick polls without spawning a thread and without blocking +// past the frame budget: +if (auto value = api.increment_within(41, std::chrono::milliseconds{0})) { + counter_label().Text(std::to_wstring(*value)); +} +``` + +This is the sanctioned shape for long-lived polling UIs. Do not spawn a +detached `std::thread` per tick: each one races the reader thread, leaks +past teardown, and churns a native thread per frame. Prefer completions +when an update push model works; use `_within` when the UI owns a tick +loop, with a zero timeout for a pure poll or a small budget when a +bounded wait is acceptable. + ### Threading RVT1 responses are decoded on Rivet's native reader thread. Normal completion diff --git a/platform/linux/host/GeneratedBackend.hpp b/platform/linux/host/GeneratedBackend.hpp index 6d92d87c..44adcfe0 100644 --- a/platform/linux/host/GeneratedBackend.hpp +++ b/platform/linux/host/GeneratedBackend.hpp @@ -5,6 +5,7 @@ // compiles before the first build. #pragma once +#include #include #include #include @@ -67,6 +68,52 @@ class API { }); } + // Bounded-wait companions: the completion resolves a promise, so a + // poller waits up to the timeout and cancels the pending request on a + // miss. Poll a long-lived UI from one call site instead of spawning a + // detached thread per tick. + std::optional greet_within( + std::string name, std::chrono::milliseconds rivet_timeout) { + auto rivet_done = std::make_shared>(); + auto rivet_id = backend_.request_async( + "greet", rivet::linux_runtime::Value::List{rivet::Value(std::move(name))}, + [rivet_done](rivet::linux_runtime::CallResult call) mutable { + auto result = unbox_string(std::move(call)); + if (result.error) { + rivet_done->set_exception(result.error); + } else { + rivet_done->set_value(*result.value); + } + }); + if (rivet_done->get_future().wait_for(rivet_timeout) != + std::future_status::ready) { + backend_.cancel(rivet_id); + return std::nullopt; + } + return rivet_done->get_future().get(); + } + + std::optional increment_within( + std::int64_t value, std::chrono::milliseconds rivet_timeout) { + auto rivet_done = std::make_shared>(); + auto rivet_id = backend_.request_async( + "increment", rivet::linux_runtime::Value::List{rivet::Value(value)}, + [rivet_done](rivet::linux_runtime::CallResult call) mutable { + auto result = unbox_int64(std::move(call)); + if (result.error) { + rivet_done->set_exception(result.error); + } else { + rivet_done->set_value(*result.value); + } + }); + if (rivet_done->get_future().wait_for(rivet_timeout) != + std::future_status::ready) { + backend_.cancel(rivet_id); + return std::nullopt; + } + return rivet_done->get_future().get(); + } + // State accessors: completion callbacks run on the backend reader thread // and must dispatch to the UI thread before touching widgets. void get_counter_async( @@ -89,6 +136,22 @@ class API { } private: + static rivet_app::Result unbox_string(rivet::linux_runtime::CallResult call) { + rivet_app::Result result; + if (!call.succeeded()) { + result.error = call.error ? call.error : + std::make_exception_ptr(std::runtime_error("unknown backend failure")); + return result; + } + if (auto text = std::get_if(&call.value->data)) { + result.value = *text; + return result; + } + result.error = std::make_exception_ptr( + std::runtime_error("Rivet result type mismatch: String")); + return result; + } + static rivet_app::Result unbox_int64(rivet::linux_runtime::CallResult call) { rivet_app::Result result; if (!call.succeeded()) { diff --git a/platform/windows/host/GeneratedBackend.hpp b/platform/windows/host/GeneratedBackend.hpp index 5ead0a4f..1378d47c 100644 --- a/platform/windows/host/GeneratedBackend.hpp +++ b/platform/windows/host/GeneratedBackend.hpp @@ -5,6 +5,7 @@ // compiles before the first build, mirroring the Linux scaffold header. #pragma once +#include #include #include #include @@ -67,6 +68,52 @@ class API { }); } + // Bounded-wait companions: the completion resolves a promise, so a + // poller waits up to the timeout and cancels the pending request on a + // miss. Poll a long-lived UI from one call site instead of spawning a + // detached thread per tick. + std::optional greet_within( + std::string name, std::chrono::milliseconds rivet_timeout) { + auto rivet_done = std::make_shared>(); + auto rivet_id = backend_.request_async( + "greet", rivet::windows::Value::List{rivet::Value(std::move(name))}, + [rivet_done](rivet::windows::CallResult call) mutable { + auto result = unbox_string(std::move(call)); + if (result.error) { + rivet_done->set_exception(result.error); + } else { + rivet_done->set_value(*result.value); + } + }); + if (rivet_done->get_future().wait_for(rivet_timeout) != + std::future_status::ready) { + backend_.cancel(rivet_id); + return std::nullopt; + } + return rivet_done->get_future().get(); + } + + std::optional increment_within( + std::int64_t value, std::chrono::milliseconds rivet_timeout) { + auto rivet_done = std::make_shared>(); + auto rivet_id = backend_.request_async( + "increment", rivet::windows::Value::List{rivet::Value(value)}, + [rivet_done](rivet::windows::CallResult call) mutable { + auto result = unbox_int64(std::move(call)); + if (result.error) { + rivet_done->set_exception(result.error); + } else { + rivet_done->set_value(*result.value); + } + }); + if (rivet_done->get_future().wait_for(rivet_timeout) != + std::future_status::ready) { + backend_.cancel(rivet_id); + return std::nullopt; + } + return rivet_done->get_future().get(); + } + // State accessors: completion callbacks run on the backend reader thread // and must dispatch to the UI thread before touching widgets. void get_counter_async( @@ -89,6 +136,22 @@ class API { } private: + static rivet_app::Result unbox_string(rivet::windows::CallResult call) { + rivet_app::Result result; + if (!call.succeeded()) { + result.error = call.error ? call.error : + std::make_exception_ptr(std::runtime_error("unknown backend failure")); + return result; + } + if (auto text = std::get_if(&call.value->data)) { + result.value = *text; + return result; + } + result.error = std::make_exception_ptr( + std::runtime_error("Rivet result type mismatch: String")); + return result; + } + static rivet_app::Result unbox_int64(rivet::windows::CallResult call) { rivet_app::Result result; if (!call.succeeded()) { diff --git a/rivet-cli/codegen/cpp.rkt b/rivet-cli/codegen/cpp.rkt index ba31acbb..c4aaabca 100644 --- a/rivet-cli/codegen/cpp.rkt +++ b/rivet-cli/codegen/cpp.rkt @@ -140,6 +140,8 @@ (define (cpp-async-handler result-type backend-namespace) (define result (cpp-type result-type)) + ;; Result has no value member, so the Void decode must not assign + ;; into it — it only validates that a value arrived. (define decode (if (eq? result-type 'Void) (format "detail::decode_~a(*rivet_result.value);" (type-key result-type)) @@ -148,6 +150,60 @@ "[completion = std::move(completion)](~a::CallResult rivet_result) mutable { Result<~a> result; if (rivet_result.error) { result.error = rivet_result.error; } else { try { if (!rivet_result.value) throw std::runtime_error(\"Rivet async call completed without a value\"); ~a } catch (...) { result.error = std::current_exception(); } } completion(std::move(result)); }" backend-namespace result decode)) +;; Bounded-wait companion (issue #252): the completion side of request_async +;; resolves a promise, so a poller waits up to the timeout and cancels the +;; request if it is still pending. Void RPCs return bool (false = timed +;; out); other results return std::nullopt on timeout. Backend errors still +;; throw from the final get, keeping the blocking call's error contract. +(define (cpp-promise-handler result-type backend-namespace) + (define settle + (if (eq? result-type 'Void) + (format "detail::decode_~a(*rivet_result.value);" (type-key result-type)) + (format "rivet_done->set_value(detail::decode_~a(*rivet_result.value));" + (type-key result-type)))) + (format + "[rivet_done](~a::CallResult rivet_result) mutable { if (rivet_result.error) { rivet_done->set_exception(rivet_result.error); } else { try { if (!rivet_result.value) throw std::runtime_error(\"Rivet async call completed without a value\"); ~a } catch (...) { rivet_done->set_exception(std::current_exception()); } } }" + backend-namespace settle)) + +(define (cpp-within-method info backend-namespace) + (define names (map cpp-id (schema-rpc-arg-names info))) + (define types (schema-rpc-arg-types info)) + (define result-type (schema-rpc-result-type info)) + (define void? (eq? result-type 'Void)) + (define params + (string-join + (for/list ([name (in-list names)] [type (in-list types)]) + (format "~a ~a" (cpp-type type) name)) + ", ")) + (define encoded + (string-join + (for/list ([name (in-list names)] [type (in-list types)]) + (format "detail::encode_~a(~a)" (type-key type) name)) + ", ")) + (define rpc-name (cpp-string-literal (symbol->string (schema-rpc-name info)))) + (define return-type + (if void? "bool" (format "std::optional<~a>" (cpp-type result-type)))) + (define timed-out-return (if void? "false" "std::nullopt")) + (define final-get + (if void? + "rivet_done->get_future().get(); return true;" + "return rivet_done->get_future().get();")) + ;; A zero-argument RPC has no parameter list; the timeout must not follow + ;; a stray comma. + (define within-params + (if (string=? params "") + "std::chrono::milliseconds rivet_timeout" + (format "~a, std::chrono::milliseconds rivet_timeout" params))) + (format + " ~a ~a_within(~a) { auto rivet_done = std::make_shared>(); auto rivet_id = backend_.request_async(~a, rivet::Value::List{~a}, ~a); if (rivet_done->get_future().wait_for(rivet_timeout) != std::future_status::ready) { backend_.cancel(rivet_id); return ~a; } ~a }\n" + return-type + (cpp-id (schema-rpc-name info)) + within-params + (cpp-type result-type) + rpc-name encoded + (cpp-promise-handler result-type backend-namespace) + timed-out-return final-get)) + (define (cpp-completion-type type) (format "std::function)>" (cpp-type type))) @@ -173,7 +229,7 @@ (cpp-append-param params (format "~a completion" (cpp-completion-type result-type)))) (format - " std::future<~a> ~a(~a) { ~a }\n [[nodiscard]] std::uint64_t ~a_async(~a) { if (!completion) throw std::invalid_argument(\"Rivet async completion handler is empty\"); return backend_.request_async(~a, rivet::Value::List{~a}, ~a); }\n" + " std::future<~a> ~a(~a) { ~a }\n [[nodiscard]] std::uint64_t ~a_async(~a) { if (!completion) throw std::invalid_argument(\"Rivet async completion handler is empty\"); return backend_.request_async(~a, rivet::Value::List{~a}, ~a); }\n~a" (cpp-type result-type) (cpp-id (schema-rpc-name info)) params @@ -182,7 +238,8 @@ rpc-name encoded)) (cpp-id (schema-rpc-name info)) async-params - rpc-name encoded (cpp-async-handler result-type backend-namespace))) + rpc-name encoded (cpp-async-handler result-type backend-namespace) + (cpp-within-method info backend-namespace))) (define (cpp-state-methods state backend-namespace) (define name (cpp-id (schema-state-name state))) @@ -228,7 +285,7 @@ identifier release-channel backend-namespace) (define types (all-types rpcs events states records enums)) (string-append - "// Generated by Rivet. Do not edit by hand.\n#pragma once\n\n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n\n#include \"backend.hpp\"\n\nnamespace rivet_app {\n" + "// Generated by Rivet. Do not edit by hand.\n#pragma once\n\n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n\n#include \"backend.hpp\"\n\nnamespace rivet_app {\n" (format "inline constexpr char kModuleName[] = ~a;\ninline constexpr char kEntryName[] = ~a;\ninline constexpr char kDisplayName[] = ~a;\ninline constexpr char kVersion[] = ~a;\ninline constexpr std::int64_t kBuild = ~a;\ninline constexpr char kIdentifier[] = ~a;\ninline constexpr char kReleaseChannel[] = ~a;\n\n" (cpp-string-literal module-name) (cpp-string-literal entry-name) diff --git a/rivet-cli/codegen/naming.rkt b/rivet-cli/codegen/naming.rkt index e513d468..aca497c1 100644 --- a/rivet-cli/codegen/naming.rkt +++ b/rivet-cli/codegen/naming.rkt @@ -287,7 +287,9 @@ (define name (cpp-id (schema-rpc-name info))) (list (cons name (format "RPC ~a" (schema-rpc-name info))) (cons (string-append name "_async") - (format "RPC async companion ~a" (schema-rpc-name info)))))) + (format "RPC async companion ~a" (schema-rpc-name info))) + (cons (string-append name "_within") + (format "RPC bounded-wait companion ~a" (schema-rpc-name info)))))) (append* (for/list ([state (in-list states)]) (define name (cpp-id (schema-state-name state))) diff --git a/rivet/tests/codegen.rkt b/rivet/tests/codegen.rkt index 3db3f8c8..52a2ab95 100644 --- a/rivet/tests/codegen.rkt +++ b/rivet/tests/codegen.rkt @@ -133,6 +133,9 @@ (define-rpc (echo-user [user : User] : User) user) +(define-rpc (log-line [message String] : Void) + (displayln message)) + (define (start in-fd out-fd) (serve-fds in-fd out-fd)) RKT @@ -140,7 +143,7 @@ RKT (define project (load-project project-root)) (define schema (generate-clients! project)) - (check-equal? (length (first schema)) 3) + (check-equal? (length (first schema)) 4) (check-equal? (length (second schema)) 1) (check-equal? (length (third schema)) 1) @@ -180,7 +183,21 @@ RKT "macos-host" "Sources" "RivetHost" "GeneratedBackend.swift"))) (define cpp (file->string - (build-path project-root "windows" "GeneratedBackend.hpp"))) + (build-path project-root "windows" "GeneratedBackend.hpp"))) ;; Bounded-wait companions (issue #252): every RPC gains a _within + ;; variant that resolves through a promise, waits up to the timeout, + ;; and cancels the pending request on a miss. Void RPCs return bool; + ;; other results return std::nullopt on timeout. + (check-true + (string-contains? + cpp "std::optional increment_within(std::int64_t value, std::chrono::milliseconds rivet_timeout)")) + (check-true (string-contains? cpp "backend_.cancel(rivet_id); return std::nullopt;")) + (check-true (string-contains? cpp "bool log_line_within(std::string message, std::chrono::milliseconds rivet_timeout)")) + (check-true (string-contains? cpp "backend_.cancel(rivet_id); return false;")) + (check-true (string-contains? cpp "rivet_done->set_value(detail::decode_Int64(*rivet_result.value));")) + ;; A Void async handler must not assign into Result, which has no + ;; value member — the decode only validates arrival. + (check-false (string-contains? cpp "result.value = detail::decode_void")) + (define linux-cpp (file->string (build-path project-root "linux" "GeneratedBackend.hpp"))) @@ -287,7 +304,7 @@ RKT (check-equal? (length (hash-ref baseline 'records)) 1) (check-equal? (map (lambda (entry) (hash-ref entry 'name)) (hash-ref baseline 'rpcs)) - '("echo-user" "greet" "increment")) + '("echo-user" "greet" "increment" "log-line")) ;; Enum support extends snapshot format v1 additively. A baseline written ;; by the pre-Enum v1 tool omitted the key and means an empty enum set. (define pre-enum-baseline-path @@ -315,10 +332,21 @@ RKT (define-rpc (greet [name String] : String) name) (define-rpc (increment [value Int64] : Int64) (add1 value)) (define-rpc (echo-user [user : User] : User) user) +(define-rpc (log-line [message String] : Void) (displayln message)) (define-rpc (health : Bool) #t) (define (start in-fd out-fd) (serve-fds in-fd out-fd)) RKT ) + ;; Zero-argument RPCs must not emit a stray comma before the timeout + ;; (the taskboard's timing-start family broke CI before this pin). + (void (generate-clients! project)) + (define zero-arg-cpp + (file->string (build-path project-root "windows" "GeneratedBackend.hpp"))) + (check-true + (string-contains? + zero-arg-cpp + "std::optional health_within(std::chrono::milliseconds rivet_timeout)")) + (define compatible-report (check-schema-compatibility! project baseline-path)) (check-true (hash-ref compatible-report 'compatible)) @@ -344,6 +372,7 @@ RKT (define-rpc (greet [name String] : Bytes) #"") (define-rpc (increment [value Int64] : Int64) (add1 value)) (define-rpc (echo-user [user : User] : User) user) +(define-rpc (log-line [message String] : Void) (displayln message)) (define (start in-fd out-fd) (serve-fds in-fd out-fd)) RKT ) @@ -372,6 +401,7 @@ RKT (define-rpc (greet [name String] : String) name) (define-rpc (increment [value Int64] : Int64) (add1 value)) (define-rpc (echo-user [user : User] : User) user) +(define-rpc (log-line [message String] : Void) (displayln message)) (define (start in-fd out-fd) (serve-fds in-fd out-fd)) RKT )