From f24f2ec553c581d7e0d2df443b702a789514034f Mon Sep 17 00:00:00 2001 From: Nitesh Purohit Date: Tue, 28 Jul 2026 21:54:24 -0400 Subject: [PATCH] feat: add UCRT runtime support - add native socket, lifecycle, and multi-worker implementations - validate Ruby 3.2, 3.4, and 4.0 on Windows - document UCRT support and reject MSVC Ruby Closes: #326 Closes: #339 Closes: #352 --- .github/copilot-instructions.md | 18 +- .github/instructions/review.instructions.md | 230 +-- .../instructions/vajra-rbs.instructions.md | 6 +- .github/instructions/vajra.instructions.md | 13 +- .github/pull_request_template.md | 17 +- .github/workflows/shared-ci.yml | 163 +- CODE_OF_CONDUCT.md | 97 +- CONTRIBUTING.md | 27 +- README.md | 35 +- SECURITY.md | 13 +- docs/index.md | 32 +- docs/pages/02-installation.md | 22 +- docs/pages/03-configuration.md | 78 +- docs/pages/04-architecture.md | 27 +- docs/pages/04-architecture/01-request-path.md | 37 +- .../pages/04-architecture/02-runtime-model.md | 27 +- .../pages/04-architecture/03-failure-modes.md | 29 +- docs/pages/04-architecture/03-native-input.md | 3 +- docs/pages/04-architecture/04-protocols.md | 51 +- .../05-http2-stream-tunnels.md | 27 +- docs/pages/04-architecture/05-rack-hijack.md | 21 +- .../04-architecture/06-shutdown-drain.md | 19 +- docs/pages/04-command-reference.md | 54 +- docs/pages/05-frameworks.md | 32 +- docs/pages/05-guides.md | 21 + .../01-observability.md} | 120 +- .../02-rack-compatibility.md} | 34 +- .../03-production.md} | 59 +- .../04-security.md} | 72 +- .../05-performance.md} | 72 +- .../06-migration.md} | 54 +- .../07-upgrading.md} | 27 +- docs/pages/05-guides/08-compatibility.md | 86 + .../09-troubleshooting.md} | 109 +- .../10-development.md} | 15 +- docs/pages/11-api-reference.md | 144 +- docs/pages/13-compatibility.md | 90 - docs/pages/14-glossary.md | 49 - docs/pages/14-support.md | 19 + gems/vajra/Gemfile | 2 + gems/vajra/Gemfile.lock | 16 +- gems/vajra/README.md | 24 +- gems/vajra/bin/ctest | 13 +- gems/vajra/ext/vajra/extconf.rb | 46 +- .../vajra/lifecycle/lifecycle_controller.cpp | 38 +- .../vajra/lifecycle/lifecycle_controller.hpp | 9 +- .../ext/vajra/listener/listener_socket.cpp | 69 +- .../ext/vajra/listener/listener_socket.hpp | 4 +- gems/vajra/ext/vajra/platform/process.cpp | 54 + gems/vajra/ext/vajra/platform/process.hpp | 43 + gems/vajra/ext/vajra/platform/socket.cpp | 545 ++++++ gems/vajra/ext/vajra/platform/socket.hpp | 86 + gems/vajra/ext/vajra/rack/http2_stream.cpp | 6 +- gems/vajra/ext/vajra/rack/native_input.cpp | 48 +- .../ext/vajra/rack/rack_request_executor.cpp | 2 +- .../ext/vajra/rack/rack_request_executor.hpp | 9 +- .../ext/vajra/rack/ruby_execution_bridge.cpp | 215 ++- .../ext/vajra/rack/ruby_execution_bridge.hpp | 5 +- .../ext/vajra/rack/ruby_rack_transport.cpp | 32 +- .../ext/vajra/rack/ruby_rack_transport.hpp | 4 +- .../vajra/ext/vajra/request/http2_session.cpp | 56 +- .../ext/vajra/request/request_body_reader.cpp | 7 +- .../ext/vajra/request/request_body_reader.hpp | 4 +- .../ext/vajra/request/request_context.hpp | 3 +- .../ext/vajra/request/request_head_parser.hpp | 6 +- .../ext/vajra/request/request_head_reader.cpp | 2 +- .../ext/vajra/request/request_head_reader.hpp | 2 +- .../ext/vajra/request/request_processor.cpp | 60 +- .../ext/vajra/request/request_processor.hpp | 9 +- .../ext/vajra/response/response_writer.cpp | 18 +- .../ext/vajra/response/response_writer.hpp | 6 +- .../ext/vajra/runtime/native_runtime.cpp | 32 +- .../ext/vajra/runtime/native_runtime.hpp | 16 +- .../vajra/runtime/native_runtime_windows.cpp | 515 +++++ .../ext/vajra/runtime/runtime_logging.cpp | 234 ++- .../ext/vajra/runtime/runtime_logging.hpp | 14 +- .../vajra/ext/vajra/runtime/runtime_state.cpp | 150 +- .../vajra/ext/vajra/runtime/runtime_state.hpp | 16 +- gems/vajra/ext/vajra/runtime/time_utils.cpp | 4 + .../vajra/runtime/windows_worker_backend.cpp | 1655 +++++++++++++++++ .../vajra/runtime/windows_worker_backend.hpp | 46 + gems/vajra/ext/vajra/runtime/worker_pool.hpp | 6 +- gems/vajra/ext/vajra/server.cpp | 409 ++-- gems/vajra/ext/vajra/server.hpp | 32 +- gems/vajra/ext/vajra/transport/connection.cpp | 40 +- gems/vajra/ext/vajra/transport/connection.hpp | 18 +- .../ext/vajra/transport/tls_connection.cpp | 188 +- .../ext/vajra/transport/tls_connection.hpp | 23 +- .../ext/vajra/vendor/nghttp2/UPSTREAM.md | 5 +- gems/vajra/lib/vajra.rb | 99 +- .../lib/vajra/internal/rack_execution.rb | 13 + gems/vajra/lib/vajra/internal/tracing.rb | 23 +- gems/vajra/performance/Gemfile.lock | 14 +- gems/vajra/performance/README.md | 31 +- gems/vajra/performance/Rakefile | 88 +- gems/vajra/sig/vajra.rbs | 8 +- gems/vajra/sig/vajra/cli.rbs | 10 +- .../sig/vajra/internal/rack_execution.rbs | 28 +- gems/vajra/sig/vajra/internal/tracing.rbs | 10 +- gems/vajra/sig/vajra/rails.rbs | 10 +- gems/vajra/spec/cpp/CMakeLists.txt | 18 +- gems/vajra/spec/cpp/platform_socket_test.cpp | 58 + gems/vajra/spec/cpp/rack_env_test.cpp | 16 +- gems/vajra/spec/cpp/request_head_test.cpp | 13 +- gems/vajra/spec/cpp/response_test.cpp | 305 ++- .../spec/cpp/ruby_rack_transport_stub.cpp | 8 +- gems/vajra/spec/cpp/runtime_logging_test.cpp | 17 + gems/vajra/spec/cpp/server_lifecycle_test.cpp | 12 +- gems/vajra/spec/cpp/server_test.cpp | 1 + gems/vajra/spec/cpp/test_suites.hpp | 1 + gems/vajra/spec/cpp/test_support.cpp | 208 ++- gems/vajra/spec/cpp/test_support.hpp | 31 +- gems/vajra/spec/e2e/spec_helper.rb | 86 +- .../spec/e2e/vajra/configuration_spec.rb | 43 +- .../spec/e2e/vajra/h2c_integration_spec.rb | 5 +- .../e2e/vajra/rack_hijack_integration_spec.rb | 8 +- .../spec/e2e/vajra/support/http_helpers.rb | 4 +- .../spec/e2e/vajra/support/process_helpers.rb | 59 +- .../spec/e2e/vajra/support/startup_helpers.rb | 14 +- .../e2e/vajra/vajra_worker_resilience_spec.rb | 236 +++ gems/vajra/spec/spec_helper.rb | 14 +- .../spec/support/documented_server_options.rb | 4 +- .../vajra/internal/rack_execution_spec.rb | 10 + .../vajra/spec/vajra/internal/tracing_spec.rb | 43 +- .../vajra/spec/vajra/native_extension_spec.rb | 234 ++- gems/vajra/vajra.gemspec | 5 +- 126 files changed, 6420 insertions(+), 2262 deletions(-) create mode 100644 docs/pages/05-guides.md rename docs/pages/{05-observability.md => 05-guides/01-observability.md} (71%) rename docs/pages/{06-rack-compatibility.md => 05-guides/02-rack-compatibility.md} (52%) rename docs/pages/{07-production.md => 05-guides/03-production.md} (73%) rename docs/pages/{09-security.md => 05-guides/04-security.md} (59%) rename docs/pages/{08-performance.md => 05-guides/05-performance.md} (63%) rename docs/pages/{10-migration.md => 05-guides/06-migration.md} (61%) rename docs/pages/{12-upgrading.md => 05-guides/07-upgrading.md} (74%) create mode 100644 docs/pages/05-guides/08-compatibility.md rename docs/pages/{06-troubleshooting.md => 05-guides/09-troubleshooting.md} (69%) rename docs/pages/{05-development.md => 05-guides/10-development.md} (88%) delete mode 100644 docs/pages/13-compatibility.md delete mode 100644 docs/pages/14-glossary.md create mode 100644 docs/pages/14-support.md create mode 100644 gems/vajra/ext/vajra/platform/process.cpp create mode 100644 gems/vajra/ext/vajra/platform/process.hpp create mode 100644 gems/vajra/ext/vajra/platform/socket.cpp create mode 100644 gems/vajra/ext/vajra/platform/socket.hpp create mode 100644 gems/vajra/ext/vajra/runtime/native_runtime_windows.cpp create mode 100644 gems/vajra/ext/vajra/runtime/windows_worker_backend.cpp create mode 100644 gems/vajra/ext/vajra/runtime/windows_worker_backend.hpp create mode 100644 gems/vajra/spec/cpp/platform_socket_test.cpp create mode 100644 gems/vajra/spec/e2e/vajra/vajra_worker_resilience_spec.rb diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 01dd403..1560a7b 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -1,21 +1,14 @@ # Vajra Copilot Instructions -For implementation work in this repository, follow these rules first. Use -`.github/instructions/review.instructions.md` as supplemental review guidance, -not as the primary coding file. +For implementation work in this repository, follow these rules first. Use `.github/instructions/review.instructions.md` as supplemental review guidance, not as the primary coding file. -Detailed rules live in path-specific files under `.github/instructions/`. -Prefer repository instruction files over client-specific config. Use client -config only when a tool is known not to load repository instruction files -reliably. +Detailed rules live in path-specific files under `.github/instructions/`. Prefer repository instruction files over client-specific config. Use client config only when a tool is known not to load repository instruction files reliably. ## Placement -- `gems/vajra` owns the canonical Ruby package, executable, and native - extension bridge. +- `gems/vajra` owns the canonical Ruby package, executable, and native extension bridge. - Keep native sources under `gems/vajra/ext/vajra/`. -- Put signatures in the mirrored `gems/vajra/sig/` path for every changed Ruby - surface. +- Put signatures in the mirrored `gems/vajra/sig/` path for every changed Ruby surface. ## Structure @@ -27,8 +20,7 @@ reliably. ## Implementation -- Trace concrete state changes for time, retries, leases, shutdown, and shared - state. +- Trace concrete state changes for time, retries, leases, shutdown, and shared state. - Validate raw input before normalization when order matters. - Keep types consistent from input to validation to runtime use. - Do not symbolize or cache unbounded input. diff --git a/.github/instructions/review.instructions.md b/.github/instructions/review.instructions.md index bb382fc..df64f96 100644 --- a/.github/instructions/review.instructions.md +++ b/.github/instructions/review.instructions.md @@ -1,38 +1,27 @@ # PR Review Instructions -Use these instructions when reviewing pull requests in this repository. Favor -high-signal semantic review over generic style feedback. +Use these instructions when reviewing pull requests in this repository. Favor high-signal semantic review over generic style feedback. ## Scope Of This File - This file is for review work, not primary implementation guidance. - For coding instructions, prefer: - `.github/copilot-instructions.md` -- When reviewing, use those files as context for intended repository shape, but - keep this file as the source of truth for review posture and review checks. +- When reviewing, use those files as context for intended repository shape, but keep this file as the source of truth for review posture and review checks. ## Documentation Contract -- Treat `docs/` and project-authored `README.md` files as current product and - package documentation. -- Verify commands, paths, defaults, APIs, payloads, and operational procedures - against the implementation, tests, configuration, and workflows. -- Flag unimplemented or stale claims. Put proposals in an explicitly marked - design document outside the production documentation path. -- Review contradictions, broken links, support boundaries, prerequisites, - failure behavior, and package ownership with the same priority as code - contracts. +- Treat `docs/` and project-authored `README.md` files as current product and package documentation. +- Verify commands, paths, defaults, APIs, payloads, and operational procedures against the implementation, tests, configuration, and workflows. +- Flag unimplemented or stale claims. Put proposals in an explicitly marked design document outside the production documentation path. +- Review contradictions, broken links, support boundaries, prerequisites, failure behavior, and package ownership with the same priority as code contracts. ## Repo-Specific Review Supplement - This repository has one canonical gem package under `gems/vajra`. -- When runtime or packaging behavior changes, verify the package docs, repo - docs, scripts, and workflows still refer to the same commands and paths. -- Keep native-extension ownership explicit: source under `ext/vajra/`, Ruby - entrypoints under `lib/`, signatures under `sig/`, and direct specs under - `spec/`. -- When reviewing refactors, prefer responsibility-based extraction over - arbitrary file splitting. +- When runtime or packaging behavior changes, verify the package docs, repo docs, scripts, and workflows still refer to the same commands and paths. +- Keep native-extension ownership explicit: source under `ext/vajra/`, Ruby entrypoints under `lib/`, signatures under `sig/`, and direct specs under `spec/`. +- When reviewing refactors, prefer responsibility-based extraction over arbitrary file splitting. ## Required Review Checks @@ -40,13 +29,8 @@ high-signal semantic review over generic style feedback. - Run performance and security review checks. - Run semantic and logic-error review checks. - Run spelling and grammar checks. -- Do not spend review effort on automated linter feedback since those tools run - in CI. Do review code clarity, logic simplification, error message quality, - and test coverage completeness. -- **Trace concretely, don't reason abstractly:** For each code path, identify - the exact line numbers where state can change, where execution can block, or - where types are coerced. Abstract reasoning like "this seems safe" is - insufficient. +- Do not spend review effort on automated linter feedback since those tools run in CI. Do review code clarity, logic simplification, error message quality, and test coverage completeness. +- **Trace concretely, don't reason abstractly:** For each code path, identify the exact line numbers where state can change, where execution can block, or where types are coerced. Abstract reasoning like "this seems safe" is insufficient. ## Concrete Trace Analysis Rules @@ -93,8 +77,7 @@ For any error-handling or retry block: - Signal suppression (catching interrupts) - Converting fatal errors to warnings - Swallowing exceptions that should propagate -- **Required:** Verify error handling doesn't make the system unstoppable or - hide bugs +- **Required:** Verify error handling doesn't make the system unstoppable or hide bugs ### Input Type Flow Tracing @@ -105,11 +88,9 @@ For any user input (CLI, API, config): 1. What type does the interface accept? (string, numeric, boolean) 2. What type does validation expect? 3. What type does internal code require? -- Look for mismatches where accepted types do not match validated or consumed - types +- Look for mismatches where accepted types do not match validated or consumed types - **Required:** Document type at each layer, flag any coercion gaps -- For Ruby changes with RBS, also trace whether the mirrored signature reflects - the same accepted and returned types. +- For Ruby changes with RBS, also trace whether the mirrored signature reflects the same accepted and returned types. ### Validation Order vs Normalization @@ -131,8 +112,7 @@ For any input validation: value = input || default # Normalize AFTER ``` -- **Required:** Verify validation sees actual input, not preprocessed/normalized - values +- **Required:** Verify validation sees actual input, not preprocessed/normalized values ### Documentation Reality Gaps @@ -157,29 +137,21 @@ For any public constants/methods: - Don't just check "are NEW things marked private?" - Scan ALL constants/methods in the file - Check: Is visibility CONSISTENT across the file? -- Look for pattern violations where some internal constants are marked private - but others with similar scope are left public +- Look for pattern violations where some internal constants are marked private but others with similar scope are left public - **Required:** Verify ALL implementation details are consistently scoped -- When a refactor extracts support modules or support classes, verify those new - objects are not accidentally exposed as public extension points. -- When an owner is split across responsibility-named Ruby files, check whether - direct unit coverage mirrors that split and whether large owner specs still - focus on public behavior instead of helper mechanics. +- When a refactor extracts support modules or support classes, verify those new objects are not accidentally exposed as public extension points. +- When an owner is split across responsibility-named Ruby files, check whether direct unit coverage mirrors that split and whether large owner specs still focus on public behavior instead of helper mechanics. ### RBS Truthfulness For any Ruby change that has a mirrored file under `sig/`: - Check whether the RBS changed with the Ruby code. -- Verify ownership matches reality after refactors. If a method moved from a - class to a support module, the signature should move too. -- Verify visibility, argument names, optionality, return types, and nested - module/class structure all still match runtime behavior. +- Verify ownership matches reality after refactors. If a method moved from a class to a support module, the signature should move too. +- Verify visibility, argument names, optionality, return types, and nested module/class structure all still match runtime behavior. - Flag stale signatures for removed methods, removed modules, or old constants. - **Required:** Treat RBS drift as a correctness issue, not documentation debt. -- **Required:** Treat spec-layout drift as a review concern when extracted - owner-local files leave all direct behavior buried only in a monolithic owner - spec. +- **Required:** Treat spec-layout drift as a review concern when extracted owner-local files leave all direct behavior buried only in a monolithic owner spec. ## Architecture Guidelines Review @@ -187,132 +159,83 @@ These checks enforce architecture guidelines. Apply them to every code change. ### Design Principles -- **Composition over inheritance:** Flag deep inheritance hierarchies. Prefer - modules/mixins and collaborator injection over long class chains. -- **Single Responsibility Principle:** Flag classes that mix responsibilities - (logic + IO + orchestration). Each class should own one clear job. -- **Dependency injection:** Flag code that reaches for globals or class-level - state when a collaborator could be passed in. Look for singleton access in - non-entrypoint code. -- **Explicit control flow:** Flag hidden execution paths, excessive - meta-programming, or dynamic dispatch that obscures behavior. Prefer explicit - code over clever abstractions. +- **Composition over inheritance:** Flag deep inheritance hierarchies. Prefer modules/mixins and collaborator injection over long class chains. +- **Single Responsibility Principle:** Flag classes that mix responsibilities (logic + IO + orchestration). Each class should own one clear job. +- **Dependency injection:** Flag code that reaches for globals or class-level state when a collaborator could be passed in. Look for singleton access in non-entrypoint code. +- **Explicit control flow:** Flag hidden execution paths, excessive meta-programming, or dynamic dispatch that obscures behavior. Prefer explicit code over clever abstractions. ### Public/Internal Boundary -- **Namespace policy:** Verify that internal support classes are not accidentally - exposed as public API. Check for consistent visibility scoping. -- **Minimal public surface:** Flag new public methods or classes that could be - internal. Only entrypoints, error classes, and documented extension points - should be public. -- **No leaking internals:** Flag cases where internal normalizers, validators, - or support objects are reachable from outside their owning module without - explicit intent. -- **Support module extraction:** When a large class is split, verify the new - modules are named after concrete responsibilities, not generic “helpers”. +- **Namespace policy:** Verify that internal support classes are not accidentally exposed as public API. Check for consistent visibility scoping. +- **Minimal public surface:** Flag new public methods or classes that could be internal. Only entrypoints, error classes, and documented extension points should be public. +- **No leaking internals:** Flag cases where internal normalizers, validators, or support objects are reachable from outside their owning module without explicit intent. +- **Support module extraction:** When a large class is split, verify the new modules are named after concrete responsibilities, not generic “helpers”. ### Configuration and Boot Policy -- **Explicit configuration:** Flag implicit environment dependencies in - non-CLI code. Configuration should flow through explicit objects, not ambient - state. -- **Fail-fast validation:** Flag code that allows partially invalid - configuration to proceed. Validate early, fail with actionable errors. -- **Separation of parsing and policy:** Flag CLI or controller code that mixes - input parsing with runtime configuration assembly. These are separate - responsibilities. +- **Explicit configuration:** Flag implicit environment dependencies in non-CLI code. Configuration should flow through explicit objects, not ambient state. +- **Fail-fast validation:** Flag code that allows partially invalid configuration to proceed. Validate early, fail with actionable errors. +- **Separation of parsing and policy:** Flag CLI or controller code that mixes input parsing with runtime configuration assembly. These are separate responsibilities. ### Concurrency and State -- **Thread-safety by default:** Flag shared mutable state without - synchronization. Assume multi-threaded execution. -- **No global mutable state:** Flag module-level or class-level mutable state - unless explicitly documented with concurrency guarantees. -- **Explicit resource management:** Flag code that opens resources (threads, - connections, files) without clear cleanup paths. -- **Timeouts on blocking operations:** Flag indefinite blocking without timeout - or cancellation mechanisms. -- **Documented concurrency contract:** Flag runtime components that use - processes, threads, or shared state without documenting safety guarantees. +- **Thread-safety by default:** Flag shared mutable state without synchronization. Assume multi-threaded execution. +- **No global mutable state:** Flag module-level or class-level mutable state unless explicitly documented with concurrency guarantees. +- **Explicit resource management:** Flag code that opens resources (threads, connections, files) without clear cleanup paths. +- **Timeouts on blocking operations:** Flag indefinite blocking without timeout or cancellation mechanisms. +- **Documented concurrency contract:** Flag runtime components that use processes, threads, or shared state without documenting safety guarantees. ### Extensibility and Strategy -- **Duck-typed extension points:** Flag extension mechanisms that require - inheritance. Prefer duck typing and registration. -- **Strategy pattern consistency:** When pluggable behavior exists, verify all - implementations satisfy the same contract. -- **No hardcoded behavior:** Flag behavior that should be replaceable but is - baked into a specific implementation. +- **Duck-typed extension points:** Flag extension mechanisms that require inheritance. Prefer duck typing and registration. +- **Strategy pattern consistency:** When pluggable behavior exists, verify all implementations satisfy the same contract. +- **No hardcoded behavior:** Flag behavior that should be replaceable but is baked into a specific implementation. ### Observability -- **Pluggable logger interface:** Flag hardcoded logging output or - framework-coupled logging. Logger should be injectable. -- **Instrumentation hooks:** Flag significant runtime events that lack - instrumentation surface. +- **Pluggable logger interface:** Flag hardcoded logging output or framework-coupled logging. Logger should be injectable. +- **Instrumentation hooks:** Flag significant runtime events that lack instrumentation surface. - **Structured, minimal logging:** Flag noisy or unstructured log output. ### Error Handling -- **Namespaced errors:** Flag generic error raises. Use project-specific error - hierarchies. -- **Actionable messages:** Flag error messages that do not help the developer - diagnose and fix the problem. -- **No silent swallowing:** Flag bare rescue/catch without re-raise or explicit - handling. Flag nil/null returns for error conditions. -- **Fail fast:** Flag code that continues in an invalid state instead of - raising. +- **Namespaced errors:** Flag generic error raises. Use project-specific error hierarchies. +- **Actionable messages:** Flag error messages that do not help the developer diagnose and fix the problem. +- **No silent swallowing:** Flag bare rescue/catch without re-raise or explicit handling. Flag nil/null returns for error conditions. +- **Fail fast:** Flag code that continues in an invalid state instead of raising. ### Load-Time Behavior -- **Side-effect-free requires/imports:** Flag code that starts threads, opens - connections, or mutates global state during module loading. -- **Explicit initialization:** Runtime startup should happen through explicit - entrypoints, not as a side effect of loading. +- **Side-effect-free requires/imports:** Flag code that starts threads, opens connections, or mutates global state during module loading. +- **Explicit initialization:** Runtime startup should happen through explicit entrypoints, not as a side effect of loading. ### DRY and Maintainability -- **Abstract only after real duplication:** Flag premature abstractions or - indirection without demonstrated need. Two similar-looking blocks are not - duplication if they serve different responsibilities. -- **Readability over cleverness:** Flag clever metaprogramming, DSL-building, - or abstraction that sacrifices readability. -- **No dumping grounds:** Flag `utils/`, `helpers/`, or similarly named modules - that collect unrelated behavior. +- **Abstract only after real duplication:** Flag premature abstractions or indirection without demonstrated need. Two similar-looking blocks are not duplication if they serve different responsibilities. +- **Readability over cleverness:** Flag clever metaprogramming, DSL-building, or abstraction that sacrifices readability. +- **No dumping grounds:** Flag `utils/`, `helpers/`, or similarly named modules that collect unrelated behavior. ### Performance and Resources -- **Explicit resource usage:** Flag hidden expensive operations (thread - creation, large allocations) behind simple-looking method calls. -- **Clean resource release:** Flag resources that lack cleanup paths (threads, - sockets, file handles). -- **No unnecessary allocations:** Flag allocation-heavy patterns in hot paths - (e.g., interning unbounded user input, repeated object creation in loops). +- **Explicit resource usage:** Flag hidden expensive operations (thread creation, large allocations) behind simple-looking method calls. +- **Clean resource release:** Flag resources that lack cleanup paths (threads, sockets, file handles). +- **No unnecessary allocations:** Flag allocation-heavy patterns in hot paths (e.g., interning unbounded user input, repeated object creation in loops). ## Review Priorities - Check internal consistency across `README.md` files and `docs/`. - Check that documentation across different packages or components agrees. - Check that optional versus first-class behavior is described clearly. -- Check for contradictions, unsupported assumptions, broken references, and weak - review reasoning. -- **Execution trace verification:** For shutdown, cleanup, and error paths, - trace line-by-line to verify code is reachable under failure conditions. -- **State mutation points:** Identify every line where external state (signals, - time, concurrency) can change, and verify checks are still valid at action. -- **Type consistency end-to-end:** Trace types from user input through all - layers to final use, flag any implicit coercion or mismatch. -- **Architecture alignment:** Verify changes follow composition over - inheritance, dependency injection, explicit control flow, and public/internal - boundary rules. +- Check for contradictions, unsupported assumptions, broken references, and weak review reasoning. +- **Execution trace verification:** For shutdown, cleanup, and error paths, trace line-by-line to verify code is reachable under failure conditions. +- **State mutation points:** Identify every line where external state (signals, time, concurrency) can change, and verify checks are still valid at action. +- **Type consistency end-to-end:** Trace types from user input through all layers to final use, flag any implicit coercion or mismatch. +- **Architecture alignment:** Verify changes follow composition over inheritance, dependency injection, explicit control flow, and public/internal boundary rules. ## Code Review Rules -- Treat changes under library source directories as package contract work unless - the code is clearly marked private or internal. -- Treat changes in adapter or integration packages as cross-package contract - work. Review for drift against the canonical runtime vocabulary and public - guidance in docs and package READMEs. +- Treat changes under library source directories as package contract work unless the code is clearly marked private or internal. +- Treat changes in adapter or integration packages as cross-package contract work. Review for drift against the canonical runtime vocabulary and public guidance in docs and package READMEs. - Flag accidental API expansion. In particular: - helper methods exposed as public unintentionally - helper constants or modules that become externally visible without intent @@ -334,21 +257,15 @@ These checks enforce architecture guidelines. Apply them to every code change. - vocabulary drifting between core packages, integration layers, and docs - adapter packages assuming behavior not guaranteed by the core - entrypoints exposing behavior inconsistent with package README guidance -- Prefer comments about behavior, API visibility, invariants, and memory - characteristics over comments about formatting. +- Prefer comments about behavior, API visibility, invariants, and memory characteristics over comments about formatting. ## Docs And Example Review Rules - Verify local documentation links and navigation. -- Verify code snippets match the repository's supported language version and - public APIs. -- Do not report valid modern language syntax as an error when the project's - language version supports it. Prefer framing such feedback as readability - guidance, not correctness guidance. -- Verify the docs site still builds successfully after changes when doc changes - are material. -- For `docs/`, review implementation parity, terminology, navigation, and - support-boundary clarity. +- Verify code snippets match the repository's supported language version and public APIs. +- Do not report valid modern language syntax as an error when the project's language version supports it. Prefer framing such feedback as readability guidance, not correctness guidance. +- Verify the docs site still builds successfully after changes when doc changes are material. +- For `docs/`, review implementation parity, terminology, navigation, and support-boundary clarity. ## Good Review Targets @@ -370,8 +287,7 @@ These checks enforce architecture guidelines. Apply them to every code change. - error handling side effects (rescue/retry creates new problems) - type flow mismatches (interface accepts X, validation expects Y, code needs Z) - validation-after-normalization (validates default instead of input) -- non-runnable documentation examples (prerequisites missing, incompatible - options) +- non-runnable documentation examples (prerequisites missing, incompatible options) - inconsistent visibility scoping (some internal constants public) - deep inheritance hierarchies instead of composition - God objects mixing logic, IO, and orchestration @@ -390,11 +306,7 @@ These checks enforce architecture guidelines. Apply them to every code change. - formatting issues already covered by automated linting in CI - speculative product objections when the change is internally consistent - proposals clearly isolated from production documentation and labeled as such -- suggesting Service/Command pattern where a constructor + method is already - clear and explicit -- requesting namespace renames purely for convention when existing names are - unambiguous -- flagging file count or line count without identifying a specific - responsibility violation -- demanding RBS churn when runtime behavior did not change and the existing - signature is already true +- suggesting Service/Command pattern where a constructor + method is already clear and explicit +- requesting namespace renames purely for convention when existing names are unambiguous +- flagging file count or line count without identifying a specific responsibility violation +- demanding RBS churn when runtime behavior did not change and the existing signature is already true diff --git a/.github/instructions/vajra-rbs.instructions.md b/.github/instructions/vajra-rbs.instructions.md index 6a80dbb..99cf2ed 100644 --- a/.github/instructions/vajra-rbs.instructions.md +++ b/.github/instructions/vajra-rbs.instructions.md @@ -5,8 +5,6 @@ applyTo: "gems/vajra/sig/**/*.rbs" # Vajra RBS Instructions - RBS must stay true to the Ruby implementation it mirrors. -- Keep signatures aligned with ownership, visibility, argument names, - optionality, and return types. +- Keep signatures aligned with ownership, visibility, argument names, optionality, and return types. - Remove stale signatures when methods or modules move or disappear. -- When Ruby changes under `gems/vajra/lib`, check the mirrored path under - `gems/vajra/sig` in the same change. +- When Ruby changes under `gems/vajra/lib`, check the mirrored path under `gems/vajra/sig` in the same change. diff --git a/.github/instructions/vajra.instructions.md b/.github/instructions/vajra.instructions.md index ee1d607..f3a60a5 100644 --- a/.github/instructions/vajra.instructions.md +++ b/.github/instructions/vajra.instructions.md @@ -5,13 +5,8 @@ applyTo: "gems/vajra/lib/**/*.rb,gems/vajra/spec/**/*.rb,gems/vajra/exe/**/*,gem # Vajra Package Instructions - `gems/vajra` is the canonical runtime package for this repository. -- Keep native-extension ownership explicit: Ruby entrypoints in `lib/`, native - sources in `ext/vajra/`, signatures in `sig/`, and mirrored direct specs in - `spec/`. +- Keep native-extension ownership explicit: Ruby entrypoints in `lib/`, native sources in `ext/vajra/`, signatures in `sig/`, and mirrored direct specs in `spec/`. - Prefer responsibility-named files over generic helpers or utils. -- Keep package-local commands authoritative. Run validation from `gems/vajra`, - not from the repo root with ad hoc paths. -- Use actionable error messages when boot, build, or native-load behavior - fails. -- Update package docs when commands, layout, or supported development flows - change. +- Keep package-local commands authoritative. Run validation from `gems/vajra`, not from the repo root with ad hoc paths. +- Use actionable error messages when boot, build, or native-load behavior fails. +- Update package docs when commands, layout, or supported development flows change. diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index b02d2bd..b5d94bc 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -2,18 +2,11 @@ The title of the GitHub must follow the following format: : Briefly describe the changes made in this pull request. -Supported title prefixes: -feat:, bugfix:, docs:, release:, chore:, refactor:, test:, style:, ci:, perf:, build: - -For example: -feat: Add a new feature to the project -bugfix: Fix bug in the project -docs: Update documentation -ci: Update GitHub Actions workflow -build: Update package build configuration - -A CLA is required for this pull request. Please read and sign the CLA at https://cla.developers.codevedas.com ---> +Supported title prefixes: feat:, bugfix:, docs:, release:, chore:, refactor:, test:, style:, ci:, perf:, build: + +For example: feat: Add a new feature to the project bugfix: Fix bug in the project docs: Update documentation ci: Update GitHub Actions workflow build: Update package build configuration + +A CLA is required for this pull request. Please read and sign the CLA at https://cla.developers.codevedas.com --> # Pull Request diff --git a/.github/workflows/shared-ci.yml b/.github/workflows/shared-ci.yml index bdbd5fd..3f113af 100644 --- a/.github/workflows/shared-ci.yml +++ b/.github/workflows/shared-ci.yml @@ -25,17 +25,10 @@ on: jobs: rubocop: - name: RuboCop · Ruby ${{ matrix.ruby_version }} + name: RuboCop · Ruby 3.4 permissions: contents: read runs-on: ubuntu-latest - strategy: - fail-fast: false - matrix: - ruby_version: - - "3.2" - - "3.4" - - "4.0" steps: - uses: actions/checkout@v7 - name: Install apt packages @@ -46,34 +39,27 @@ jobs: - name: Set up Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: ${{ matrix.ruby_version }} + ruby-version: "3.4" bundler-cache: false - name: Restore bundle cache uses: actions/cache@v6 with: path: ${{ env.BUNDLE_PATH }} - key: ${{ runner.os }}-ruby-${{ matrix.ruby_version }}-bundle-${{ hashFiles('gems/vajra/Gemfile.lock', 'docs/Gemfile.lock', 'danger/Gemfile.lock') }} + key: ${{ runner.os }}-ruby-3.4-bundle-${{ hashFiles('gems/vajra/Gemfile.lock', 'docs/Gemfile.lock', 'danger/Gemfile.lock') }} restore-keys: | - ${{ runner.os }}-ruby-${{ matrix.ruby_version }}-bundle- + ${{ runner.os }}-ruby-3.4-bundle- - name: Install bundles run: scripts/ci-install-bundles env: - CI_RUBY_VERSION: ${{ matrix.ruby_version }} + CI_RUBY_VERSION: "3.4" - name: Run RuboCop run: scripts/run-rubocop-all reek: - name: Reek and RBS · Ruby ${{ matrix.ruby_version }} + name: Reek and RBS · Ruby 3.4 permissions: contents: read runs-on: ubuntu-latest - strategy: - fail-fast: false - matrix: - ruby_version: - - "3.2" - - "3.4" - - "4.0" steps: - uses: actions/checkout@v7 - name: Install apt packages @@ -84,19 +70,19 @@ jobs: - name: Set up Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: ${{ matrix.ruby_version }} + ruby-version: "3.4" bundler-cache: false - name: Restore bundle cache uses: actions/cache@v6 with: path: ${{ env.BUNDLE_PATH }} - key: ${{ runner.os }}-ruby-${{ matrix.ruby_version }}-bundle-${{ hashFiles('gems/vajra/Gemfile.lock', 'docs/Gemfile.lock', 'danger/Gemfile.lock') }} + key: ${{ runner.os }}-ruby-3.4-bundle-${{ hashFiles('gems/vajra/Gemfile.lock', 'docs/Gemfile.lock', 'danger/Gemfile.lock') }} restore-keys: | - ${{ runner.os }}-ruby-${{ matrix.ruby_version }}-bundle- + ${{ runner.os }}-ruby-3.4-bundle- - name: Install bundles run: scripts/ci-install-bundles env: - CI_RUBY_VERSION: ${{ matrix.ruby_version }} + CI_RUBY_VERSION: "3.4" - name: Run Reek run: scripts/run-reek-all - name: Run C++ lint @@ -105,30 +91,59 @@ jobs: run: scripts/run-rbs-all test: - name: Unit Tests · Ruby ${{ matrix.ruby_version }} + name: Unit Tests · ${{ matrix.os }} · Ruby ${{ matrix.ruby_version }} permissions: contents: read - runs-on: ubuntu-latest + runs-on: ${{ matrix.os }} needs: [rubocop, reek] strategy: fail-fast: false matrix: - ruby_version: - - "3.2" - - "3.4" - - "4.0" + include: + - os: ubuntu-latest + ruby_version: "3.2" + - os: ubuntu-latest + ruby_version: "3.4" + - os: ubuntu-latest + ruby_version: "4.0" + - os: macos-15 + ruby_version: "3.2" + - os: macos-15 + ruby_version: "3.4" + - os: macos-15 + ruby_version: "4.0" + - os: windows-2022 + ruby_version: "3.2" + - os: windows-2022 + ruby_version: "3.4" + - os: windows-2022 + ruby_version: "4.0" steps: - uses: actions/checkout@v7 - name: Install apt packages + if: runner.os == 'Linux' run: | sudo apt-get update sudo apt-get install --no-install-recommends -y \ build-essential cmake git libyaml-dev pkg-config + - name: Install macOS packages + if: runner.os == 'macOS' + run: brew install cmake libyaml openssl@3 pkg-config - name: Set up Ruby uses: ruby/setup-ruby@v1 with: ruby-version: ${{ matrix.ruby_version }} bundler-cache: false + - name: Install Windows native dependencies + if: runner.os == 'Windows' + shell: cmd + run: >- + ridk exec pacman --noconfirm --needed -S + mingw-w64-ucrt-x86_64-openssl + mingw-w64-ucrt-x86_64-libyaml + mingw-w64-ucrt-x86_64-pkgconf + mingw-w64-ucrt-x86_64-toolchain + make - name: Restore bundle cache uses: actions/cache@v6 with: @@ -137,13 +152,29 @@ jobs: restore-keys: | ${{ runner.os }}-ruby-${{ matrix.ruby_version }}-bundle- - name: Install bundles + if: runner.os == 'Linux' run: scripts/ci-install-bundles env: CI_RUBY_VERSION: ${{ matrix.ruby_version }} + - name: Install macOS bundle + if: runner.os == 'macOS' + working-directory: gems/vajra + run: bundle install --jobs 4 --retry 3 + - name: Install Windows bundle and compile + if: runner.os == 'Windows' + working-directory: gems/vajra + env: + VAJRA_TEST_FAULT_INJECTION: "1" + run: | + bundle install --jobs 4 --retry 3 + ridk exec bundle exec rake clobber compile - name: Run unit tests + shell: bash + env: + VAJRA_TEST_FAULT_INJECTION: "1" run: scripts/run-rspec-unit-all - name: Publish coverage to Qlty - if: inputs.upload_coverage && matrix.ruby_version == '3.4' + if: inputs.upload_coverage && runner.os == 'Linux' && matrix.ruby_version == '3.4' uses: qltysh/qlty-action/coverage@v2 with: token: ${{ secrets.QLTY_COVERAGE_TOKEN }} @@ -151,26 +182,46 @@ jobs: ${{ github.workspace }}/gems/vajra/coverage/.resultset.json test_e2e: - name: E2E Tests · Ruby ${{ matrix.ruby_version }} + name: E2E Tests · ${{ matrix.os }} · Ruby ${{ matrix.ruby_version }} permissions: contents: read - runs-on: ubuntu-latest + runs-on: ${{ matrix.os }} needs: [rubocop, reek] strategy: fail-fast: false matrix: - ruby_version: - - "3.2" - - "3.4" - - "4.0" + include: + - os: ubuntu-latest + ruby_version: "3.2" + - os: ubuntu-latest + ruby_version: "3.4" + - os: ubuntu-latest + ruby_version: "4.0" + - os: macos-15 + ruby_version: "3.2" + - os: macos-15 + ruby_version: "3.4" + - os: macos-15 + ruby_version: "4.0" + - os: windows-2022 + ruby_version: "3.2" + - os: windows-2022 + ruby_version: "3.4" + - os: windows-2022 + ruby_version: "4.0" steps: - uses: actions/checkout@v7 - name: Install apt packages + if: runner.os == 'Linux' run: | sudo apt-get update sudo apt-get install --no-install-recommends -y \ build-essential git libyaml-dev pkg-config + - name: Install macOS packages + if: runner.os == 'macOS' + run: brew install libyaml openssl@3 pkg-config - name: Install h2spec + if: runner.os == 'Linux' env: H2SPEC_VERSION: v2.6.0 H2SPEC_SHA256: 157ee0de702e01ad40e752dbf074b366027e550c8e7504f9450da2809e279318 @@ -187,6 +238,16 @@ jobs: with: ruby-version: ${{ matrix.ruby_version }} bundler-cache: false + - name: Install Windows native dependencies + if: runner.os == 'Windows' + shell: cmd + run: >- + ridk exec pacman --noconfirm --needed -S + mingw-w64-ucrt-x86_64-openssl + mingw-w64-ucrt-x86_64-libyaml + mingw-w64-ucrt-x86_64-pkgconf + mingw-w64-ucrt-x86_64-toolchain + make - name: Restore bundle cache uses: actions/cache@v6 with: @@ -195,12 +256,30 @@ jobs: restore-keys: | ${{ runner.os }}-ruby-${{ matrix.ruby_version }}-bundle- - name: Install bundles + if: runner.os == 'Linux' run: scripts/ci-install-bundles env: CI_RUBY_VERSION: ${{ matrix.ruby_version }} + - name: Install macOS bundle + if: runner.os == 'macOS' + working-directory: gems/vajra + run: bundle install --jobs 4 --retry 3 + - name: Install Windows bundle and compile + if: runner.os == 'Windows' + working-directory: gems/vajra + env: + VAJRA_TEST_FAULT_INJECTION: "1" + run: | + bundle install --jobs 4 --retry 3 + ridk exec bundle exec rake clobber compile - name: Run e2e tests + shell: bash + env: + VAJRA_TEST_FAULT_INJECTION: "1" + NO_COVERAGE: "1" run: scripts/run-rspec-e2e-all - name: Run HTTP/2 h2spec conformance tests + if: runner.os == 'Linux' run: scripts/run-h2spec-all build_package: @@ -268,3 +347,13 @@ jobs: CI_RUBY_VERSION: ${{ env.RUBY_VERSION }} - name: Build docs run: scripts/run-docs-build-all + + ci_finished: + name: ci_finished + permissions: + contents: read + runs-on: ubuntu-latest + needs: [test, test_e2e] + steps: + - name: Confirm all tests passed + run: echo "ci_finished" diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md index f40ebc9..16e1554 100644 --- a/CODE_OF_CONDUCT.md +++ b/CODE_OF_CONDUCT.md @@ -2,128 +2,81 @@ ## Our Pledge -We as members, contributors, and leaders pledge to make participation in our -community a harassment-free experience for everyone, regardless of age, body -size, visible or invisible disability, ethnicity, sex characteristics, gender -identity and expression, level of experience, education, socio-economic status, -nationality, personal appearance, race, caste, color, religion, or sexual -identity and orientation. +We as members, contributors, and leaders pledge to make participation in our community a harassment-free experience for everyone, regardless of age, body size, visible or invisible disability, ethnicity, sex characteristics, gender identity and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, caste, color, religion, or sexual identity and orientation. -We pledge to act and interact in ways that contribute to an open, welcoming, -diverse, inclusive, and healthy community. +We pledge to act and interact in ways that contribute to an open, welcoming, diverse, inclusive, and healthy community. ## Our Standards -Examples of behavior that contributes to a positive environment for our -community include: +Examples of behavior that contributes to a positive environment for our community include: - Demonstrating empathy and kindness toward other people - Being respectful of differing opinions, viewpoints, and experiences - Giving and gracefully accepting constructive feedback -- Accepting responsibility and apologizing to those affected by our mistakes, - and learning from the experience -- Focusing on what is best not just for us as individuals, but for the overall - community +- Accepting responsibility and apologizing to those affected by our mistakes, and learning from the experience +- Focusing on what is best not just for us as individuals, but for the overall community Examples of unacceptable behavior include: -- The use of sexualized language or imagery, and sexual attention or advances of - any kind +- The use of sexualized language or imagery, and sexual attention or advances of any kind - Trolling, insulting or derogatory comments, and personal or political attacks - Public or private harassment -- Publishing others' private information, such as a physical or email address, - without their explicit permission -- Other conduct which could reasonably be considered inappropriate in a - professional setting +- Publishing others' private information, such as a physical or email address, without their explicit permission +- Other conduct which could reasonably be considered inappropriate in a professional setting ## Enforcement Responsibilities -Community leaders are responsible for clarifying and enforcing our standards of -acceptable behavior and will take appropriate and fair corrective action in -response to any behavior that they deem inappropriate, threatening, offensive, -or harmful. +Community leaders are responsible for clarifying and enforcing our standards of acceptable behavior and will take appropriate and fair corrective action in response to any behavior that they deem inappropriate, threatening, offensive, or harmful. -Community leaders have the right and responsibility to remove, edit, or reject -comments, commits, code, wiki edits, issues, and other contributions that are -not aligned to this Code of Conduct, and will communicate reasons for moderation -decisions when appropriate. +Community leaders have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, and will communicate reasons for moderation decisions when appropriate. ## Scope -This Code of Conduct applies within all community spaces, and also applies when -an individual is officially representing the community in public spaces. -Examples of representing our community include using an official email address, -posting via an official social media account, or acting as an appointed -representative at an online or offline event. +This Code of Conduct applies within all community spaces, and also applies when an individual is officially representing the community in public spaces. Examples of representing our community include using an official email address, posting via an official social media account, or acting as an appointed representative at an online or offline event. ## Enforcement -Instances of abusive, harassing, or otherwise unacceptable behavior may be -reported to the community leaders responsible for enforcement at +Instances of abusive, harassing, or otherwise unacceptable behavior may be reported to the community leaders responsible for enforcement at . All complaints will be reviewed and investigated promptly and fairly. -All community leaders are obligated to respect the privacy and security of the -reporter of any incident. +All community leaders are obligated to respect the privacy and security of the reporter of any incident. ## Enforcement Guidelines -Community leaders will follow these Community Impact Guidelines in determining -the consequences for any action they deem in violation of this Code of Conduct: +Community leaders will follow these Community Impact Guidelines in determining the consequences for any action they deem in violation of this Code of Conduct: ### 1. Correction -**Community Impact**: Use of inappropriate language or other behavior deemed -unprofessional or unwelcome in the community. +**Community Impact**: Use of inappropriate language or other behavior deemed unprofessional or unwelcome in the community. -**Consequence**: A private, written warning from community leaders, providing -clarity around the nature of the violation and an explanation of why the -behavior was inappropriate. A public apology may be requested. +**Consequence**: A private, written warning from community leaders, providing clarity around the nature of the violation and an explanation of why the behavior was inappropriate. A public apology may be requested. ### 2. Warning -**Community Impact**: A violation through a single incident or series of -actions. +**Community Impact**: A violation through a single incident or series of actions. -**Consequence**: A warning with consequences for continued behavior. No -interaction with the people involved, including unsolicited interaction with -those enforcing the Code of Conduct, for a specified period of time. This -includes avoiding interactions in community spaces as well as external channels -like social media. Violating these terms may lead to a temporary or permanent -ban. +**Consequence**: A warning with consequences for continued behavior. No interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, for a specified period of time. This includes avoiding interactions in community spaces as well as external channels like social media. Violating these terms may lead to a temporary or permanent ban. ### 3. Temporary Ban -**Community Impact**: A serious violation of community standards, including -sustained inappropriate behavior. +**Community Impact**: A serious violation of community standards, including sustained inappropriate behavior. -**Consequence**: A temporary ban from any sort of interaction or public -communication with the community for a specified period of time. No public or -private interaction with the people involved, including unsolicited interaction -with those enforcing the Code of Conduct, is allowed during this period. -Violating these terms may lead to a permanent ban. +**Consequence**: A temporary ban from any sort of interaction or public communication with the community for a specified period of time. No public or private interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, is allowed during this period. Violating these terms may lead to a permanent ban. ### 4. Permanent Ban -**Community Impact**: Demonstrating a pattern of violation of community -standards, including sustained inappropriate behavior, harassment of an -individual, or aggression toward or disparagement of classes of individuals. +**Community Impact**: Demonstrating a pattern of violation of community standards, including sustained inappropriate behavior, harassment of an individual, or aggression toward or disparagement of classes of individuals. -**Consequence**: A permanent ban from any sort of public interaction within the -community. +**Consequence**: A permanent ban from any sort of public interaction within the community. ## Attribution -This Code of Conduct is adapted from the [Contributor Covenant][homepage], -version 2.1, available at -[https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1]. +This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 2.1, available at [https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1]. -Community Impact Guidelines were inspired by -[Mozilla's code of conduct enforcement ladder][Mozilla CoC]. +Community Impact Guidelines were inspired by [Mozilla's code of conduct enforcement ladder][Mozilla CoC]. -For answers to common questions about this code of conduct, see the FAQ at -[https://www.contributor-covenant.org/faq][FAQ]. Translations are available at -[https://www.contributor-covenant.org/translations][translations]. +For answers to common questions about this code of conduct, see the FAQ at [https://www.contributor-covenant.org/faq][FAQ]. Translations are available at [https://www.contributor-covenant.org/translations][translations]. [homepage]: https://www.contributor-covenant.org [v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 8540ce1..01b3f45 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,8 +1,6 @@ # Contributing to Vajra -Thank you for contributing to Vajra. This repository contains one canonical -gem under `gems/vajra`, a product documentation site under `docs/`, and the -automation and governance files used to validate and release the project. +Thank you for contributing to Vajra. This repository contains one canonical gem under `gems/vajra`, a product documentation site under `docs/`, and the automation and governance files used to validate and release the project. ## Repository Layout @@ -16,8 +14,7 @@ automation and governance files used to validate and release the project. ## Development Baseline -Use Ruby `3.2+` and run package-local commands from `gems/vajra` unless a root -script explicitly says otherwise. +Use Ruby `3.2+` and run package-local commands from `gems/vajra` unless a root script explicitly says otherwise. ```bash cd gems/vajra @@ -31,11 +28,7 @@ bundle exec rbs -I sig validate bundle exec exe/vajra ``` -The native extension source of truth lives under `gems/vajra/ext/vajra/`. -When Ruby files are split by responsibility, mirror that split in direct specs -under `gems/vajra/spec/` where the file owns behavior. -Unit tests are unit tests with coverage. `bin/rspec-e2e` is the integration -lane with `NO_COVERAGE=1`. +The native extension source of truth lives under `gems/vajra/ext/vajra/`. When Ruby files are split by responsibility, mirror that split in direct specs under `gems/vajra/spec/` where the file owns behavior. Unit tests are unit tests with coverage. `bin/rspec-e2e` is the integration lane with `NO_COVERAGE=1`. ## Docs Development @@ -47,16 +40,13 @@ bundle install bundle exec jekyll serve ``` -Update docs whenever commands, paths, runtime behavior, or support boundaries -change. -The intended public docs host is `vajra.codevedas.com`. +Update docs whenever commands, paths, runtime behavior, or support boundaries change. The intended public docs host is `vajra.codevedas.com`. ## Pull Requests Before opening a PR: -1. Create a branch using a meaningful prefix such as `feat/`, `bugfix/`, - `docs/`, `chore/`, or `ci/`. +1. Create a branch using a meaningful prefix such as `feat/`, `bugfix/`, `docs/`, `chore/`, or `ci/`. 2. Run the relevant local checks. 3. Update docs when behavior or usage changes. 4. Complete the PR template with enough detail for reviewers. @@ -68,9 +58,7 @@ scripts/ci-install-bundles scripts/run-all ``` -That flow covers unit tests with coverage, e2e integration tests without -coverage, clean native rebuild verification, package build validation, and docs -build validation. +That flow covers unit tests with coverage, e2e integration tests without coverage, clean native rebuild verification, package build validation, and docs build validation. ## Security @@ -84,6 +72,5 @@ To report a security issue, follow [SECURITY.md](SECURITY.md). 4. Run `scripts/run-all`. 5. Open a PR to `main` and label it appropriately. 6. After merge, create a GitHub Release with tag `v`. -7. Docs are published through `.github/workflows/jekyll-gh-pages.yml` to - `vajra.codevedas.com`. +7. Docs are published through `.github/workflows/jekyll-gh-pages.yml` to `vajra.codevedas.com`. 8. The release workflow publishes the gem from `gems/vajra`. diff --git a/README.md b/README.md index 756ec54..0248a37 100644 --- a/README.md +++ b/README.md @@ -1,35 +1,27 @@ # Vajra -Vajra is a native Ruby application server implemented in C++ and packaged as a -Ruby extension. +Vajra is a native Ruby application server implemented in C++ and packaged as a Ruby extension. -The repository contains the canonical gem under `gems/vajra`, the published -documentation site under `docs/`, and the GitHub automation and contributor -guidance needed to build, validate, and release the project. +The repository contains the canonical gem under `gems/vajra`, the published documentation site under `docs/`, and the GitHub automation and contributor guidance needed to build, validate, and release the project. ## Product Shape Vajra keeps one deliberate ownership split: - Ruby owns packaging, executable boot, signatures, and build diagnostics. -- C++ owns the native listener, request loop, connection handling, and shutdown - behavior. -- `docs/` owns the full product story for installation, runtime behavior, - observability, troubleshooting, and development workflow. +- C++ owns the native listener, request loop, connection handling, and shutdown behavior. +- `docs/` owns the full product story for installation, runtime behavior, observability, troubleshooting, and development workflow. ## Repository Map -- `gems/vajra`: canonical gem, executable, signatures, and native extension - sources +- `gems/vajra`: canonical gem, executable, signatures, and native extension sources - `docs/`: product documentation site built with Jekyll and Just the Docs -- `.github/`: issue templates, workflows, release drafting, and repository - instructions +- `.github/`: issue templates, workflows, release drafting, and repository instructions - `scripts/`: root-level convenience commands for CI and local verification ## Local Development -Install dependencies and run the shared repository validation flow from the -repository root: +Install dependencies and run the shared repository validation flow from the repository root: ```bash scripts/ci-install-bundles @@ -48,13 +40,11 @@ bin/reek bundle exec exe/vajra ``` -`bin/rspec-unit` is the covered unit lane. `bin/rspec-e2e` is the integration -lane and runs without coverage. +`bin/rspec-unit` is the covered unit lane. `bin/rspec-e2e` is the integration lane and runs without coverage. ## Documentation -The docs site under `docs/` is the authoritative product documentation surface -for installation, runtime behavior, operations, and troubleshooting. +The docs site under `docs/` is the authoritative product documentation surface for installation, runtime behavior, operations, and troubleshooting. Start with: @@ -63,10 +53,9 @@ Start with: - [`docs/pages/03-configuration.md`](docs/pages/03-configuration.md) - [`docs/pages/04-architecture/02-runtime-model.md`](docs/pages/04-architecture/02-runtime-model.md) - [`docs/pages/04-architecture.md`](docs/pages/04-architecture.md) -- [`docs/pages/06-troubleshooting.md`](docs/pages/06-troubleshooting.md) -- [`docs/pages/05-development.md`](docs/pages/05-development.md) +- [`docs/pages/05-guides/09-troubleshooting.md`](docs/pages/05-guides/09-troubleshooting.md) +- [`docs/pages/05-guides/10-development.md`](docs/pages/05-guides/10-development.md) ## Contributing -See [CONTRIBUTING.md](CONTRIBUTING.md) for setup, workflow, documentation, and -release guidance. +See [CONTRIBUTING.md](CONTRIBUTING.md) for setup, workflow, documentation, and release guidance. diff --git a/SECURITY.md b/SECURITY.md index 31eaecc..52a3181 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -2,25 +2,18 @@ ## Supported Versions -Only the latest released Vajra version receives security fixes. Earlier -versions are unsupported; upgrade to the latest release before requesting a -security backport. The current release is identified by RubyGems, repository -tags, and `CHANGELOG.md`. +Only the latest released Vajra version receives security fixes. Earlier versions are unsupported; upgrade to the latest release before requesting a security backport. The current release is identified by RubyGems, repository tags, and `CHANGELOG.md`. ## Reporting a Vulnerability -Report vulnerabilities privately through -[GitHub Security Advisories](https://github.com/Code-Vedas/vajra/security/advisories/new). -Do not open a public issue for a suspected vulnerability. -Include: +Report vulnerabilities privately through [GitHub Security Advisories](https://github.com/Code-Vedas/vajra/security/advisories/new). Do not open a public issue for a suspected vulnerability. Include: - affected version or commit - impact summary - reproduction details - suggested mitigation if known -We will triage the report and prioritize a fix according to impact and -exploitability. +We will triage the report and prioritize a fix according to impact and exploitability. ## Security Updates diff --git a/docs/index.md b/docs/index.md index 341a13a..1eb1035 100644 --- a/docs/index.md +++ b/docs/index.md @@ -7,23 +7,17 @@ description: Vajra product overview and documentation entrypoint. # Vajra -Vajra is a native Ruby application server for Rack and Rails applications. It is -distributed as a Ruby gem with a C++ runtime that owns the listener, request -parsing, response writing, worker lifecycle, and shutdown behavior. +Vajra is a native Ruby application server for Rack and Rails applications. It is distributed as a Ruby gem with a C++ runtime that owns the listener, request parsing, response writing, worker lifecycle, and shutdown behavior. -Use Vajra when an application needs Rack compatibility with native listener, -protocol, and worker lifecycle management. +Use Vajra when an application needs Rack compatibility with native listener, protocol, and worker lifecycle management. ## Protocol Support -Vajra supports HTTP/1.0, HTTP/1.1, TLS HTTP/1.1, TLS HTTP/2, and cleartext h2c. -HTTP/2 includes prior-knowledge h2c, HTTP/1.1 `Upgrade: h2c`, Extended CONNECT -stream tunnels, and WebSocket-over-HTTP/2 as raw WebSocket frame transport. +Vajra supports HTTP/1.0, HTTP/1.1, TLS HTTP/1.1, TLS HTTP/2, and cleartext h2c. HTTP/2 includes prior-knowledge h2c, HTTP/1.1 `Upgrade: h2c`, Extended CONNECT stream tunnels, and WebSocket-over-HTTP/2 as raw WebSocket frame transport. ## Framework Support -Vajra uses the standard Rack contract, so the same runtime can serve common Ruby -web frameworks. +Vajra uses the standard Rack contract, so the same runtime can serve common Ruby web frameworks. | Framework | How Vajra Fits | | --------- | -------------------------------------------------------------------------------------------------------------- | @@ -40,18 +34,9 @@ web frameworks. 3. [Command Reference](/command-reference/) 4. [Frameworks](/frameworks/) 5. [Architecture](/architecture/) -6. [Observability](/observability/) -7. [Rack Compatibility](/rack-compatibility/) -8. [API Reference](/api-reference/) -9. [Production Deployment](/production/) -10. [Security](/security/) -11. [Performance](/performance/) -12. [Migration](/migration/) -13. [Upgrading](/upgrading/) -14. [Compatibility](/compatibility/) -15. [Troubleshooting](/troubleshooting/) -16. [Development](/development/) -17. [Glossary](/glossary/) +6. [Guides](/guides/) +7. [API Reference](/api-reference/) +8. [Professional Support](/support/) ## Quick Start @@ -73,5 +58,4 @@ For Rails apps, keep the normal Rails command: bin/rails server ``` -Add `config/vajra.rb` when the app needs server-specific settings such as host, -port, worker count, thread count, access logs, or request limits. +Add `config/vajra.rb` when the app needs server-specific settings such as host, port, worker count, thread count, access logs, or request limits. diff --git a/docs/pages/02-installation.md b/docs/pages/02-installation.md index 5697a4e..5c9c015 100644 --- a/docs/pages/02-installation.md +++ b/docs/pages/02-installation.md @@ -6,9 +6,7 @@ permalink: /installation/ # Installation -Use this page when adding Vajra to an application. Repository setup, native -extension development, conformance checks, performance profiles, and release -validation live in [Development](/development/). +Use this page when adding Vajra to an application. Repository setup, native extension development, conformance checks, performance profiles, and release validation live in [Development](/development/). ## Add The Gem @@ -25,8 +23,7 @@ Install the bundle from the application root. bundle install ``` -Vajra ships with a native extension. Bundler compiles it during installation, -the same way it does for other native gems. +Vajra is currently published as a source gem. Bundler compiles its native extension for the active Ruby toolchain during installation. Windows requires a 64-bit RubyInstaller UCRT Ruby (`x64-mingw-ucrt`); MSVC-built Ruby is not supported. Validate the package loads: @@ -34,10 +31,7 @@ Validate the package loads: bundle exec ruby -rvajra -e 'puts Vajra::VERSION' ``` -If the native extension does not load, rebuild it through Bundler and then see -[Troubleshooting](/troubleshooting/#native-extension-does-not-load). Runtime -development prerequisites and repository validation commands live in -[Development](/development/), not on this installation path. +If the native extension does not load, rebuild the source gem through Bundler using the active Ruby toolchain, then see [Troubleshooting](/troubleshooting/#native-extension-does-not-load). Do not copy or rename an extension built for another Ruby installation. Runtime development prerequisites and repository validation commands live in [Development](/development/), not on this installation path. ## Rails @@ -47,8 +41,7 @@ Rails applications continue to use the normal Rails launcher. bin/rails server ``` -Vajra supplies the server handler for `bin/rails server`; Rails applications do -not need another Rack server gem for that command. +Vajra supplies the server handler for `bin/rails server`; Rails applications do not need another Rack server gem for that command. Add `config/vajra.rb` when the application needs explicit Vajra server settings. @@ -77,9 +70,7 @@ Start Vajra from the application root. bundle exec vajra ``` -Vajra loads `config.ru` automatically when no explicit `config/vajra.rb` is -present. Create a Vajra config file for server settings such as `host`, `port`, -request limits, TLS, logging, metrics, or tracing. +Vajra loads `config.ru` automatically when no explicit `config/vajra.rb` is present. Create a Vajra config file for server settings such as `host`, `port`, request limits, TLS, logging, metrics, or tracing. ## Next Steps @@ -87,5 +78,4 @@ request limits, TLS, logging, metrics, or tracing. - See [Command Reference](/command-reference/) for executable behavior. - See [Frameworks](/frameworks/) for framework-specific setup. - See [Production](/production/) for deployment guidance. -- See [Troubleshooting](/troubleshooting/) for common boot and native build - issues. +- See [Troubleshooting](/troubleshooting/) for common boot and native build issues. diff --git a/docs/pages/03-configuration.md b/docs/pages/03-configuration.md index c9a3d34..747c962 100644 --- a/docs/pages/03-configuration.md +++ b/docs/pages/03-configuration.md @@ -6,8 +6,7 @@ permalink: /configuration/ # Configuration -Vajra reads server configuration at startup. Change a setting in -`config/vajra.rb` or the process environment, then restart the server. +Vajra reads server configuration at startup. Change a setting in `config/vajra.rb` or the process environment, then restart the server. The configuration file has two responsibilities: @@ -16,8 +15,7 @@ The configuration file has two responsibilities: ## Minimal Configuration -Rack, Sinatra, Roda, and Hanami applications can start from a standard -`config.ru`: +Rack, Sinatra, Roda, and Hanami applications can start from a standard `config.ru`: ```bash bundle exec vajra @@ -42,8 +40,7 @@ Vajra.configure do |config| end ``` -Production deployments should set listener address, worker count, thread count, -request limits, and observability outputs explicitly: +Production deployments should set listener address, worker count, thread count, request limits, and observability outputs explicitly: ```ruby Vajra.configure do |config| @@ -142,8 +139,7 @@ Vajra.configure do |config| end ``` -The certificate file should contain the served certificate chain. The private -key must be readable by the runtime user. +The certificate file should contain the served certificate chain. The private key must be readable by the runtime user. ### HTTP/2 And h2c @@ -157,8 +153,7 @@ Vajra.configure do |config| end ``` -With `http2 true`, TLS listeners negotiate HTTP/2 through ALPN and plain -listeners accept h2c prior knowledge and HTTP/1.1 upgrade. +With `http2 true`, TLS listeners negotiate HTTP/2 through ALPN and plain listeners accept h2c prior knowledge and HTTP/1.1 upgrade. ### Observability @@ -176,8 +171,7 @@ Vajra.configure do |config| end ``` -Protect `stats_path` and `metrics_endpoint` at the network or reverse-proxy -layer. +Protect `stats_path` and `metrics_endpoint` at the network or reverse-proxy layer. ### Strict Public Listener @@ -192,8 +186,7 @@ Vajra.configure do |config| end ``` -Use stricter limits for internet-facing deployments and raise them only for -routes that need larger uploads or longer body delivery windows. +Use stricter limits for internet-facing deployments and raise them only for routes that need larger uploads or longer body delivery windows. ### Environment Overrides @@ -203,13 +196,11 @@ Environment variables override Ruby config: VAJRA_PORT=4000 VAJRA_WORKERS=4 bundle exec vajra ``` -With that command, `port` and `workers` from `config/vajra.rb` are ignored for -the process. +With that command, `port` and `workers` from `config/vajra.rb` are ignored for the process. ### Invalid Config -Unknown start keywords and unsupported config-file directives fail before the -server begins serving: +Unknown start keywords and unsupported config-file directives fail before the server begins serving: ```ruby Vajra.start(bind: "tcp://0.0.0.0:3000") @@ -231,9 +222,7 @@ unsupported configuration directive: bind ## Full Runtime Reference -These settings are the supported runtime configuration surface. The table tracks -the Ruby `Vajra.start` keyword arguments, the `config/vajra.rb` DSL, and the -native runtime loader. +These settings are the supported runtime configuration surface. The table tracks the Ruby `Vajra.start` keyword arguments, the `config/vajra.rb` DSL, and the native runtime loader. ### Listener And Concurrency @@ -246,21 +235,21 @@ native runtime loader. | `max_connections` | `256` | none | Integer `>= 1` | Worker-side active connection capacity. | | `socket_queue_capacity` | `256` | `VAJRA_SOCKET_QUEUE_CAPACITY` | Integer `>= 1` | Pending dispatch capacity before admission pressure. | -Vajra keeps listener ownership in the master process. Accepted connections are dispatched to workers through controlled file-descriptor handoff. +Vajra keeps listener ownership in the master process. Linux and macOS dispatch accepted descriptors through Unix control sockets. Windows dispatches `WSADuplicateSocketW` metadata through framed named pipes so the worker can reconstruct its own socket handle. ### Request Limits And Timeouts -| Setting | Default | Env Override | Valid Values | Effect | -| ------------------------ | ------------ | ------------------------------ | -------------- | --------------------------------------------------------------------------------- | -| `max_request_head_bytes` | `16_384` | `VAJRA_MAX_REQUEST_HEAD_BYTES` | Integer `>= 1` | Maximum request-head bytes before parser rejection. | -| `max_request_body_bytes` | `16_777_216` | `VAJRA_MAX_REQUEST_BODY_BYTES` | Integer `>= 1` | Maximum accepted request-body bytes. | -| `request_timeout` | `25` | `VAJRA_REQUEST_TIMEOUT` | `1..2_147_483` | Maximum queue wait before execution starts. | -| `request_head_timeout` | `5` | `VAJRA_REQUEST_HEAD_TIMEOUT` | `1..2_147_483` | Time allowed to receive a complete request head. | -| `request_body_timeout` | `30` | `VAJRA_REQUEST_BODY_TIMEOUT` | `1..2_147_483` | Time allowed to receive a complete request body after the request head. | -| `first_data_timeout` | `30` | `VAJRA_FIRST_DATA_TIMEOUT` | `1..2_147_483` | Time allowed for first bytes on a new connection. | -| `persistent_timeout` | `30` | `VAJRA_PERSISTENT_TIMEOUT` | `1..2_147_483` | Idle keep-alive timeout between requests. | -| `worker_timeout` | `60` | `VAJRA_WORKER_TIMEOUT` | `1..2_147_483` | Worker health timeout. | -| `max_keepalive_requests` | `0` | `VAJRA_MAX_KEEPALIVE_REQUESTS` | Integer `>= 0` | Maximum sequential requests on one keep-alive connection. `0` disables the limit. | +| Setting | Default | Env Override | Valid Values | Effect | +| ------------------------ | ------------ | ------------------------------ | -------------- | ----------------------------------------------------------------------------------------------- | +| `max_request_head_bytes` | `16_384` | `VAJRA_MAX_REQUEST_HEAD_BYTES` | Integer `>= 1` | Maximum request-head bytes before parser rejection. | +| `max_request_body_bytes` | `16_777_216` | `VAJRA_MAX_REQUEST_BODY_BYTES` | Integer `>= 1` | Maximum accepted request-body bytes. | +| `request_timeout` | `25` | `VAJRA_REQUEST_TIMEOUT` | `1..2_147_483` | Maximum queue wait before execution starts. | +| `request_head_timeout` | `5` | `VAJRA_REQUEST_HEAD_TIMEOUT` | `1..2_147_483` | Time allowed to receive a complete request head. | +| `request_body_timeout` | `30` | `VAJRA_REQUEST_BODY_TIMEOUT` | `1..2_147_483` | Time allowed to receive a complete request body after the request head. | +| `first_data_timeout` | `30` | `VAJRA_FIRST_DATA_TIMEOUT` | `1..2_147_483` | Time allowed for first bytes on a new connection. | +| `persistent_timeout` | `30` | `VAJRA_PERSISTENT_TIMEOUT` | `1..2_147_483` | Idle keep-alive timeout between requests. | +| `worker_timeout` | `60` | `VAJRA_WORKER_TIMEOUT` | `1..2_147_483` | Worker lifecycle deadline used for readiness, recovery, shutdown drain, and timeout escalation. | +| `max_keepalive_requests` | `0` | `VAJRA_MAX_KEEPALIVE_REQUESTS` | Integer `>= 0` | Maximum sequential requests on one keep-alive connection. `0` disables the limit. | Request bodies are exposed to Rack through `Vajra::NativeInput`. The native input layer buffers in bounded chunks, spills large bodies to temporary storage, and wakes Rack readers when bytes, EOF, close, or errors are available. @@ -271,8 +260,8 @@ Request bodies are exposed to Rack through `Vajra::NativeInput`. The native inpu | `tls` | `false` | `VAJRA_TLS` | `true`, `false` | Enable TLS on the listener. | | `tls_certificate` | `""` | `VAJRA_TLS_CERTIFICATE` | String path | Certificate chain file. Required when `tls` is true. | | `tls_private_key` | `""` | `VAJRA_TLS_PRIVATE_KEY` | String path | Private key file. Required when `tls` is true. | -| `tls_ca_certificate` | `""` | `VAJRA_TLS_CA_CERTIFICATE` | String path | CA bundle; required when peer verification is enabled. | -| `tls_verify_mode` | `"none"` | `VAJRA_TLS_VERIFY_MODE` | `"none"`, `"peer"` | TLS peer verification mode. `peer` requires `tls_ca_certificate`. | +| `tls_ca_certificate` | `""` | `VAJRA_TLS_CA_CERTIFICATE` | String path | CA bundle; required when peer verification is enabled. | +| `tls_verify_mode` | `"none"` | `VAJRA_TLS_VERIFY_MODE` | `"none"`, `"peer"` | TLS peer verification mode. `peer` requires `tls_ca_certificate`. | | `tls_min_version` | `"TLSv1_2"` | `VAJRA_TLS_MIN_VERSION` | `"TLSv1_2"`, `"TLSv1_3"` | Minimum TLS protocol version. | | `alpn_protocols` | `["http/1.1"]` or `["h2", "http/1.1"]` with TLS HTTP/2 | `VAJRA_ALPN_PROTOCOLS` | Array or comma-separated list of protocol strings | Advertised TLS ALPN protocols. | | `http2` | `false` | `VAJRA_HTTP2` | `true`, `false` | Enable HTTP/2 over TLS ALPN and cleartext h2c on plain listeners. | @@ -281,12 +270,7 @@ Request bodies are exposed to Rack through `Vajra::NativeInput`. The native inpu | `http2_max_frame_size` | `1_048_576` | `VAJRA_HTTP2_MAX_FRAME_SIZE` | `16_384..16_777_215` | Maximum HTTP/2 DATA frame size; HEADERS frames are capped separately for request-head safety. | | `http2_header_table_size` | `4096` | `VAJRA_HTTP2_HEADER_TABLE_SIZE` | `0..2_147_483_647` | HPACK dynamic table size. | -When `http2` is enabled, TLS listeners negotiate HTTP/2 through ALPN and plain -listeners accept h2c prior knowledge and HTTP/1.1 `Upgrade: h2c`. Ordinary -textual `HTTP/2.0` request lines remain invalid HTTP/1.x requests. Extended -CONNECT provides bidirectional HTTP/2 stream IO, including -WebSocket-over-HTTP/2. Priority scheduling applies to response DATA and tunnel -DATA; server push is not implemented. +When `http2` is enabled, TLS listeners negotiate HTTP/2 through ALPN and plain listeners accept h2c prior knowledge and HTTP/1.1 `Upgrade: h2c`. Ordinary textual `HTTP/2.0` request lines remain invalid HTTP/1.x requests. Extended CONNECT provides bidirectional HTTP/2 stream IO, including WebSocket-over-HTTP/2. Priority scheduling applies to response DATA and tunnel DATA; server push is not implemented. ### Logging, Metrics, And Tracing @@ -300,15 +284,11 @@ DATA; server push is not implemented. | `stats_path` | `""` | `VAJRA_STATS_PATH` | String path | JSON stats endpoint. Empty disables the endpoint. | | `metrics_endpoint` | `""` | `VAJRA_METRICS_ENDPOINT` | String path | Prometheus metrics endpoint. Empty disables the endpoint. | | `trace_enabled` | `false` | `VAJRA_TRACE_ENABLED` | `true`, `false` | Enable request tracing. | -| `trace_endpoint` | `""` | `VAJRA_TRACE_ENDPOINT` | String URL | OTLP/HTTP trace endpoint when Vajra exports spans. | +| `trace_endpoint` | `""` | `VAJRA_TRACE_ENDPOINT` | HTTPS URL | OTLP/HTTP endpoint for Vajra-owned native export. | | `trace_service_name` | `"vajra"` | `VAJRA_TRACE_SERVICE_NAME` | String | OpenTelemetry service name. | | `trace_otel_owner` | `false` | `VAJRA_TRACE_OTEL_OWNER` | `true`, `false` | Let Vajra own native OTLP export. | -Vajra also reads standard OpenTelemetry environment variables: -`OTEL_SERVICE_NAME`, `OTEL_RESOURCE_ATTRIBUTES`, -`OTEL_EXPORTER_OTLP_ENDPOINT`, `OTEL_TRACES_EXPORTER`, -`OTEL_METRICS_EXPORTER`, `OTEL_PROPAGATORS`, `OTEL_TRACES_SAMPLER`, and -`OTEL_TRACES_SAMPLER_ARG`. +Vajra also reads standard OpenTelemetry environment variables: `OTEL_SERVICE_NAME`, `OTEL_RESOURCE_ATTRIBUTES`, `OTEL_EXPORTER_OTLP_ENDPOINT`, `OTEL_TRACES_EXPORTER`, `OTEL_METRICS_EXPORTER`, `OTEL_PROPAGATORS`, `OTEL_TRACES_SAMPLER`, and `OTEL_TRACES_SAMPLER_ARG`. ## Environment Precedence @@ -319,6 +299,4 @@ Configuration precedence is: 3. standard `OTEL_*` environment variables for tracing defaults when no Vajra-specific tracing override is present 4. Vajra defaults -Environment variables are process-level runtime settings and override explicit -Ruby configuration. Use them for deployment-owned values such as ports, worker -counts, TLS file paths, or OpenTelemetry endpoints. +Environment variables are process-level runtime settings and override explicit Ruby configuration. Use them for deployment-owned values such as ports, worker counts, TLS file paths, or OpenTelemetry endpoints. diff --git a/docs/pages/04-architecture.md b/docs/pages/04-architecture.md index dc39d80..7fddbd4 100644 --- a/docs/pages/04-architecture.md +++ b/docs/pages/04-architecture.md @@ -7,7 +7,7 @@ has_children: true # Architecture -Vajra has one public Ruby package and one native runtime. Ruby owns gem loading, configuration, and application boot. The C++ runtime owns listener sockets, connection dispatch, request parsing, request-body transport, response writing, logging transport, worker lifecycle, and shutdown. +Vajra has one public Ruby package and one shared runtime contract with platform-specific native backends. Ruby owns gem loading, configuration, and application boot. The C++ runtime owns listener sockets, connection dispatch, request parsing, request-body transport, response writing, logging transport, worker lifecycle, and shutdown. ```mermaid flowchart TD @@ -38,6 +38,8 @@ The runtime boundary is: - HTTP/2 stream tunnels expose multiplexed byte streams to Rack without transferring the underlying socket. - Failure handling is explicit at package load, listener bind, request parsing, worker lifecycle, and shutdown boundaries. +The public Rack, protocol, lifecycle, and configuration contracts are shared, but the process and IO mechanisms are not. Linux uses `fork`, Unix control sockets, descriptor passing, shared `mmap`, and `epoll`. macOS uses the same POSIX process and handoff model with `kqueue`. Windows uses Winsock and `WSAPoll`; the master starts workers with `CreateProcessW`, transfers accepted sockets with `WSADuplicateSocketW`, uses framed named pipes for control and supervision, stores shared state in file mappings, and owns workers through a kill-on-close Job Object. Process identities remain distinct from owned process, thread, socket, mapping, and pipe handles. + ## Sections 1. [Request Path](/architecture/request-path/) @@ -53,22 +55,19 @@ The runtime boundary is: Use these files when validating architecture claims against implementation: -| Area | Source Files | -| --------------------- | ----------------------------------------------------------------------------- | -| Runtime supervision | `gems/vajra/ext/vajra/runtime/native_runtime.cpp`, `worker_pool.hpp` | -| Runtime configuration | `gems/vajra/lib/vajra.rb`, `gems/vajra/lib/vajra/cli.rb`, `runtime_config.cpp` | -| Request path | `request_processor.cpp`, `request_head_reader.cpp`, `request_body_reader.cpp` | -| Response writing | `response_serializer.cpp`, `response_writer.cpp` | -| HTTP/2 session | `http2_session.cpp`, `http2_stream.cpp` | -| Rack bridge | `ruby_execution_bridge.cpp`, `ruby_rack_transport.cpp`, `native_input.cpp` | -| Public types | `gems/vajra/sig/vajra.rbs`, `gems/vajra/sig/vajra/internal/rack_execution.rbs` | +- Runtime supervision: `gems/vajra/ext/vajra/runtime/native_runtime.cpp`, `gems/vajra/ext/vajra/runtime/native_runtime_windows.cpp`, and `gems/vajra/ext/vajra/runtime/windows_worker_backend.cpp`. +- Platform contracts: `gems/vajra/ext/vajra/platform/socket.cpp` and `gems/vajra/ext/vajra/platform/process.cpp`. +- Runtime configuration: `gems/vajra/lib/vajra.rb`, `gems/vajra/lib/vajra/cli.rb`, and `gems/vajra/ext/vajra/runtime/runtime_config.cpp`. +- Request path: `gems/vajra/ext/vajra/request/request_processor.cpp`, `gems/vajra/ext/vajra/request/request_head_reader.cpp`, and `gems/vajra/ext/vajra/request/request_body_reader.cpp`. +- Response writing: `gems/vajra/ext/vajra/response/response_serializer.cpp` and `gems/vajra/ext/vajra/response/response_writer.cpp`. +- HTTP/2 session: `gems/vajra/ext/vajra/request/http2_session.cpp` and `gems/vajra/ext/vajra/rack/http2_stream.cpp`. +- Rack bridge: `gems/vajra/ext/vajra/rack/ruby_execution_bridge.cpp`, `gems/vajra/ext/vajra/rack/ruby_rack_transport.cpp`, and `gems/vajra/ext/vajra/rack/native_input.cpp`. +- Public types: `gems/vajra/sig/vajra.rbs` and `gems/vajra/sig/vajra/internal/rack_execution.rbs`. Core invariants: - Ruby longjmp-sensitive calls must not run while native mutexes are held. - HTTP request framing is validated before a Rack request is forwarded. -- HTTP/2 response headers are validated and forbidden connection-specific - headers are stripped before submission. +- HTTP/2 response headers are validated and forbidden connection-specific headers are stripped before submission. - Rack full hijack requires the request body to be fully consumed. -- HTTP/2 stream tunnels keep ownership at the stream level; Vajra continues to - manage the shared HTTP/2 connection. +- HTTP/2 stream tunnels keep ownership at the stream level; Vajra continues to manage the shared HTTP/2 connection. diff --git a/docs/pages/04-architecture/01-request-path.md b/docs/pages/04-architecture/01-request-path.md index e801a7b..7b1516e 100644 --- a/docs/pages/04-architecture/01-request-path.md +++ b/docs/pages/04-architecture/01-request-path.md @@ -13,8 +13,8 @@ A request moves through Vajra from the master-owned listener to a worker-owned s flowchart TD client["Client"] master["Native Master
Accept and dispatch"] - handoff["FD Handoff
worker receives socket"] - worker_reactor["Worker Reactor
epoll/kqueue readiness"] + handoff["Platform Socket Handoff
SCM_RIGHTS or WSADuplicateSocketW"] + worker_reactor["Platform Worker Reactor
epoll, kqueue, or WSAPoll"] parser["Native Parser
request head and body plan"] input["Vajra::NativeInput
body bytes and backpressure"] execution_pool["Ruby Execution Pool
Fixed thread pool"] @@ -34,15 +34,15 @@ flowchart TD ## Ownership -| Stage | Owner | Responsibility | -| -------------- | ------------------------- | ------------------------------------------------------------------------------------------- | -| Acceptance | Master process, C++ | Accept TCP connections and choose a worker. | -| Dispatch | Master and worker, C++ | Transfer the accepted client file descriptor to the worker. | -| IO readiness | Worker process, C++ | Register idle sockets with `epoll` or `kqueue`. | -| Request head | Worker process, C++ | Read and parse the request line and headers within configured limits. | -| Request body | `Vajra::NativeInput`, C++ | Buffer body bytes, spill large rewindable bodies, apply watermarks, and unblock Rack reads. | -| Rack execution | Worker process, Ruby | Run the Rack app on a fixed execution pool. | -| Response | Worker process, C++ | Validate Rack response shape, serialize HTTP framing, and write bytes to the client socket. | +| Stage | Owner | Responsibility | +| -------------- | ------------------------- | ---------------------------------------------------------------------------------------------------- | +| Acceptance | Master process, C++ | Accept TCP connections and choose a worker. | +| Dispatch | Master and worker, C++ | Transfer ownership of the accepted client socket to the worker through the platform control channel. | +| IO readiness | Worker process, C++ | Register idle sockets with `epoll` on Linux, `kqueue` on macOS, or `WSAPoll` on Windows. | +| Request head | Worker process, C++ | Read and parse the request line and headers within configured limits. | +| Request body | `Vajra::NativeInput`, C++ | Buffer body bytes, spill large rewindable bodies, apply watermarks, and unblock Rack reads. | +| Rack execution | Worker process, Ruby | Run the Rack app on a fixed execution pool. | +| Response | Worker process, C++ | Validate Rack response shape, serialize HTTP framing, and write bytes to the client socket. | ## Body Paths @@ -52,10 +52,7 @@ Rack code pulls bytes from `rack.input`. Native producers append bytes as they a ## HTTP/2 Stream Tunnels -Extended CONNECT requests create a Rack environment with -`env["vajra.http2.stream"]`. When the application accepts the stream, Vajra -sends HTTP/2 response headers and switches that request to stream IO. From -there, DATA frames move through the stream object's `read` and `write` methods. +Extended CONNECT requests create a Rack environment with `env["vajra.http2.stream"]`. When the application accepts the stream, Vajra sends HTTP/2 response headers and switches that request to stream IO. From there, DATA frames move through the stream object's `read` and `write` methods. ## Keep-Alive @@ -63,10 +60,14 @@ After a response, reusable HTTP/1.x sockets return to the worker reactor. The co Access logging is outside the request execution path. When access logging is enabled, request threads enqueue compact log events and return to serving. A background logger formats and writes the log lines. +## Platform Handoff + +On POSIX systems, the master sends the accepted descriptor over the worker control socket. On Windows, the master creates a `WSAPROTOCOL_INFOW` payload with `WSADuplicateSocketW`, sends that payload over the worker's framed named pipe, waits for an acknowledgement, and then closes its copy. In both paths, the selected worker becomes the request socket owner after a successful handoff. + ## Code Signposts -- Listener dispatch and worker ownership: `gems/vajra/ext/vajra/runtime/native_runtime.cpp`. +- Listener dispatch and worker ownership: `gems/vajra/ext/vajra/runtime/native_runtime.cpp` on POSIX and `gems/vajra/ext/vajra/runtime/windows_worker_backend.cpp` on Windows. - HTTP/1 request processing: `gems/vajra/ext/vajra/request/request_processor.cpp`. -- Request-head and request-body parsing: `request_head_reader.cpp` and `request_body_reader.cpp`. +- Request-head and request-body parsing: `gems/vajra/ext/vajra/request/request_head_reader.cpp` and `gems/vajra/ext/vajra/request/request_body_reader.cpp`. - Rack execution bridge: `gems/vajra/ext/vajra/rack/ruby_rack_transport.cpp`. -- Response serialization and writing: `response_serializer.cpp` and `response_writer.cpp`. +- Response serialization and writing: `gems/vajra/ext/vajra/response/response_serializer.cpp` and `gems/vajra/ext/vajra/response/response_writer.cpp`. diff --git a/docs/pages/04-architecture/02-runtime-model.md b/docs/pages/04-architecture/02-runtime-model.md index babe3da..d772796 100644 --- a/docs/pages/04-architecture/02-runtime-model.md +++ b/docs/pages/04-architecture/02-runtime-model.md @@ -7,12 +7,12 @@ permalink: /architecture/runtime-model/ # Runtime Model -Vajra keeps listener control in the master process. The master accepts connections, supervises workers, tracks health, and dispatches accepted file descriptors to workers. Workers own sockets after handoff and run request IO, protocol parsing, request-body transport, Rack execution scheduling, and response writing. +Vajra keeps listener control in the master process. The master accepts connections, supervises workers, tracks health, and dispatches accepted sockets to workers. Workers own sockets after handoff and run request IO, protocol parsing, request-body transport, Rack execution scheduling, and response writing. ```mermaid flowchart TD master["Master Process
listener, admission, dispatch, supervision"] - control["Control Channel
FD handoff and lifecycle signals"] + control["Platform Control Channel
socket handoff and lifecycle control"] worker["Worker Process
native IO and Rack execution"] pool["Ruby Execution Pool
fixed Rack threads"] app["Rack App"] @@ -44,18 +44,33 @@ flowchart TD - serialize and write responses - report progress, health, and counters back to runtime state -The master process, not kernel listener balancing, selects workers and manages -worker lifecycle. +The master process, not kernel listener balancing, selects workers and manages worker lifecycle. + +## Platform Runtime Backends + +| Responsibility | Linux | macOS | Windows | +| --------------------- | ----------------------------------------------- | ----------------------------------------------- | ---------------------------------------------------------------------------------------------------- | +| Worker creation | `fork` from the preloaded master | `fork` from the preloaded master | `CreateProcessW` with serialized runtime configuration and an inherited-handle allowlist | +| Control channel | Unix socket | Unix socket | Message-mode named pipe with versioned frames | +| Socket transfer | `SCM_RIGHTS` descriptor passing | `SCM_RIGHTS` descriptor passing | `WSADuplicateSocketW` metadata and worker-side `WSASocketW` reconstruction | +| Connection readiness | `epoll` | `kqueue` | `WSAPoll` | +| Shared runtime state | Anonymous shared `mmap` inherited across `fork` | Anonymous shared `mmap` inherited across `fork` | File mapping handle inherited by workers | +| Parent ownership | Process IDs and signal-driven supervision | Process IDs and signal-driven supervision | Owned process handles grouped in a kill-on-close Job Object | +| Shutdown notification | `SIGINT`/`SIGTERM` and runtime control messages | `SIGINT`/`SIGTERM` and runtime control messages | Console control events or `Vajra.stop`, control frames, and an inherited manual-reset shutdown event | + +The platform backends preserve the same ownership contract: the master owns admission and supervision, workers own dispatched client sockets, and shared runtime state is the source for aggregate worker health and counters. ## Ruby Thread Boundary Ruby threads execute Rack application code. Native worker IO threads do not run application code. They read sockets, parse protocol state, feed native request input, and wake Rack execution when work is ready. -This boundary keeps blocking socket IO outside the Ruby GVL while preserving the standard Rack application contract. +This boundary keeps native socket waits outside the Ruby GVL while preserving the standard Rack application contract. Rack execution and blocking Ruby-facing native input or tunnel calls use explicit GVL boundaries rather than assuming that every operation is GVL-free. ## Code Signposts -- Worker lifecycle and descriptor handoff: `gems/vajra/ext/vajra/runtime/native_runtime.cpp`. +- POSIX worker lifecycle and descriptor handoff: `gems/vajra/ext/vajra/runtime/native_runtime.cpp`. +- Windows worker lifecycle, named-pipe protocol, socket duplication, replacement, and Job Object ownership: `gems/vajra/ext/vajra/runtime/windows_worker_backend.cpp`. +- Windows runtime selection and Ruby lifecycle boundary: `gems/vajra/ext/vajra/runtime/native_runtime_windows.cpp`. - Shared worker state: `gems/vajra/ext/vajra/runtime/worker_pool.hpp`. - Runtime stats and metrics: `gems/vajra/ext/vajra/runtime/runtime_state.cpp`. - Ruby execution pool: `gems/vajra/ext/vajra/rack/ruby_rack_transport.cpp`. diff --git a/docs/pages/04-architecture/03-failure-modes.md b/docs/pages/04-architecture/03-failure-modes.md index 4c601e7..b504170 100644 --- a/docs/pages/04-architecture/03-failure-modes.md +++ b/docs/pages/04-architecture/03-failure-modes.md @@ -7,8 +7,7 @@ permalink: /architecture/failure-modes/ # Failure Modes -Vajra reports failures at explicit runtime boundaries without hidden fallback -behavior. +Vajra reports failures at explicit runtime boundaries without hidden fallback behavior. ```mermaid flowchart LR @@ -30,17 +29,19 @@ flowchart LR ## Boundaries -| Failure | Runtime Behavior | -| --------------------------------- | ------------------------------------------------------------------------------------------------------------ | -| Native extension missing or stale | Package load fails with an actionable error. | -| Listener bind failure | Startup fails before serving requests. | -| Worker boot failure | Startup reports worker readiness failure. | -| Malformed request head | Parser rejects the request with bounded behavior. | -| Oversized request head | Parser rejects the request at the configured limit. | -| Slow client upload | Native input watermarks limit buffered body bytes and force producers to wait for capacity. | -| Oversized request body | Native input fails the body, unblocks Rack readers, and preserves the configured error response behavior. | -| Worker exit while serving | Runtime reports worker failure and follows replacement or shutdown behavior. | -| Shutdown while serving | Listener admission stops, workers drain active Rack execution within `worker_timeout`, idle sockets close, and runtime resources are released. | +| Failure | Runtime Behavior | +| --------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Native extension missing or stale | Package load fails with an actionable error. | +| Listener bind failure | Startup fails before serving requests. | +| Worker boot failure | Startup reports worker readiness failure. | +| Windows worker pipe failure | The receiving endpoint rejects an invalid, stale, oversized, duplicate, or incomplete frame and closes the control exchange. The supervisor replaces the worker when the failed exchange or process state makes it unavailable. | +| Windows socket handoff failure | The master closes its accepted socket copy. A duplication or worker reconstruction rejection can fail only that connection; a failed control exchange makes the worker unavailable and enters bounded replacement. | +| Malformed request head | Parser rejects the request with bounded behavior. | +| Oversized request head | Parser rejects the request at the configured limit. | +| Slow client upload | Native input watermarks limit buffered body bytes and force producers to wait for capacity. | +| Oversized request body | Native input fails the body, unblocks Rack readers, and preserves the configured error response behavior. | +| Worker exit while serving | Runtime reports worker failure and follows replacement or shutdown behavior; Windows replacement attempts stop after the bounded terminal-failure threshold. | +| Shutdown while serving | Listener admission stops, workers drain active Rack execution within `worker_timeout`, idle sockets close, and runtime resources are released. | ## What To Inspect @@ -52,7 +53,7 @@ flowchart LR ## Code Signposts -- Worker lifecycle events and recovery state: `gems/vajra/ext/vajra/runtime/native_runtime.cpp`. +- Worker lifecycle events and recovery state: `gems/vajra/ext/vajra/runtime/native_runtime.cpp` and `gems/vajra/ext/vajra/runtime/windows_worker_backend.cpp`. - Runtime log and lifecycle telemetry emission: `gems/vajra/ext/vajra/runtime/runtime_logging.cpp`. - Stats and metrics state: `gems/vajra/ext/vajra/runtime/runtime_state.cpp`. - Request rejection paths: `gems/vajra/ext/vajra/request/request_processor.cpp` and `http2_session.cpp`. diff --git a/docs/pages/04-architecture/03-native-input.md b/docs/pages/04-architecture/03-native-input.md index 860bece..d77cbb3 100644 --- a/docs/pages/04-architecture/03-native-input.md +++ b/docs/pages/04-architecture/03-native-input.md @@ -59,5 +59,4 @@ HTTP/2 uses the same body abstraction. Consumed-byte accounting is used to relea - HTTP/1 body producer: `gems/vajra/ext/vajra/request/request_body_reader.cpp`. - HTTP/2 body producer and consumed-byte flow control: `gems/vajra/ext/vajra/request/http2_session.cpp`. -The input object must not allocate Ruby strings while holding native locks; read -paths copy native bytes first, then build Ruby strings after releasing mutexes. +The input object must not allocate Ruby strings while holding native locks; read paths copy native bytes first, then build Ruby strings after releasing mutexes. diff --git a/docs/pages/04-architecture/04-protocols.md b/docs/pages/04-architecture/04-protocols.md index b84e4a0..de12759 100644 --- a/docs/pages/04-architecture/04-protocols.md +++ b/docs/pages/04-architecture/04-protocols.md @@ -9,19 +9,19 @@ permalink: /architecture/protocols/ Vajra supports HTTP/1.x over plain TCP, HTTP/1.1 over TLS, HTTP/2 over TLS ALPN, and cleartext h2c when HTTP/2 is enabled. -| Capability | Support | -| ---------------------------------- | -------------------------------------------------------------------------------------- | -| HTTP/1.0 | Supported. Connections close by default unless keep-alive is requested. | -| HTTP/1.1 | Supported. Keep-alive is the default unless the request asks to close. | -| TLS HTTP/1.1 | Supported with configured certificate and private key. | -| HTTP/2 over TLS ALPN | Supported when `tls true` and `http2 true` are set. | -| h2c cleartext HTTP/2 | Supported when `http2 true` is set. Prior knowledge and HTTP/1.1 upgrade are accepted. | -| HTTP/2 server push | Not supported. | -| HTTP/2 priority scheduling | Weighted dependencies and exclusive reprioritization for response and tunnel DATA. | -| HTTP/2 extended CONNECT | Supported through `env["vajra.http2.stream"]`. | -| HTTP/2 WebSocket | Supported as raw WebSocket frame bytes over extended CONNECT. | -| Rack full hijack over HTTP/1.x | `env["rack.hijack"]` on plain HTTP/1.x and TLS HTTP/1.1 requests. | -| Rack partial hijack | Not implemented. | +| Capability | Support | +| ------------------------------ | -------------------------------------------------------------------------------------- | +| HTTP/1.0 | Supported. Connections close by default unless keep-alive is requested. | +| HTTP/1.1 | Supported. Keep-alive is the default unless the request asks to close. | +| TLS HTTP/1.1 | Supported with configured certificate and private key. | +| HTTP/2 over TLS ALPN | Supported when `tls true` and `http2 true` are set. | +| h2c cleartext HTTP/2 | Supported when `http2 true` is set. Prior knowledge and HTTP/1.1 upgrade are accepted. | +| HTTP/2 server push | Not supported. | +| HTTP/2 priority scheduling | Weighted dependencies and exclusive reprioritization for response and tunnel DATA. | +| HTTP/2 extended CONNECT | Supported through `env["vajra.http2.stream"]`. | +| HTTP/2 WebSocket | Supported as raw WebSocket frame bytes over extended CONNECT. | +| Rack full hijack over HTTP/1.x | `env["rack.hijack"]` on plain HTTP/1.x and TLS HTTP/1.1 requests. | +| Rack partial hijack | Not implemented. | ## HTTP/1.x @@ -29,7 +29,7 @@ Workers parse request heads natively, read request bodies into `Vajra::NativeInp ## TLS -TLS is configured on the Vajra listener. TLS startup validates the certificate chain and private key before the listener enters serving state. ALPN controls whether the connection uses HTTP/1.1 or HTTP/2. +TLS is configured on the Vajra listener. Startup loads the configured certificate chain and private key, verifies that the private key matches the certificate, and loads the configured CA file when peer verification is enabled. It does not independently establish that the server certificate chains to a public trust root. ALPN controls whether the connection uses HTTP/1.1 or HTTP/2. ## HTTP/2 @@ -37,20 +37,11 @@ HTTP/2 runs through Vajra's native nghttp2 integration. TLS connections enter HT Request headers create a Rack request context. DATA frames feed the same `Vajra::NativeInput` abstraction used by HTTP/1.x. Flow-control credit is released as Rack consumes body bytes. -Extended CONNECT requests receive `env["vajra.http2.stream"]`, a native stream -object for one HTTP/2 stream. Rack applications call `accept`, then read and -write DATA bytes through that object. For `:protocol = websocket`, Vajra marks -the Rack environment as WebSocket-capable and carries raw WebSocket frame bytes. -It does not parse WebSocket frames. +Extended CONNECT requests receive `env["vajra.http2.stream"]`, a native stream object for one HTTP/2 stream. Rack applications call `accept`, then read and write DATA bytes through that object. For `:protocol = websocket`, Vajra marks the Rack environment as WebSocket-capable and carries raw WebSocket frame bytes. It does not parse WebSocket frames. -Outbound DATA scheduling honors HTTP/2 dependencies, weights, exclusive -reprioritization, and in-flight reprioritization. Normal response DATA and -accepted tunnel DATA use the same scheduler. +Outbound DATA scheduling honors HTTP/2 dependencies, weights, exclusive reprioritization, and in-flight reprioritization. Normal response DATA and accepted tunnel DATA use the same scheduler. -For bidirectional application-owned IO, use -[Rack Hijack](/architecture/rack-hijack/) with HTTP/1.x connections and -[HTTP/2 Stream Tunnels](/architecture/http2-stream-tunnels/) with Extended -CONNECT streams. +For bidirectional application-owned IO, use [Rack Hijack](/architecture/rack-hijack/) with HTTP/1.x connections and [HTTP/2 Stream Tunnels](/architecture/http2-stream-tunnels/) with Extended CONNECT streams. ## HTTP/2 Capabilities Not Implemented @@ -58,7 +49,7 @@ Server push is not implemented. ## Code Signposts -- HTTP/1 parsing and response writing: `request_processor.cpp`, `response_serializer.cpp`, and `response_writer.cpp`. -- TLS context and connection handling: `transport/tls_connection.cpp`. -- HTTP/2 session, validation, flow control, and response submission: `request/http2_session.cpp`. -- HTTP/2 stream tunnel Ruby object: `rack/http2_stream.cpp`. +- HTTP/1 parsing and response writing: `gems/vajra/ext/vajra/request/request_processor.cpp`, `gems/vajra/ext/vajra/response/response_serializer.cpp`, and `gems/vajra/ext/vajra/response/response_writer.cpp`. +- TLS context and connection handling: `gems/vajra/ext/vajra/transport/tls_connection.cpp`. +- HTTP/2 session, validation, flow control, and response submission: `gems/vajra/ext/vajra/request/http2_session.cpp`. +- HTTP/2 stream tunnel Ruby object: `gems/vajra/ext/vajra/rack/http2_stream.cpp`. diff --git a/docs/pages/04-architecture/05-http2-stream-tunnels.md b/docs/pages/04-architecture/05-http2-stream-tunnels.md index 03a03e1..6729e49 100644 --- a/docs/pages/04-architecture/05-http2-stream-tunnels.md +++ b/docs/pages/04-architecture/05-http2-stream-tunnels.md @@ -7,10 +7,7 @@ permalink: /architecture/http2-stream-tunnels/ # HTTP/2 Stream Tunnels -HTTP/2 Extended CONNECT gives Rack applications a full-duplex byte stream -without taking over the whole client connection. Vajra keeps the HTTP/2 session -and its other streams alive; the application works with one -`Vajra::HTTP2::Stream` object. +HTTP/2 Extended CONNECT gives Rack applications a full-duplex byte stream without taking over the whole client connection. Vajra keeps the HTTP/2 session and its other streams alive; the application works with one `Vajra::HTTP2::Stream` object. ## Rack Environment @@ -20,8 +17,7 @@ Valid Extended CONNECT requests expose: - `env["vajra.http2.stream"]` - `env["vajra.http2.websocket"]` -`env["vajra.http2.stream"]` is the `Vajra::HTTP2::Stream` object for that -request. +`env["vajra.http2.stream"]` is the `Vajra::HTTP2::Stream` object for that request. For WebSocket-over-HTTP/2, Vajra sets: @@ -30,8 +26,7 @@ For WebSocket-over-HTTP/2, Vajra sets: - `env["vajra.http2.extended_connect"]` to `true` - `env["vajra.http2.websocket"]` to `true` -Vajra then carries raw WebSocket frame bytes inside HTTP/2 DATA frames. The Rack -application, or its WebSocket library, owns WebSocket framing. +Vajra then carries raw WebSocket frame bytes inside HTTP/2 DATA frames. The Rack application, or its WebSocket library, owns WebSocket framing. ## Stream API @@ -47,18 +42,13 @@ application, or its WebSocket library, owns WebSocket framing. - `protocol` - `stream_id` -Calling `accept` sends the HTTP/2 response headers and puts the request in -tunnel mode. After that point the stream object's `read` and `write` methods -carry DATA frames. If the application never accepts the stream, Vajra serializes -the Rack response as a standard HTTP/2 response. +Calling `accept` sends the HTTP/2 response headers and puts the request in tunnel mode. After that point the stream object's `read` and `write` methods carry DATA frames. If the application never accepts the stream, Vajra serializes the Rack response as a standard HTTP/2 response. ## Flow Control Inbound DATA frames feed a bounded native stream buffer. Rack reads drain that buffer and release HTTP/2 flow-control credit. Outbound writes use the remote stream and connection windows. Blocking reads and writes release the Ruby GVL while waiting for bytes, capacity, EOF, close, or reset. -Accepted tunnels and ordinary response bodies share the HTTP/2 priority -scheduler. Stream weights, dependencies, and exclusive reprioritization apply -to both. +Accepted tunnels and ordinary response bodies share the HTTP/2 priority scheduler. Stream weights, dependencies, and exclusive reprioritization apply to both. Reset and close wake blocked readers and writers deterministically: @@ -88,13 +78,10 @@ end ## Relationship To Rack Hijack -Rack hijack is connection-level and fits HTTP/1.x. HTTP/2 stream tunnels are -stream-level: Ruby gets bidirectional IO for one Extended CONNECT stream while -Vajra keeps managing the shared HTTP/2 connection. +Rack hijack is connection-level and fits HTTP/1.x. HTTP/2 stream tunnels are stream-level: Ruby gets bidirectional IO for one Extended CONNECT stream while Vajra keeps managing the shared HTTP/2 connection. ## Code Signposts - Rack environment object installation: `gems/vajra/ext/vajra/rack/ruby_execution_bridge.cpp`. - Ruby stream API: `gems/vajra/ext/vajra/rack/http2_stream.cpp`. -- HTTP/2 accept, DATA scheduling, reset, and flow-control integration: - `gems/vajra/ext/vajra/request/http2_session.cpp`. +- HTTP/2 accept, DATA scheduling, reset, and flow-control integration: `gems/vajra/ext/vajra/request/http2_session.cpp`. diff --git a/docs/pages/04-architecture/05-rack-hijack.md b/docs/pages/04-architecture/05-rack-hijack.md index 79b79f9..e2028e6 100644 --- a/docs/pages/04-architecture/05-rack-hijack.md +++ b/docs/pages/04-architecture/05-rack-hijack.md @@ -7,16 +7,11 @@ permalink: /architecture/rack-hijack/ # Rack Hijack -Rack full hijack gives an application direct control of a client connection. -Vajra exposes it through `env["rack.hijack"]` for HTTP/1.x requests, including -TLS HTTP/1.1. +Rack full hijack gives an application direct control of a client connection. Vajra exposes it through `env["rack.hijack"]` for HTTP/1.x requests, including TLS HTTP/1.1. ## Full Hijack -The hijack callable is present in plain HTTP/1.0, plain HTTP/1.1, and TLS -HTTP/1.1 Rack environments. Plain HTTP returns the client socket as a Ruby `IO`. -TLS HTTP/1.1 returns an IO-like object backed by Vajra's TLS layer, so Ruby reads -and writes decrypted bytes while Vajra handles encryption on the wire. +The hijack callable is present in plain HTTP/1.0, plain HTTP/1.1, and TLS HTTP/1.1 Rack environments. On POSIX, plain HTTP returns the client descriptor as a Ruby `IO`. On Windows, a Winsock `SOCKET` is not a POSIX file descriptor, so plain HTTP uses Vajra's native socket-backed IO object. TLS HTTP/1.1 uses the same native IO class backed by Vajra's TLS layer, so Ruby reads and writes decrypted bytes while Vajra handles encryption on the wire. After a successful hijack: @@ -30,21 +25,15 @@ The callable is single-use. Calling it twice raises an `IOError`. ## Request Body Requirement -The request body must be drained before hijack. Vajra rejects hijack while -unread body bytes remain, because native parsing has already consumed the -request head and may have buffered body bytes. Returning a raw socket at that -point would risk data loss or protocol corruption. +The request body must be drained before hijack. Vajra rejects hijack while unread body bytes remain, because native parsing has already consumed the request head and may have buffered body bytes. Returning a raw socket at that point would risk data loss or protocol corruption. ## Partial Hijack -Partial hijack through a response header is not implemented. Use full hijack for -raw connection ownership. +Partial hijack through a response header is not implemented. Use full hijack for raw connection ownership. ## HTTP/2 Stream IO -HTTP/2 multiplexes many streams on one connection, so Vajra exposes per-stream -IO through a stream object. Applications that need bidirectional HTTP/2 IO use -[HTTP/2 Stream Tunnels](/architecture/http2-stream-tunnels/). +HTTP/2 multiplexes many streams on one connection, so Vajra exposes per-stream IO through a stream object. Applications that need bidirectional HTTP/2 IO use [HTTP/2 Stream Tunnels](/architecture/http2-stream-tunnels/). ## Code Signposts diff --git a/docs/pages/04-architecture/06-shutdown-drain.md b/docs/pages/04-architecture/06-shutdown-drain.md index c64a44f..f2d6b07 100644 --- a/docs/pages/04-architecture/06-shutdown-drain.md +++ b/docs/pages/04-architecture/06-shutdown-drain.md @@ -7,11 +7,11 @@ permalink: /architecture/shutdown-drain/ # Shutdown And Drain -Vajra shutdown is coordinated from the native runtime. The master stops accepting new work, signals workers, drains active Rack execution up to the configured worker runtime limit, and releases sockets. +Vajra shutdown is coordinated from the native runtime. POSIX `SIGINT`/`SIGTERM`, Windows console control events, and programmatic `Vajra.stop` converge on the same lifecycle. The master stops accepting new work, notifies workers through the platform backend, drains active Rack execution up to the configured worker runtime limit, and releases sockets. ```mermaid flowchart LR - signal["Shutdown signal"] + signal["Shutdown request"] listener["Stop listener admission"] drain["Drain active work"] workers["Stop workers"] @@ -25,31 +25,28 @@ flowchart LR ## Drain Boundary -Drain starts by preventing new accepted connections from entering the request -path. Active Rack application calls have until `worker_timeout` to complete. -Idle keep-alive sockets and incomplete next requests close during drain. +Drain starts by preventing new accepted connections from entering the request path. Active Rack application calls have until `worker_timeout` to complete. Idle keep-alive sockets and incomplete next requests close during drain. ## Worker Stop Workers receive shutdown state through runtime control channels and native runtime state. Each worker stops queueing new connection work, waits for the same-process Rack execution pool to become idle within `worker_timeout`, then exits. If active Rack execution does not drain in that window, worker shutdown falls back to the runtime termination path. +On Windows, Ruby's unblock callback only sets the supervisor stop request and signals its wake event. The supervisor loop owns cleanup: it closes listener admission and queued sockets, sends shutdown frames over worker named pipes, waits up to the shared deadline, terminates workers that exceed it, signals the inherited parent-shutdown event, and releases process and pipe handles. The Job Object remains the final parent-death containment boundary. + ## Hijacked Sockets Hijacked sockets are Ruby-owned after a successful full hijack. Vajra excludes them from keep-alive reuse and native timeout management. Ruby code is responsible for closing the returned `IO`. ## HTTP/2 Stream Tunnels -Accepted HTTP/2 stream tunnels are stream-owned, not socket-owned. During -process shutdown, Vajra resets remaining HTTP/2 streams so shutdown is not held -open by tunnel traffic. Applications should close or reset accepted streams as -part of their own shutdown path. +Accepted HTTP/2 stream tunnels are stream-owned, not socket-owned. During process shutdown, Vajra resets remaining HTTP/2 streams so shutdown is not held open by tunnel traffic. Applications should close or reset accepted streams as part of their own shutdown path. ## Log Rotation -`SIGUSR1` reopens configured access and error log files. Use it after external log rotation so workers write to the replacement files. +On POSIX systems, `SIGUSR1` reopens configured access and error log files. Use it after external log rotation so workers write to the replacement files. Windows does not expose a `SIGUSR1` log-reopen path. ## Code Signposts -- Shutdown coordination and worker drain: `gems/vajra/ext/vajra/runtime/native_runtime.cpp`. +- Shutdown coordination and worker drain: `gems/vajra/ext/vajra/runtime/native_runtime.cpp` on POSIX and `gems/vajra/ext/vajra/runtime/native_runtime_windows.cpp` with `gems/vajra/ext/vajra/runtime/windows_worker_backend.cpp` on Windows. - Rack execution idle wait: `gems/vajra/ext/vajra/rack/ruby_rack_transport.cpp`. - Log reopen handling: `gems/vajra/ext/vajra/runtime/runtime_logging.cpp`. diff --git a/docs/pages/04-command-reference.md b/docs/pages/04-command-reference.md index f4b2cfc..29251a7 100644 --- a/docs/pages/04-command-reference.md +++ b/docs/pages/04-command-reference.md @@ -6,9 +6,7 @@ permalink: /command-reference/ # Command Reference -The `vajra` executable starts a Rack application from the application root. -Rails applications can also start Vajra through the normal Rails server -launcher. +The `vajra` executable starts a Rack application from the application root. Rails applications can also start Vajra through the normal Rails server launcher. ## Synopsis @@ -19,10 +17,10 @@ bundle exec vajra [-C PATH] The executable accepts one option: -| Option | Meaning | -| ------------------- | -------------------------- | -| `-C PATH` | Load Vajra config at PATH. | -| `--config PATH` | Load Vajra config at PATH. | +| Option | Meaning | +| --------------- | -------------------------- | +| `-C PATH` | Load Vajra config at PATH. | +| `--config PATH` | Load Vajra config at PATH. | Unknown options or positional arguments are rejected before startup. @@ -34,8 +32,7 @@ Vajra resolves application startup in this order: 2. `config/vajra.rb` 3. `config.ru` -Use `config/vajra.rb` for Vajra server settings and application loading -directives. Use `config.ru` for ordinary Rack boot. +Use `config/vajra.rb` for Vajra server settings and application loading directives. Use `config.ru` for ordinary Rack boot. ```ruby # config/vajra.rb @@ -48,9 +45,7 @@ Vajra.configure do |config| end ``` -If the config file contains an unsupported directive, startup fails while the -file is loading. This is intentional: unsupported server features should fail -before the native runtime starts. +If the config file contains an unsupported directive, startup fails while the file is loading. This is intentional: unsupported server features should fail before the native runtime starts. ## Rack Applications @@ -67,9 +62,7 @@ Start the server from the application root: bundle exec vajra ``` -Add `config/vajra.rb` only when the application needs explicit Vajra settings -such as listener address, worker count, limits, TLS, logging, metrics, or -tracing. +Add `config/vajra.rb` only when the application needs explicit Vajra settings such as listener address, worker count, limits, TLS, logging, metrics, or tracing. ## Rails Applications @@ -79,8 +72,7 @@ Rails applications use the standard Rails launcher: bin/rails server ``` -Vajra installs a Rails server handler when the gem is in the application bundle. -Add `config/vajra.rb` for server-specific settings. +Vajra installs a Rails server handler when the gem is in the application bundle. Add `config/vajra.rb` for server-specific settings. ```ruby # config/vajra.rb @@ -95,39 +87,35 @@ end ## Startup Banner -On successful startup, Vajra prints the runtime banner and the listener address. -When `port 0` is configured, the operating system chooses an ephemeral port and -the banner reports the actual bound port. +On successful startup, Vajra prints the runtime banner and the listener address. When `port 0` is configured, the operating system chooses an ephemeral port and the banner reports the actual bound port. -Use an explicit `PORT` in production. Use `port 0` for tests and local scripts -that discover the port from the startup output. +Use an explicit `PORT` in production. Use `port 0` for tests and local scripts that discover the port from the startup output. ## Failure Behavior Common startup failures: -| Failure | Boundary | -| ------------------------------- | ---------------------------------------------------- | -| Unknown CLI option | Command-line parsing rejects the process. | -| Unknown config directive | `config/vajra.rb` load fails. | -| Invalid `Vajra.start` keyword | Ruby validation raises before native startup. | -| TLS cert or key missing | Native runtime validation rejects TLS startup. | -| Port already in use | Listener bind fails before serving requests. | -| `config.ru` does not load a app | Rack application loading fails before native startup. | +| Failure | Boundary | +| -------------------------------- | ----------------------------------------------------- | +| Unknown CLI option | Command-line parsing rejects the process. | +| Unknown config directive | `config/vajra.rb` load fails. | +| Invalid `Vajra.start` keyword | Ruby validation raises before native startup. | +| TLS cert or key missing | Native runtime validation rejects TLS startup. | +| Port already in use | Listener bind fails before serving requests. | +| `config.ru` does not load an app | Rack application loading fails before native startup. | See [Troubleshooting](/troubleshooting/) for symptom-based recovery steps. ## Supported Config Directives -The CLI config DSL supports the same native-backed server settings documented in -[Configuration](/configuration/). Application loading directives are: +The CLI config DSL supports the same native-backed server settings documented in [Configuration](/configuration/). Application loading directives are: | Directive | Effect | | --------------- | --------------------------------------- | | `rackup` | Load `config.ru`. | | `rackup "path"` | Load a specific rackup file. | | `rails` | Load `config/environment`. | -| `rails "path"` | Load a specific Rails environment file. | +| `rails "path"` | Load a specific Rails environment file. | | `app object` | Install an explicit Rack app. | | `app { ... }` | Build and install a Rack app. | diff --git a/docs/pages/05-frameworks.md b/docs/pages/05-frameworks.md index 7508b48..569bb40 100644 --- a/docs/pages/05-frameworks.md +++ b/docs/pages/05-frameworks.md @@ -6,9 +6,7 @@ permalink: /frameworks/ # Frameworks -Vajra serves Rack applications. Frameworks keep their normal boot and routing -behavior; Vajra owns the server runtime, listener, request parsing, request body -transport, response writing, and worker lifecycle. +Vajra serves Rack applications. Frameworks keep their normal boot and routing behavior; Vajra owns the server runtime, listener, request parsing, request body transport, response writing, and worker lifecycle. ## Rails @@ -40,11 +38,9 @@ Vajra.configure do |config| end ``` -Keep the database pool at least as large as the per-worker maximum thread count. -For the example above, each worker can run five Rack requests concurrently. +Keep the database pool at least as large as the per-worker maximum thread count. For the example above, each worker can run five Rack requests concurrently. -Rails owns application health routes such as `/up`. Vajra's stats and metrics -endpoints expose runtime internals and should stay private. +Rails owns application health routes such as `/up`. Vajra's stats and metrics endpoints expose runtime internals and should stay private. ## Rack @@ -94,9 +90,7 @@ require_relative "app" run MySinatraApp ``` -Vajra does not change Sinatra routing, middleware, params, or response behavior. -Use the same middleware ordering that the application uses with other Rack -servers. +Vajra does not change Sinatra routing, middleware, params, or response behavior. Use the same middleware ordering that the application uses with other Rack servers. ## Roda @@ -118,8 +112,7 @@ Use `config/vajra.rb` only for server settings. ## Hanami -Hanami applications run through their Rack entrypoint. Keep the framework's -normal boot file and let Vajra load it through `config.ru` or `config.rackup`. +Hanami applications run through their Rack entrypoint. Keep the framework's normal boot file and let Vajra load it through `config.ru` or `config.rackup`. ```ruby # config.ru @@ -127,8 +120,7 @@ require_relative "config/app" run Hanami.app ``` -If the app has framework-specific boot requirements, keep them in Hanami's boot -files rather than in Vajra server config. +If the app has framework-specific boot requirements, keep them in Hanami's boot files rather than in Vajra server config. ## Response Bodies @@ -138,19 +130,13 @@ Rack responses must return the standard three-element response shape: [status, headers, body] ``` -The body must respond to `each`. If it responds to `close`, Vajra calls it after -response conversion. For `HEAD`, `1xx`, `204`, `205`, and `304` responses, Vajra -preserves headers but does not send a response body. +The body must respond to `each`. If it responds to `close`, Vajra calls it after response conversion. For `HEAD`, `1xx`, `204`, `205`, and `304` responses, Vajra preserves headers but does not send a response body. ## Request Bodies -Applications read request bodies through `env["rack.input"]`, a -`Vajra::NativeInput`. It implements `read`, `gets`, `each`, `rewind`, `close`, -and `external_encoding`. +Applications read request bodies through `env["rack.input"]`, a `Vajra::NativeInput`. It implements `read`, `gets`, `each`, `rewind`, `close`, and `external_encoding`. -Large request bodies use native buffering and spill storage. Tune -`max_request_body_bytes`, `request_body_timeout`, and `first_data_timeout` for -public endpoints that accept uploads. +Large request bodies use native buffering and spill storage. Tune `max_request_body_bytes`, `request_body_timeout`, and `first_data_timeout` for public endpoints that accept uploads. ## Troubleshooting diff --git a/docs/pages/05-guides.md b/docs/pages/05-guides.md new file mode 100644 index 0000000..3884c38 --- /dev/null +++ b/docs/pages/05-guides.md @@ -0,0 +1,21 @@ +--- +title: Guides +nav_order: 7 +permalink: /guides/ +has_children: true +--- + +# Guides + +Use these guides to operate, secure, observe, benchmark, migrate, upgrade, troubleshoot, and develop Vajra deployments. + +1. [Observability](/observability/) +2. [Rack Compatibility](/rack-compatibility/) +3. [Production Deployment](/production/) +4. [Security](/security/) +5. [Performance](/performance/) +6. [Migration](/migration/) +7. [Upgrading](/upgrading/) +8. [Compatibility](/compatibility/) +9. [Troubleshooting](/troubleshooting/) +10. [Development](/development/) diff --git a/docs/pages/05-observability.md b/docs/pages/05-guides/01-observability.md similarity index 71% rename from docs/pages/05-observability.md rename to docs/pages/05-guides/01-observability.md index 8454cca..0309117 100644 --- a/docs/pages/05-observability.md +++ b/docs/pages/05-guides/01-observability.md @@ -1,14 +1,13 @@ --- title: Observability -nav_order: 7 +parent: Guides +nav_order: 1 permalink: /observability/ --- # Observability -Vajra exposes request logging, runtime logs, a JSON stats endpoint, a -Prometheus-compatible metrics endpoint, and optional OpenTelemetry request -tracing. +Vajra exposes request logging, runtime logs, a JSON stats endpoint, a Prometheus-compatible metrics endpoint, and optional OpenTelemetry request tracing. ## Logging @@ -27,28 +26,22 @@ Supported access log formats: | Format | Behavior | | ------------ | -------------------------------------------------------------------------------------------------------- | -| `text` | Plain text Vajra access log lines. | +| `text` | Plain text Vajra access log lines. | | `json` | Stable structured access events for ingestion. | | `common` | Common-log-compatible request lines. | | `combined` | Combined-log-compatible request lines with referer and user agent. | | token string | Small custom format using tokens such as `%m`, `%U`, `%s`, `%b`, `%a`, `%H`, `%i`, `%D`, `%T`, and `%S`. | -Structured access logs include method, target, status, duration, response body -bytes, remote address, HTTP protocol, host, user agent, referer, request id, -worker pid/index, connection outcome, and incoming `traceparent` trace/span ids -when present. +Structured access logs include method, target, status, duration, response body bytes, remote address, HTTP protocol, host, user agent, referer, request id, worker pid/index, connection outcome, and incoming `traceparent` trace/span ids when present. -After rotation, send `SIGUSR1` to every Vajra process, including the master and -all workers. Log reopen state is process-local; signalling only one PID leaves -the other processes writing to their previous file descriptors. +On POSIX, send `SIGUSR1` to every Vajra process after rotation, including the master and all workers. Log reopen state is process-local; signalling only one PID leaves the other processes writing to their previous file descriptors. Windows does not implement a runtime log-reopen control event, so use stdout/stderr collection or a supervisor-managed restart instead of external file rotation. ```bash VAJRA_PIDS="1200 1201 1202" # replace with the current master and worker PIDs kill -USR1 ${VAJRA_PIDS} ``` -Use the process IDs reported by your process supervisor or Vajra stats output; -do not use an unscoped process-name match on a shared host. +Use the process IDs reported by your process supervisor or Vajra stats output; do not use an unscoped process-name match on a shared host. ## Stats And Metrics @@ -61,14 +54,9 @@ Vajra.configure do |config| end ``` -The stats endpoint returns JSON with master state, tracing availability, -scheduler pressure, worker health, worker lifecycle, request timing, execution -counts, and restart/replacement counters. +The stats endpoint returns JSON with master state, tracing availability, scheduler pressure, worker health, worker lifecycle, request timing, execution counts, and restart/replacement counters. -The metrics endpoint remains Prometheus text. It includes runtime liveness, -active connections, active/idle executions, accepts, dispatches, completed -requests, request timing totals, local queue depth, worker lifecycle/health -states, replacement counters, timeout escalations, and unexpected exits. +The metrics endpoint returns Prometheus text exposition. It includes runtime liveness, active connections, active/idle executions, accepts, dispatches, completed requests, request timing totals, local queue depth, worker lifecycle/health states, replacement counters, timeout escalations, and unexpected exits. Stats response shape: @@ -106,7 +94,7 @@ Operational fields: | Field | Meaning | | ---------------------------- | ------------------------------------------------ | -| `active_connections` | Worker-owned connections in active processing. | +| `active_connections` | Open connections owned by the worker. | | `active_execution_count` | Rack execution threads running application code. | | `idle_execution_count` | Rack execution threads available for work. | | `local_queue_depth` | Worker-local queued work. | @@ -118,21 +106,17 @@ Operational fields: Stats top-level fields: -| Field | Meaning | -| ---------------------- | ---------------------------------------- | -| `master_pid` | Native runtime master process id. | -| `master_rss_bytes` | Master process RSS when available. | -| `socket_queue_capacity` | Configured pending dispatch capacity. | -| `workers` | Per-worker runtime state array. | -| `profiling` | Cumulative timing and dispatch counters. | -| `native_observability` | Native request/span event counters. | -| `health_counts` | Worker count by health state. | - -Worker fields include `worker_index`, `pid`, `rss_bytes`, connection and -execution counts, queue depth, availability, lifecycle/health/recovery names, -accept/dispatch/receive counters, completed requests, replacement counters, -timeout escalation counters, unexpected exit counters, recovery timing, and -terminal replacement failure state. +| Field | Meaning | +| ----------------------- | ---------------------------------------- | +| `master_pid` | Native runtime master process id. | +| `master_rss_bytes` | Master process RSS when available. | +| `socket_queue_capacity` | Configured pending dispatch capacity. | +| `workers` | Per-worker runtime state array. | +| `profiling` | Cumulative timing and dispatch counters. | +| `native_observability` | Native request/span event counters. | +| `health_counts` | Worker count by health state. | + +Worker fields include `worker_index`, `pid`, `rss_bytes`, connection and execution counts, queue depth, availability, lifecycle/health/recovery names, accept/dispatch/receive counters, completed requests, replacement counters, timeout escalation counters, unexpected exit counters, recovery timing, and terminal replacement failure state. Prometheus metric examples: @@ -181,12 +165,9 @@ Metric catalog: | `vajra_worker_timeout_escalations_total` | Worker timeout escalations. | | `vajra_worker_unexpected_exits_total` | Unexpected worker exits. | -Metric labels are intentionally low cardinality. `worker` is the worker index; -`state` is the current lifecycle or health state. Do not add request path, user, -host, or tenant labels at this layer. +Metric labels are intentionally low cardinality. `worker` is the worker index; `state` is the current lifecycle or health state. Do not add request path, user, host, or tenant labels at this layer. -Use stats for direct runtime inspection and Prometheus metrics for scraping, -alerting, dashboards, and long-term storage. +Use stats for direct runtime inspection and Prometheus metrics for scraping, alerting, dashboards, and long-term storage. Prometheus scrape example: @@ -204,25 +185,24 @@ PromQL examples: ```promql sum(rate(vajra_worker_completed_requests_total[5m])) sum(vajra_worker_local_queue_depth) -sum(vajra_worker_active_executions) / sum(vajra_worker_active_executions + vajra_worker_idle_executions) +sum(vajra_worker_active_executions) / (sum(vajra_worker_active_executions) + sum(vajra_worker_idle_executions)) increase(vajra_worker_unexpected_exits_total[10m]) increase(vajra_worker_timeout_escalations_total[10m]) ``` Alert starting points: -| Alert | Example Condition | -| --------------------------- | ------------------------------------------------------- | -| Queue pressure | `sum(vajra_worker_local_queue_depth) > 0` for 5 minutes | -| Worker exits | `increase(vajra_worker_unexpected_exits_total[10m]) > 0` | +| Alert | Example Condition | +| --------------------------- | ----------------------------------------------------------- | +| Queue pressure | `sum(vajra_worker_local_queue_depth) > 0` for 5 minutes | +| Worker exits | `increase(vajra_worker_unexpected_exits_total[10m]) > 0` | | Timeout escalation | `increase(vajra_worker_timeout_escalations_total[10m]) > 0` | -| No idle execution capacity | `sum(vajra_worker_idle_executions) == 0` for 5 minutes | -| Runtime metrics unavailable | scrape failure or missing `vajra_runtime_up` | +| No idle execution capacity | `sum(vajra_worker_idle_executions) == 0` for 5 minutes | +| Runtime metrics unavailable | scrape failure or missing `vajra_runtime_up` | ## OpenTelemetry -Tracing is optional. Vajra boots when OpenTelemetry gems are absent; tracing is -reported as unavailable until the required SDK/exporter components are present. +Tracing is optional. Vajra boots when OpenTelemetry gems are absent; tracing is reported as unavailable until the required SDK/exporter components are present. ```ruby Vajra.configure do |config| @@ -233,10 +213,7 @@ Vajra.configure do |config| end ``` -When `trace_otel_owner` is false, Vajra uses the application's existing global -OpenTelemetry provider so application and library spans share the same active -Rack context. When it is true, Vajra owns request-span export through its native -OTLP/HTTP pipeline and shuts that native exporter down during `Vajra.stop`. +When `trace_otel_owner` is false, Vajra uses the application's existing global OpenTelemetry provider so application and library spans share the same active Rack context. When it is true, Vajra owns request-span export through its native OTLP/HTTP pipeline and shuts that native exporter down during `Vajra.stop`. Native export requires HTTPS and verifies the collector's certificate chain and hostname. Collector example: @@ -258,10 +235,7 @@ service: exporters: [logging] ``` -Tracing follows the same runtime precedence as the rest of Vajra config: -`VAJRA_*` environment variables override explicit Ruby settings. Standard -`OTEL_*` variables provide tracing defaults when no Vajra-specific setting is -present. Vajra reads: +Tracing follows the same runtime precedence as the rest of Vajra config: `VAJRA_*` environment variables override explicit Ruby settings. Standard `OTEL_*` variables provide tracing defaults when no Vajra-specific setting is present. Vajra reads: - `OTEL_SERVICE_NAME` - `OTEL_RESOURCE_ATTRIBUTES` @@ -272,30 +246,20 @@ present. Vajra reads: - `OTEL_TRACES_SAMPLER` - `OTEL_TRACES_SAMPLER_ARG` -Request spans use stable HTTP server attributes for the active request path: -`http.request.method`, `url.path`, `url.scheme`, -`http.response.status_code` where available, `server.address`, `server.port`, -`network.protocol.name`, and `network.protocol.version`. +Request spans use stable HTTP server attributes for the active request path: `http.request.method`, `url.path`, `url.scheme`, `http.response.status_code` where available, `server.address`, `server.port`, `network.protocol.name`, and `network.protocol.version`. -Native request failures, such as malformed request heads, request-body -disconnects, queue-capacity rejections, queue wait timeouts, and execution -errors, emit server spans with `vajra.request.outcome`, `vajra.failure.kind`, -and `vajra.response.sent`. +Native request failures, such as malformed request heads, request-body disconnects, queue-capacity rejections, queue wait timeouts, and execution errors, emit server spans with `vajra.request.outcome`, `vajra.failure.kind`, and `vajra.response.sent`. -Lifecycle spans use `vajra.` names and include worker attributes such as -`vajra.worker.lifecycle_event`, `vajra.worker.lifecycle_state`, -`health_state`, `recovery_state`, worker index, worker pid, availability, -replacement state, exit classification, and exit detail. +Lifecycle spans use `vajra.` names and include worker attributes such as `vajra.worker.lifecycle_event`, `vajra.worker.lifecycle_state`, `health_state`, `recovery_state`, worker index, worker pid, availability, replacement state, exit classification, and exit detail. Exporter ownership: -| `trace_otel_owner` | Behavior | -| ------------------ | -------- | +| `trace_otel_owner` | Behavior | +| ------------------ | ------------------------------------------------------------------------------------------- | | `false` | Vajra uses the application's OpenTelemetry provider and active Rack context when available. | -| `true` | Vajra owns native OTLP/HTTP export to `trace_endpoint`. | +| `true` | Vajra owns native OTLP/HTTP export to `trace_endpoint`. | -Protect tracing endpoints as internal infrastructure. Spans can include request -paths, hosts, status codes, failure kinds, worker ids, and lifecycle state. +Protect tracing endpoints as internal infrastructure. Spans can include request paths, hosts, status codes, failure kinds, worker ids, and lifecycle state. ## Log Correlation @@ -320,9 +284,7 @@ Structured JSON access logs are the stable ingestion format: } ``` -When an active OpenTelemetry request span exists, Vajra uses that span's -`trace_id` and `span_id` for access-log correlation. If no active span id is -available, Vajra falls back to valid incoming `traceparent` ids. +When an active OpenTelemetry request span exists, Vajra uses that span's `trace_id` and `span_id` for access-log correlation. If no active span id is available, Vajra falls back to valid incoming `traceparent` ids. Common access-log requirements map to Vajra formats: diff --git a/docs/pages/06-rack-compatibility.md b/docs/pages/05-guides/02-rack-compatibility.md similarity index 52% rename from docs/pages/06-rack-compatibility.md rename to docs/pages/05-guides/02-rack-compatibility.md index c356003..feaa4dc 100644 --- a/docs/pages/06-rack-compatibility.md +++ b/docs/pages/05-guides/02-rack-compatibility.md @@ -1,18 +1,17 @@ --- title: Rack Compatibility -nav_order: 8 +parent: Guides +nav_order: 2 permalink: /rack-compatibility/ --- # Rack Compatibility -Vajra presents applications with a standard Rack environment and executes the -Rack app through the normal `call(env)` contract. +Vajra presents applications with a standard Rack environment and executes the Rack app through the normal `call(env)` contract. ## Rack Input -`env["rack.input"]` is a `Vajra::NativeInput` object. It supports the Rack input -methods applications and middleware expect: +`env["rack.input"]` is a `Vajra::NativeInput` object. It supports the Rack input methods applications and middleware expect: - `read` - `gets` @@ -23,15 +22,11 @@ methods applications and middleware expect: Body strings are binary-safe. `external_encoding` returns `ASCII-8BIT`. -`rewind` is available after the request body is complete. Large rewindable -bodies use native spill storage, which keeps Ruby memory from holding the whole -body. +`rewind` is available after the request body is complete. Large rewindable bodies use native spill storage, which keeps Ruby memory from holding the whole body. ## Rack Hijack -HTTP/1.x requests expose `env["rack.hijack"]`. Calling it performs full -hijack. Plain HTTP returns the client socket as a Ruby `IO`; TLS HTTP/1.1 -returns an IO-like object for decrypted connection bytes. +HTTP/1.x requests expose `env["rack.hijack"]`. Calling it performs full hijack. On POSIX, plain HTTP returns the client descriptor as a Ruby `IO`. Windows plain HTTP and TLS HTTP/1.1 use Vajra's native IO object because Winsock sockets are not Ruby file descriptors; TLS reads and writes expose decrypted connection bytes. Full hijack requirements: @@ -44,8 +39,7 @@ Partial hijack through the `rack.hijack` response header is not implemented. ## HTTP/2 Stream Tunnels -HTTP/2 Extended CONNECT requests expose `env["vajra.http2.stream"]`, a -full-duplex object for one HTTP/2 stream. +HTTP/2 Extended CONNECT requests expose `env["vajra.http2.stream"]`, a full-duplex object for one HTTP/2 stream. The stream object supports: @@ -59,10 +53,7 @@ The stream object supports: - `protocol` - `stream_id` -For `:protocol = websocket`, Vajra sets -`env["vajra.http2.websocket"]` to `true` and transports raw WebSocket frame -bytes over HTTP/2 DATA frames. WebSocket frame parsing remains the -application's responsibility. +For `:protocol = websocket`, Vajra sets `env["vajra.http2.websocket"]` to `true` and transports raw WebSocket frame bytes over HTTP/2 DATA frames. WebSocket frame parsing remains the application's responsibility. ## Response Shape @@ -72,13 +63,8 @@ Rack applications return: [status, headers, body] ``` -Vajra validates response status and headers before writing bytes to the client. -Applications should return the Rack response shape and leave HTTP framing, -including `Content-Length` and connection headers, to Vajra. +Vajra validates response status and headers before writing bytes to the client. Applications should return the Rack response shape and leave HTTP framing, including `Content-Length` and connection headers, to Vajra. ## Frameworks -Rails, Sinatra, Roda, and Hanami run through their Rack entrypoints. The -framework owns routing, middleware, and application behavior. Vajra owns the -listener, request transport, response writing, worker lifecycle, and -observability endpoints. +Rails, Sinatra, Roda, and Hanami run through their Rack entrypoints. The framework owns routing, middleware, and application behavior. Vajra owns the listener, request transport, response writing, worker lifecycle, and observability endpoints. diff --git a/docs/pages/07-production.md b/docs/pages/05-guides/03-production.md similarity index 73% rename from docs/pages/07-production.md rename to docs/pages/05-guides/03-production.md index bc2bf53..ca8cb00 100644 --- a/docs/pages/07-production.md +++ b/docs/pages/05-guides/03-production.md @@ -1,24 +1,23 @@ --- title: Production Deployment -nav_order: 10 +parent: Guides +nav_order: 3 permalink: /production/ --- # Production Deployment -Production deployments should make listener ownership, process supervision, -logging, shutdown, and limits explicit. +Production deployments should make listener ownership, process supervision, logging, shutdown, and limits explicit. ## Process Supervision -Run Vajra under a supervisor such as systemd, a container runtime, or a -platform process manager. The supervisor should: +Run Vajra under a supervisor such as systemd, a container runtime, or a platform process manager. The supervisor should: - start the process from the application root - provide environment variables for port, worker count, and trace endpoints - restart the process after unexpected exit - route stdout and stderr to the platform log sink -- send normal termination signals during deploy and shutdown +- request graceful termination during deploy and shutdown with `SIGINT`/`SIGTERM` on POSIX or a Windows console control event Example command: @@ -56,8 +55,7 @@ TimeoutStopSec=70 WantedBy=multi-user.target ``` -Set `TimeoutStopSec` longer than `worker_timeout` so Vajra can drain active -Rack execution before the supervisor sends a hard kill. +Set `TimeoutStopSec` longer than `worker_timeout` so Vajra can drain active Rack execution before the supervisor sends a hard kill. ## Core Runtime Settings @@ -81,8 +79,7 @@ Vajra.configure do |config| end ``` -Tune `workers` for CPU and memory budget. Tune `threads` for the concurrency -profile of the Rack app. IO-heavy apps can use more threads than CPU-heavy apps. +Tune `workers` for CPU and memory budget. Tune `threads` for the concurrency profile of the Rack app. IO-heavy apps can use more threads than CPU-heavy apps. ## TLS @@ -97,14 +94,11 @@ Vajra.configure do |config| end ``` -Certificate and key files must be readable by the runtime user. Use -`tls_verify_mode "peer"` only when the deployment requires client certificate -verification and a CA bundle is configured. +Certificate and key files must be readable by the runtime user. Use `tls_verify_mode "peer"` only when the deployment requires client certificate verification and a CA bundle is configured. ## HTTP/2 -Enable HTTP/2 deliberately on deployments that need TLS ALPN `h2`, cleartext -h2c, Extended CONNECT, or WebSocket-over-HTTP/2: +Enable HTTP/2 deliberately on deployments that need TLS ALPN `h2`, cleartext h2c, Extended CONNECT, or WebSocket-over-HTTP/2: ```ruby Vajra.configure do |config| @@ -113,11 +107,7 @@ Vajra.configure do |config| end ``` -Plain listeners accept h2c prior knowledge and HTTP/1.1 `Upgrade: h2c` when -`http2` is enabled. Extended CONNECT gives applications a bidirectional HTTP/2 -stream object while Vajra keeps the shared connection. Applications using -WebSocket-over-HTTP/2 should close or reset accepted streams during their own -shutdown path. +Plain listeners accept h2c prior knowledge and HTTP/1.1 `Upgrade: h2c` when `http2` is enabled. Extended CONNECT gives applications a bidirectional HTTP/2 stream object while Vajra keeps the shared connection. Applications using WebSocket-over-HTTP/2 should close or reset accepted streams during their own shutdown path. ## Logs @@ -132,8 +122,7 @@ Vajra.configure do |config| end ``` -Send `SIGUSR1` to the master and every worker after external log rotation. -Reopen state is process-local, so signalling one worker is insufficient: +On POSIX, send `SIGUSR1` to the master and every worker after external log rotation. Reopen state is process-local, so signalling one worker is insufficient: ```bash VAJRA_PIDS="1200 1201 1202" # replace with the current master and worker PIDs @@ -142,8 +131,9 @@ kill -USR1 ${VAJRA_PIDS} Obtain the complete PID list from the process supervisor or Vajra stats output. -Container platforms can collect stdout and stderr. Use file logs when the host -handles rotation and retention. +Windows does not implement `SIGUSR1` log reopening. Prefer stdout/stderr collection on Windows, or restart the supervised process when a file-backed sink must be replaced. + +Container platforms can collect stdout and stderr. Use file logs when the host handles rotation and retention. ## Control Plane @@ -156,16 +146,13 @@ Vajra.configure do |config| end ``` -Protect these endpoints at the network or reverse-proxy layer. They expose -runtime process state and operational counters. +Protect these endpoints at the network or reverse-proxy layer. They expose runtime process state and operational counters. -Use an app-owned health route, such as `/up`, for load balancer health checks. -Use `stats_path` and `metrics_endpoint` for internal runtime inspection. +Use an app-owned health route, such as `/up`, for load balancer health checks. Use `stats_path` and `metrics_endpoint` for internal runtime inspection. ## Containers -Build the native extension inside the target Linux image. Do not copy a -host-built extension into a Linux container. +Build the native extension inside the target Linux image. Do not copy a host-built extension into a Linux container. Container checklist: @@ -173,7 +160,7 @@ Container checklist: - run `bundle install` for the application bundle - compile Vajra in the container image - set `PORT`, `WEB_CONCURRENCY`, and trace/log environment variables at deploy -- send termination signals during rollout so Vajra can drain +- request graceful termination during rollout so Vajra can drain Minimal Dockerfile shape: @@ -272,14 +259,8 @@ backend vajra server app1 127.0.0.1:3000 check ``` -If the proxy terminates TLS, keep Vajra on a private listener. If Vajra -terminates TLS directly, configure `tls true`, certificate paths, and ALPN in -`config/vajra.rb`. +If the proxy terminates TLS, keep Vajra on a private listener. If Vajra terminates TLS directly, configure `tls true`, certificate paths, and ALPN in `config/vajra.rb`. ## Shutdown -During shutdown, Vajra stops listener admission, drains active Rack execution -within `worker_timeout`, closes idle keep-alive sockets, and releases native -runtime resources. After full hijack, Ruby owns the returned connection object -and must close it. Accepted HTTP/2 tunnels are stream-owned; Vajra resets any -remaining HTTP/2 streams during process shutdown. +During shutdown, Vajra stops listener admission, drains active Rack execution within `worker_timeout`, closes idle keep-alive sockets, and releases native runtime resources. After full hijack, Ruby owns the returned connection object and must close it. Accepted HTTP/2 tunnels are stream-owned; Vajra resets any remaining HTTP/2 streams during process shutdown. diff --git a/docs/pages/09-security.md b/docs/pages/05-guides/04-security.md similarity index 59% rename from docs/pages/09-security.md rename to docs/pages/05-guides/04-security.md index 9b514f7..8046b62 100644 --- a/docs/pages/09-security.md +++ b/docs/pages/05-guides/04-security.md @@ -1,14 +1,13 @@ --- title: Security -nav_order: 11 +parent: Guides +nav_order: 4 permalink: /security/ --- # Security -Vajra exposes the controls needed to run a Rack application at the network -edge, but the application and deployment still own authentication, -authorization, secret management, and upstream trust boundaries. +Vajra exposes the controls needed to run a Rack application at the network edge, but the application and deployment still own authentication, authorization, secret management, and upstream trust boundaries. ## TLS @@ -23,11 +22,9 @@ Vajra.configure do |config| end ``` -Use a certificate chain file for `tls_certificate`. Keep the private key owned -by the runtime user or group and unreadable by other users. +Use a certificate chain file for `tls_certificate`. Keep the private key owned by the runtime user or group and unreadable by other users. -Use `TLSv1_3` when all clients support it. Use the default `TLSv1_2` minimum -when older clients still need access. +Use `TLSv1_3` when all clients support it. Use the default `TLSv1_2` minimum when older clients still need access. ## Mutual TLS @@ -43,20 +40,13 @@ Vajra.configure do |config| end ``` -Use mTLS only when clients are provisioned with certificates and the application -expects certificate-based trust. Otherwise leave `tls_verify_mode "none"` and -perform authentication at the application layer or reverse proxy. +Use mTLS only when clients are provisioned with certificates and the application expects certificate-based trust. Otherwise leave `tls_verify_mode "none"` and perform authentication at the application layer or reverse proxy. ## Reverse Proxies -When TLS terminates before Vajra, protect the plain listener behind private -networking. Do not expose an unencrypted Vajra listener directly to the internet -unless that is the intended deployment. +When TLS terminates before Vajra, protect the plain listener behind private networking. Do not expose an unencrypted Vajra listener directly to the internet unless that is the intended deployment. -Forward only the headers the application trusts. If the app uses -`X-Forwarded-For`, `Forwarded`, `X-Forwarded-Proto`, or request ids, configure -the proxy and application together so untrusted clients cannot spoof identity or -scheme. +Forward only the headers the application trusts. If the app uses `X-Forwarded-For`, `Forwarded`, `X-Forwarded-Proto`, or request ids, configure the proxy and application together so untrusted clients cannot spoof identity or scheme. ## Request Limits @@ -75,25 +65,20 @@ end Recommended starting points: -| Setting | Protects Against | -| ------------------------ | ---------------------------------------- | +| Setting | Protects Against | +| ------------------------ | ----------------------------------------- | | `max_request_head_bytes` | Oversized request heads and header abuse. | -| `max_request_body_bytes` | Unbounded uploads. | -| `first_data_timeout` | Idle accepted connections. | -| `request_head_timeout` | Slow request head delivery. | -| `request_body_timeout` | Slow request body delivery. | -| `request_timeout` | Excessive queue wait before Rack runs. | +| `max_request_body_bytes` | Unbounded uploads. | +| `first_data_timeout` | Idle accepted connections. | +| `request_head_timeout` | Slow request head delivery. | +| `request_body_timeout` | Slow request body delivery. | +| `request_timeout` | Excessive queue wait before Rack runs. | -`max_request_body_bytes` is a listener-wide Vajra limit. Set it no higher than -the largest route requires, then enforce smaller route-specific limits and -authorization in the application or reverse proxy before accepting expensive -uploads. +`max_request_body_bytes` is a listener-wide Vajra limit. Set it no higher than the largest route requires, then enforce smaller route-specific limits and authorization in the application or reverse proxy before accepting expensive uploads. ## Reporting Vulnerabilities -Do not disclose suspected vulnerabilities in a public issue. Follow the private -reporting process in the repository [Security Policy](https://github.com/Code-Vedas/vajra/security/policy), -which directs reports through GitHub Security Advisories. +Do not disclose suspected vulnerabilities in a public issue. Follow the private reporting process in the repository [Security Policy](https://github.com/Code-Vedas/vajra/security/policy), which directs reports through GitHub Security Advisories. ## Control Plane @@ -106,14 +91,11 @@ Vajra.configure do |config| end ``` -Protect these paths with network policy, a reverse-proxy allowlist, or internal -service discovery. They are not authentication endpoints. +Protect these paths with network policy, a reverse-proxy allowlist, or internal service discovery. They are not authentication endpoints. ## Logs -Access logs can include request targets, host names, user agents, referers, -request ids, worker details, and trace ids. Treat logs as operational data with -the same retention and access controls used for the application. +Access logs can include request targets, host names, user agents, referers, request ids, worker details, and trace ids. Treat logs as operational data with the same retention and access controls used for the application. Use structured logs when logs are ingested by a collector: @@ -125,22 +107,14 @@ Vajra.configure do |config| end ``` -Avoid logging secrets in query strings. If the application accepts tokens in -URLs, sanitize those routes before they reach shared log sinks. +Avoid logging secrets in query strings. If the application accepts tokens in URLs, sanitize those routes before they reach shared log sinks. ## Telemetry -OpenTelemetry spans can include request path, scheme, host, protocol, status, -worker identity, failure kind, and lifecycle state. Export spans only to trusted -collectors. +OpenTelemetry spans can include request path, scheme, host, protocol, status, worker identity, failure kind, and lifecycle state. Export spans only to trusted collectors. -When the application already owns OpenTelemetry setup, keep -`trace_otel_owner false` so Vajra uses the app's provider. Use -`trace_otel_owner true` when Vajra should export native spans to the configured -OTLP/HTTP endpoint. +When the application already owns OpenTelemetry setup, keep `trace_otel_owner false` so Vajra uses the app's provider. Vajra's native OTLP/HTTP exporter requires HTTPS and verifies the collector's certificate chain and hostname. ## Known Limits -Vajra does not implement application authentication, request authorization, -cookie/session policy, CSRF defense, rate limiting, or WAF behavior. Keep those -controls in the application, reverse proxy, or platform layer. +Vajra does not implement application authentication, request authorization, cookie/session policy, CSRF defense, rate limiting, or WAF behavior. Keep those controls in the application, reverse proxy, or platform layer. diff --git a/docs/pages/08-performance.md b/docs/pages/05-guides/05-performance.md similarity index 63% rename from docs/pages/08-performance.md rename to docs/pages/05-guides/05-performance.md index 61066fa..41dd80c 100644 --- a/docs/pages/08-performance.md +++ b/docs/pages/05-guides/05-performance.md @@ -1,13 +1,13 @@ --- title: Performance -nav_order: 12 +parent: Guides +nav_order: 5 permalink: /performance/ --- # Performance -Vajra includes a package-local performance runner that exercises Rack, Rails, -Roda, Sinatra, and Hanami fixtures against Vajra and peer Rack servers. +Vajra includes a package-local performance runner that exercises Rack, Rails, Roda, Sinatra, and Hanami fixtures against Vajra and peer Rack servers. Run from the package performance directory: @@ -28,10 +28,7 @@ Set `DOCKER=1` on any of those scripts to run the profile inside the Linux test ## Reproducing A Run -Use the same operating system, Ruby version, worker count, thread count, and -load profile for every comparison. Native extension behavior and socket -behavior can differ between macOS and Linux, so use Docker or a Linux host for -production-facing claims. +Use the same operating system, Ruby version, worker count, thread count, and load profile for every comparison. Native extension behavior and socket behavior can differ by operating system and Windows ABI, so measure on the exact production target. Checklist: @@ -43,12 +40,12 @@ Checklist: ## Profiles -| Task | Use | -| -------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------- | -| `performance:main`, `scripts/run-performance-main` | Compare Vajra and peer servers across framework fixtures. | -| `performance:observability`, `scripts/run-performance-observability` | Measure Vajra with access logs, structured logs, metrics, and tracing modes. | -| `performance:protocol`, `scripts/run-performance-protocol` | Measure Vajra protocol modes, including HTTP/1, TLS, HTTP/2, h2c, uploads, keep-alive, multiplexing, and tunnels. | -| `performance:run` | Run the main, observability, and protocol profiles. | +| Task | Use | +| -------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------- | +| `performance:main`, `scripts/run-performance-main` | Compare Vajra and peer servers across framework fixtures. | +| `performance:observability`, `scripts/run-performance-observability` | Measure Vajra with access logs, structured logs, metrics, and tracing modes. | +| `performance:protocol`, `scripts/run-performance-protocol` | Measure Vajra protocol modes, including HTTP/1, TLS, HTTP/2, h2c, uploads, keep-alive, multiplexing, and tunnels. | +| `performance:run` | Run the main, observability, and protocol profiles. | ## Workload Shape @@ -65,14 +62,11 @@ The main workload uses a mixed route set: - streamed `rack.input.read` - line-oriented `rack.input.gets` -The workload is intended to expose request parsing, Rack env construction, -request-body handling, response writing, logging, and memory behavior. +The workload is intended to expose request parsing, Rack env construction, request-body handling, response writing, logging, and memory behavior. ## Protocol Workload -The protocol profile measures Vajra transport behavior across connection setup, -request bodies, keep-alive reuse, multiplexing, Rack hijack, and stream tunnels. -It includes: +The protocol profile measures Vajra transport behavior across connection setup, request bodies, keep-alive reuse, multiplexing, Rack hijack, and stream tunnels. It includes: - plain HTTP/1 - TLS HTTP/1.1 @@ -91,15 +85,9 @@ It includes: - TLS HTTP/1.1 keep-alive reuse - TLS HTTP/2 small mixed GET/POST traffic -The profile uses k6 for ordinary HTTP workloads. h2c, same-connection HTTP/2 -multiplexing, Rack hijack, and tunnel lanes use a dedicated protocol driver that -validates frames, response bodies, stream resets, and unexpected connection -closes directly. +The profile uses k6 for ordinary HTTP workloads. h2c, same-connection HTTP/2 multiplexing, Rack hijack, and tunnel lanes use a dedicated protocol driver that validates frames, response bodies, stream resets, and unexpected connection closes directly. -The concurrent-stream and tunnel lanes exercise HTTP/2 priority scheduling, -dependency changes, and tunnel backpressure under constrained HTTP/2 windows. -Use route-level metrics and per-lane latency when comparing protocol behavior; -aggregate throughput alone can hide scheduler or flow-control regressions. +The concurrent-stream and tunnel lanes exercise HTTP/2 priority scheduling, dependency changes, and tunnel backpressure under constrained HTTP/2 windows. Use route-level metrics and per-lane latency when comparing protocol behavior; aggregate throughput alone can hide scheduler or flow-control regressions. ## Output @@ -117,12 +105,11 @@ The top-level `summary.json` records: - error rate - p95 and p99 latency - process-group RSS min, max, and final memory +- Windows process-tree working set, private bytes, and handle counts - route-level throughput and latency - Vajra runtime stats snapshots when available -Use route-level metrics when diagnosing regressions. A single aggregate number -can hide a problem isolated to uploads, line reads, TLS, HTTP/2, response -writing, or observability. +Use route-level metrics when diagnosing regressions. A single aggregate number can hide a problem isolated to uploads, line reads, TLS, HTTP/2, response writing, or observability. ## Comparing Runs @@ -136,32 +123,21 @@ Compare by workload lane, not only by aggregate throughput. Check: - route-level throughput and latency - skipped lanes and skip reasons -Skipped lanes mean the runner could not produce a valid measurement for that -server or mode. Treat them as missing data, not as a pass or a failure, until -the skip reason is understood. +Skipped lanes mean the runner could not produce a valid measurement for that server or mode. Treat them as missing data, not as a pass or a failure, until the skip reason is understood. -Protocol and tunnel lanes can be noisier than simple HTTP lanes because they -exercise connection setup, flow control, stream scheduling, and custom protocol -drivers. Rerun a noisy lane before calling a regression. +Protocol and tunnel lanes can be noisier than simple HTTP lanes because they exercise connection setup, flow control, stream scheduling, and custom protocol drivers. Rerun a noisy lane before calling a regression. ## Linux Validation -Validate Linux behavior in a Linux environment. Native extension behavior, -socket behavior, and scheduler behavior can differ from macOS. +Validate Linux behavior in a Linux environment. Native extension behavior, socket behavior, and scheduler behavior can differ from macOS. -The extension must be compiled inside the Linux environment being measured. -Copying a host-built extension into a Linux container is not a valid -performance run. +The extension must be compiled inside the Linux environment being measured. Copying a host-built extension into a Linux container is not a valid performance run. ## CI Expectations -Performance scripts are useful for release confidence and regression analysis. -They are not a substitute for correctness checks such as C++ tests, RSpec, -h2spec, RBS validation, and the docs build. +Performance scripts are manual tools for release confidence and regression analysis. They live under `gems/vajra/performance/` and are not executed by CI. They are not a substitute for correctness checks such as C++ tests, RSpec, h2spec, RBS validation, and the docs build. -When a performance result is used in docs or release notes, include the artifact -path and the workload axis being claimed. Avoid broad claims from one favorable -route. +When a performance result is used in docs or release notes, include the artifact path and the workload axis being claimed. Avoid broad claims from one favorable route. ## Interpreting Results @@ -175,6 +151,4 @@ Report benchmark claims by axis: - observability overhead - protocol mode -Avoid turning one favorable route or one platform run into a broad product claim. -A production claim should name the workload, platform, Ruby version, worker -count, thread count, duration, and artifact path. +Avoid turning one favorable route or one platform run into a broad product claim. A production claim should name the workload, platform, Ruby version, worker count, thread count, duration, and artifact path. diff --git a/docs/pages/10-migration.md b/docs/pages/05-guides/06-migration.md similarity index 61% rename from docs/pages/10-migration.md rename to docs/pages/05-guides/06-migration.md index 3869d8c..8e89e6e 100644 --- a/docs/pages/10-migration.md +++ b/docs/pages/05-guides/06-migration.md @@ -1,14 +1,13 @@ --- title: Migration -nav_order: 13 +parent: Guides +nav_order: 6 permalink: /migration/ --- # Migration -This page maps common Rack server settings and behaviors to Vajra. Use it as a -checklist when moving an existing app; verify production behavior under the -application's own workload before replacing the current server. +This page maps common Rack server settings and behaviors to Vajra. Use it as a checklist when moving an existing app; verify production behavior under the application's own workload before replacing the current server. ## From Puma @@ -16,7 +15,7 @@ Common setting mappings: | Puma Concept | Vajra Equivalent | | --------------------- | ---------------------------------------- | -| `bind "tcp://..."` | `host` and `port` TCP settings. | +| `bind "tcp://..."` | `host` and `port` TCP settings. | | `port` | `port`. | | `workers` | `workers`. | | `threads min, max` | `threads min, max`. | @@ -25,29 +24,22 @@ Common setting mappings: | Phased restart | Not supported as a public Vajra feature. | | Puma plugins | No Vajra plugin API. | -Vajra uses a native master/worker runtime. The master owns listener admission -and workers own request IO, Rack execution, and response transport after -descriptor handoff. +Vajra uses a native master/worker runtime. The master owns listener admission and workers own request IO, Rack execution, and response transport after platform socket handoff. POSIX transfers descriptors; Windows transfers duplication metadata and reconstructs a worker-owned socket. ## From Passenger -Passenger can integrate with Nginx or Apache and can manage applications behind -those integrations. Vajra is a Rack application server packaged as a Ruby gem. -Run it under a process supervisor, container runtime, or platform process -manager. +Passenger can integrate with Nginx or Apache and can manage applications behind those integrations. Vajra is a Rack application server packaged as a Ruby gem. Run it under a process supervisor, container runtime, or platform process manager. Migration checks: - Replace Passenger process options with Vajra `workers` and `threads`. - Move reverse proxy config to Nginx, Caddy, HAProxy, or the platform. -- Replace Passenger status/admin workflows with `stats_path`, Prometheus - metrics, structured logs, and OpenTelemetry. +- Replace Passenger status/admin workflows with `stats_path`, Prometheus metrics, structured logs, and OpenTelemetry. - Keep app boot in `config.ru`, Rails boot, or `config/vajra.rb`. ## From Falcon -Falcon is built around async Ruby and fiber-oriented IO. Vajra uses native IO -and a fixed Ruby Rack execution pool. +Falcon is built around async Ruby and fiber-oriented IO. Vajra uses native IO and a fixed Ruby Rack execution pool. Migration checks: @@ -58,19 +50,16 @@ Migration checks: ## From Unicorn -Unicorn-style deployments often rely on process count and external buffering. -When moving to Vajra: +Unicorn-style deployments often rely on process count and external buffering. When moving to Vajra: - Start with fewer workers and more Rack threads if the app is thread-safe. - Review database pool size per worker. - Configure request body limits and timeouts explicitly. -- Replace signal/restart workflows with the supervisor's normal restart path - and Vajra shutdown drain behavior. +- Replace signal/restart workflows with the supervisor's normal restart path and Vajra shutdown drain behavior. ## From Thin Or WEBrick -Thin and WEBrick development setups usually have minimal server config. Move the -application boot into `config.ru`, add Vajra to the bundle, and start with: +Thin and WEBrick development setups usually have minimal server config. Move the application boot into `config.ru`, add Vajra to the bundle, and start with: ```bash bundle exec vajra @@ -89,15 +78,15 @@ end ## Behavior Differences To Check -| Area | Vajra Behavior | -| --------------------- | ------------------------------------------------------------ | -| Config precedence | `VAJRA_*` environment variables override Ruby config. | -| Request body object | Rack receives `Vajra::NativeInput`. | -| HTTP/1 hijack | Full hijack is available through `env["rack.hijack"]`. | -| HTTP/2 bidirectional | Extended CONNECT uses `env["vajra.http2.stream"]`. | -| Partial hijack | `rack.hijack` response-header partial hijack is unsupported. | -| No-body responses | `HEAD`, `1xx`, `204`, `205`, and `304` do not send bodies. | -| Server push | HTTP/2 server push is unsupported. | +| Area | Vajra Behavior | +| -------------------- | ------------------------------------------------------------ | +| Config precedence | `VAJRA_*` environment variables override Ruby config. | +| Request body object | Rack receives `Vajra::NativeInput`. | +| HTTP/1 hijack | Full hijack is available through `env["rack.hijack"]`. | +| HTTP/2 bidirectional | Extended CONNECT uses `env["vajra.http2.stream"]`. | +| Partial hijack | `rack.hijack` response-header partial hijack is unsupported. | +| No-body responses | `HEAD`, `1xx`, `204`, `205`, and `304` do not send bodies. | +| Server push | HTTP/2 server push is unsupported. | ## Migration Checklist @@ -108,5 +97,4 @@ end 5. Configure logs, stats, metrics, and tracing. 6. Run the app's request, upload, streaming, WebSocket, and shutdown tests. 7. Deploy behind the same reverse proxy or platform routing used in production. -8. Watch queue depth, worker health, request latency, errors, and unexpected - exits during the first rollout. +8. Watch queue depth, worker health, request latency, errors, and unexpected exits during the first rollout. diff --git a/docs/pages/12-upgrading.md b/docs/pages/05-guides/07-upgrading.md similarity index 74% rename from docs/pages/12-upgrading.md rename to docs/pages/05-guides/07-upgrading.md index 86a4175..2386b4a 100644 --- a/docs/pages/12-upgrading.md +++ b/docs/pages/05-guides/07-upgrading.md @@ -1,14 +1,13 @@ --- title: Upgrading -nav_order: 14 +parent: Guides +nav_order: 7 permalink: /upgrading/ --- # Upgrading -Use the changelog and the application test suite together. Vajra is a native -server runtime, so upgrades should validate Ruby behavior, native extension -loading, protocol behavior, and deployment shutdown. +Use the changelog and the application test suite together. Vajra is a native server runtime, so upgrades should validate Ruby behavior, native extension loading, protocol behavior, and deployment shutdown. ## Before Upgrading @@ -42,15 +41,13 @@ bin/rails server Review these areas on every upgrade: - listener settings: `host`, `port` -- concurrency: `workers`, `threads`, `max_connections`, - `socket_queue_capacity` +- concurrency: `workers`, `threads`, `max_connections`, `socket_queue_capacity` - request limits and timeouts - TLS and HTTP/2 settings - access/error log settings - stats, metrics, and tracing settings -`VAJRA_*` environment variables override Ruby config. Check deployment -environment before assuming a value in `config/vajra.rb` is active. +`VAJRA_*` environment variables override Ruby config. Check deployment environment before assuming a value in `config/vajra.rb` is active. ## Validation @@ -60,8 +57,7 @@ Run the app's normal test suite, then add server-focused checks: curl -f http://127.0.0.1:3000/ ``` -When the optional control-plane endpoints are enabled, validate their configured -paths as separate checks: +When the optional control-plane endpoints are enabled, validate their configured paths as separate checks: ```bash stats_path=/__vajra/stats @@ -86,17 +82,12 @@ Deploy one environment at a time. Watch: - unexpected exits - application errors -Stop rollout and rollback if worker replacement failures, unexpected exits, or -request errors increase beyond the application's normal baseline. +Stop rollout and rollback if worker replacement failures, unexpected exits, or request errors increase beyond the application's normal baseline. ## Rollback -Rollback by restoring the previous bundle and runtime config, then restarting -the process through the platform supervisor. Do not reuse a native extension -built for a different gem version or target platform. +Rollback by restoring the previous bundle and runtime config, then restarting the process through the platform supervisor. Do not reuse a native extension built for a different gem version or target platform. ## Compatibility Policy -The docs describe current supported behavior. If a release changes public -configuration, Rack environment objects, native APIs, or protocol behavior, the -change should be reflected in the changelog and the relevant docs page. +The docs describe current supported behavior. If a release changes public configuration, Rack environment objects, native APIs, or protocol behavior, the change should be reflected in the changelog and the relevant docs page. diff --git a/docs/pages/05-guides/08-compatibility.md b/docs/pages/05-guides/08-compatibility.md new file mode 100644 index 0000000..2344d25 --- /dev/null +++ b/docs/pages/05-guides/08-compatibility.md @@ -0,0 +1,86 @@ +--- +title: Compatibility +parent: Guides +nav_order: 8 +permalink: /compatibility/ +--- + +# Compatibility + +This page lists Vajra's documented support surface and known limitations. It is grounded in the current gem, CLI, RBS, and native runtime configuration surface. + +## Ruby And Rack + +| Area | Status | +| ------- | ------------------------------------------- | +| Ruby | Ruby 3.2 or newer. | +| Rack | Standard Rack three-element response shape. | +| Rails | Supported through Vajra's Rails handler. | +| Sinatra | Supported through Rack. | +| Roda | Supported through Rack. | +| Hanami | Supported through Rack. | + +## Platforms + +| Platform | Status | +| -------------------- | ----------------------------------------------------- | +| Linux | Primary production and performance validation target. | +| macOS | Supported development and local testing target. | +| Windows 10+ | Supported with 64-bit RubyInstaller UCRT Ruby. | +| Windows Server 2022+ | Supported with 64-bit RubyInstaller UCRT Ruby. | + +Native extensions are ABI-specific. Do not copy extensions between operating systems or Ruby installations. Windows supports `x64-mingw-ucrt`; MSVC-built Ruby (`x64-mswin64`) is not supported. + +## Protocols + +| Protocol Or Mode | Status | +| ----------------------- | --------------------------------------------------- | +| HTTP/1.0 | Supported. | +| HTTP/1.1 | Supported. | +| TLS HTTP/1.1 | Supported with certificate and private key. | +| HTTP/2 over TLS ALPN | Supported when `tls true` and `http2 true` are set. | +| h2c prior knowledge | Supported when `http2 true` is set. | +| HTTP/1.1 `Upgrade: h2c` | Supported when `http2 true` is set. | +| HTTP/2 Extended CONNECT | Supported through `Vajra::HTTP2::Stream`. | +| WebSocket-over-HTTP/2 | Supported as raw WebSocket frames over HTTP/2 DATA. | +| HTTP/2 server push | Not supported. | + +## Rack Features + +| Feature | Status | +| ----------------------- | ---------------------------------------------------------- | +| `rack.input` | `Vajra::NativeInput`. | +| Full Rack hijack | Supported for HTTP/1.x, including TLS HTTP/1.1. | +| Partial hijack | Not supported. | +| HTTP/2 bidirectional IO | Supported through Extended CONNECT stream tunnels. | +| Rewindable request body | Supported after body completion through native buffering. | +| `HEAD` responses | Headers are preserved; response body DATA is suppressed. | +| No-body statuses | `1xx`, `204`, `205`, and `304` do not send message bodies. | + +## Server Features + +| Feature | Status | +| ---------------------- | ------------------------------------------- | +| TCP host and port bind | Supported through `host` and `port`. | +| Unix socket bind | Not in the public supported config surface. | +| Worker processes | Supported through `workers`. | +| Rack execution threads | Supported through `threads`. | +| Access/error logs | Supported. | +| Stats endpoint | Supported. | +| Prometheus metrics | Supported. | +| OpenTelemetry tracing | Supported when configured. | +| Daemonization | Use an external supervisor. | +| Phased restart | Not a public Vajra feature. | +| Plugin API | Not provided. | + +## Config Surface + +Supported runtime settings are listed in [Configuration](/configuration/). Unknown `Vajra.start` keywords fail as unknown start options. Unknown `config/vajra.rb` directives fail while loading configuration. + +## Known Limitations + +- Control-plane endpoints are plain Rack routes and must be protected by the deployment. +- HTTP/2 stream tunnels expose byte streams; WebSocket framing remains the application's responsibility. +- Full hijack requires the request body to be fully consumed first. +- Native extension behavior should be validated on the same OS and architecture used in production. +- Vajra does not support its `reuse_port` option on Windows; configuration fails during startup instead of silently changing listener behavior. diff --git a/docs/pages/06-troubleshooting.md b/docs/pages/05-guides/09-troubleshooting.md similarity index 69% rename from docs/pages/06-troubleshooting.md rename to docs/pages/05-guides/09-troubleshooting.md index 7af3a8a..c0bd022 100644 --- a/docs/pages/06-troubleshooting.md +++ b/docs/pages/05-guides/09-troubleshooting.md @@ -1,23 +1,20 @@ --- title: Troubleshooting -nav_order: 16 +parent: Guides +nav_order: 9 permalink: /troubleshooting/ --- # Troubleshooting -Use this page to triage package setup, native extension builds, executable boot -failures, docs-site problems, and repository checks. +Use this page to triage package setup, native extension builds, executable boot failures, docs-site problems, and repository checks. -The fastest way to use this page is to identify the symptom first, then jump to -the boundary that owns it: package setup, native build, executable boot, docs -build, or repository checks. +The fastest way to use this page is to identify the symptom first, then jump to the boundary that owns it: package setup, native build, executable boot, docs build, or repository checks. ## Common Starting Points - if `require "vajra"` fails, start with the native extension build path -- if the executable exits before binding the port, start with package setup and - local runtime boot +- if the executable exits before binding the port, start with package setup and local runtime boot - if docs changes do not render, start with the Jekyll workflow under `docs/` - if repository checks fail, start with `scripts/run-all` @@ -27,8 +24,7 @@ When opening an issue or debugging an incident, capture: - Vajra version and Ruby version - `config/vajra.rb` with secrets removed -- active `VAJRA_*`, `RACK_ENV`, `RAILS_ENV`, `PORT`, `WEB_CONCURRENCY`, and - tracing environment variables +- active `VAJRA_*`, `RACK_ENV`, `RAILS_ENV`, `PORT`, `WEB_CONCURRENCY`, and tracing environment variables - startup logs and lifecycle logs - access/error log excerpts for the failing window - stats endpoint output if enabled @@ -44,8 +40,7 @@ cd gems/vajra bundle exec rake compile ``` -The package raises an explicit load error when the compiled extension cannot be -found or loaded through the canonical path. +The package raises an explicit load error when the compiled extension cannot be found or loaded through the canonical path. ## Port `3000` Is Already In Use @@ -82,8 +77,7 @@ If `bundle exec vajra` fails in an app root: - check `config/vajra.rb` first - if no Vajra config file is present, check `config.ru` -- for Rails, confirm `config/environment.rb` loads and defines - `Rails.application` +- for Rails, confirm `config/environment.rb` loads and defines `Rails.application` - for Rack-first frameworks, confirm `config.ru` returns a valid Rack app If `bin/rails server` says: @@ -96,16 +90,13 @@ confirm that: - `gem "vajra"` is in the application bundle - Vajra's Railtie has been loaded through Bundler -- the app is not forcing another Rack server through conflicting environment - variables or server flags +- the app is not forcing another Rack server through conflicting environment variables or server flags -Rails applications using Vajra do not need another Rack server gem for the -server command. +Rails applications using Vajra do not need another Rack server gem for the server command. ## Lifecycle Log Looks Like The Master Is Serving Requests -If the logs show a serving transition from the runtime process, read the -ownership fields carefully: +If the logs show a serving transition from the runtime process, read the ownership fields carefully: - `process_role` identifies which process emitted the event - `request_execution_role` identifies which role executes application requests @@ -126,22 +117,17 @@ It means: Tracing is optional. If `trace_enabled` is true but no spans are exported: - install the OpenTelemetry SDK and exporter gems when the application owns OTel -- set `trace_otel_owner true` only when Vajra should use native OTLP/HTTP export +- set `trace_otel_owner true` only when Vajra should use native OTLP/HTTP export over HTTPS - check `OTEL_EXPORTER_OTLP_ENDPOINT` or `VAJRA_TRACE_ENDPOINT` - set `OTEL_TRACES_EXPORTER=otlp`; `none` disables trace exporting - confirm the stats endpoint reports tracing as enabled and available - confirm the collector accepts OTLP/HTTP on the configured endpoint -When an application already owns OpenTelemetry setup, leave -`trace_otel_owner` disabled and configure the provider in the application. +When an application already owns OpenTelemetry setup, leave `trace_otel_owner` disabled and configure the provider in the application. -If the collector is reachable but has no spans, check that its pipeline has an -OTLP receiver and a traces pipeline. For local collector testing, use -`OTEL_EXPORTER_OTLP_ENDPOINT=http://127.0.0.1:4318`. +If the collector is reachable but has no spans, check that its pipeline has an OTLP receiver and a traces pipeline. Vajra-owned native export requires a collector certificate trusted by OpenSSL's configured trust store and valid for the configured endpoint hostname. -If lifecycle spans are missing but request spans exist, confirm lifecycle -telemetry callback installation ran before native startup and that -`trace_otel_owner` matches the intended exporter ownership model. +If lifecycle spans are missing but request spans exist, confirm lifecycle telemetry callback installation ran before native startup and that `trace_otel_owner` matches the intended exporter ownership model. ## Latency Is High @@ -155,21 +141,15 @@ Check these signals: - application database pool saturation - reverse-proxy queueing headers if present -If queue depth grows while idle executions are zero, increase worker or thread -capacity only after checking database and downstream service capacity. +If queue depth grows while idle executions are zero, increase worker or thread capacity only after checking database and downstream service capacity. ## Worker Keeps Restarting -Inspect lifecycle logs, `vajra_worker_unexpected_exits_total`, -`vajra_worker_timeout_escalations_total`, and stats fields for replacement -attempts and terminal replacement failure. A worker that exits before ready is -usually an application boot problem. A worker that times out while active needs -application-level latency and blocking IO inspection. +Inspect lifecycle logs, `vajra_worker_unexpected_exits_total`, `vajra_worker_timeout_escalations_total`, and stats fields for replacement attempts and terminal replacement failure. A worker that exits before ready is usually an application boot problem. A worker that times out while active needs application-level latency and blocking IO inspection. ## HTTP/1 WebSockets or `rack.hijack` Fail to Connect -Vajra supports connection hijacking via `env['rack.hijack']` for HTTP/1.x -requests. If this is failing: +Vajra supports connection hijacking via `env['rack.hijack']` for HTTP/1.x requests. If this is failing: - use an HTTP/1.x client - for TLS clients, offer ALPN `http/1.1` @@ -177,27 +157,21 @@ requests. If this is failing: - call the hijack proc only once - use full hijack, not a `rack.hijack` response header -See [Rack Hijack](/architecture/rack-hijack/) and -[Rack Compatibility](/rack-compatibility/) for the contract. +See [Rack Hijack](/architecture/rack-hijack/) and [Rack Compatibility](/rack-compatibility/) for the contract. ## HTTP/2 WebSockets or Extended CONNECT Fail -HTTP/2 Extended CONNECT exposes a stream object at -`env["vajra.http2.stream"]`. For WebSocket-over-HTTP/2, -`env["vajra.http2.websocket"]` is `true` and the stream carries raw WebSocket -frame bytes. +HTTP/2 Extended CONNECT exposes a stream object at `env["vajra.http2.stream"]`. For WebSocket-over-HTTP/2, `env["vajra.http2.websocket"]` is `true` and the stream carries raw WebSocket frame bytes. If an HTTP/2 tunnel fails: - enable HTTP/2 with `http2 true` - use TLS ALPN `h2` or cleartext h2c -- send an Extended CONNECT request with `:method = CONNECT` and a `:protocol` - pseudo-header +- send an Extended CONNECT request with `:method = CONNECT` and a `:protocol` pseudo-header - call `env["vajra.http2.stream"].accept` before writing tunnel bytes - handle WebSocket framing in the application or WebSocket library -See [HTTP/2 Stream Tunnels](/architecture/http2-stream-tunnels/) for the -stream contract. +See [HTTP/2 Stream Tunnels](/architecture/http2-stream-tunnels/) for the stream contract. ## TLS Startup Fails @@ -209,13 +183,11 @@ TLS startup validates credentials before the listener enters serving state. Chec - `tls_min_version` is `TLSv1_2` or `TLSv1_3` - file permissions allow the Vajra worker process to read the credential files -For local self-signed testing, set `tls_verify_mode "none"` on the server and -disable verification in the test client. +For local self-signed testing, set `tls_verify_mode "none"` on the server and disable verification in the test client. ## HTTP/2 Negotiation Fails -Enable HTTP/2 with `http2 true`. TLS listeners use ALPN and plain listeners -accept h2c prior knowledge and HTTP/1.1 upgrade: +Enable HTTP/2 with `http2 true`. TLS listeners use ALPN and plain listeners accept h2c prior knowledge and HTTP/1.1 upgrade: ```ruby Vajra.configure do |config| @@ -225,11 +197,9 @@ Vajra.configure do |config| end ``` -For TLS, confirm the client offered `h2` in ALPN and that `http2` is enabled. If -startup fails, remove `h2` from `alpn_protocols` or enable `http2`. +For TLS, confirm the client offered `h2` in ALPN and that `http2` is enabled. If startup fails, remove `h2` from `alpn_protocols` or enable `http2`. -Set the local listener port, then use `curl` to confirm ALPN and request handling -against a self-signed endpoint or a cleartext h2c endpoint: +Set the local listener port, then use `curl` to confirm ALPN and request handling against a self-signed endpoint or a cleartext h2c endpoint: ```bash VAJRA_PORT=3000 # replace with the configured listener port @@ -237,12 +207,9 @@ curl --http2 --insecure "https://localhost:${VAJRA_PORT}/" curl --http2-prior-knowledge "http://localhost:${VAJRA_PORT}/" ``` -An HTTP/1.1 upgrade client must send `Upgrade: h2c`, `Connection: Upgrade, -HTTP2-Settings`, and one valid `HTTP2-Settings` header. +An HTTP/1.1 upgrade client must send `Upgrade: h2c`, `Connection: Upgrade, HTTP2-Settings`, and one valid `HTTP2-Settings` header. -Extended CONNECT is advertised through HTTP/2 settings when `http2 true` is -enabled. Clients that require RFC 8441 WebSocket-over-HTTP/2 support must wait -for that setting before opening the tunnel. +Extended CONNECT is advertised through HTTP/2 settings when `http2 true` is enabled. Clients that require RFC 8441 WebSocket-over-HTTP/2 support must wait for that setting before opening the tunnel. Use `h2spec` for external protocol conformance checks: @@ -252,10 +219,7 @@ export PATH="$HOME/go/bin:$PATH" scripts/run-h2spec-all ``` -If HTTP/2 clients reset streams after negotiation succeeds, check for invalid -request headers, mismatched `content-length`, unsupported request shape, or an -application exception during Rack execution. Use the access log outcome, runtime -error logs, and h2 client debug output together. +If HTTP/2 clients reset streams after negotiation succeeds, check for invalid request headers, mismatched `content-length`, unsupported request shape, or an application exception during Rack execution. Use the access log outcome, runtime error logs, and h2 client debug output together. ## Large Uploads Fail @@ -272,8 +236,7 @@ For full hijack, consume the request body before calling `env["rack.hijack"]`. ## Access Logs Do Not Rotate -Vajra reopens configured access and error log files on `SIGUSR1`. Send the -signal to the master and every worker because reopen state is process-local: +On POSIX, Vajra reopens configured access and error log files on `SIGUSR1`. Send the signal to the master and every worker because reopen state is process-local: ```bash VAJRA_PIDS="1200 1201 1202" # replace with the current master and worker PIDs @@ -282,12 +245,11 @@ kill -USR1 ${VAJRA_PIDS} Obtain the complete PID list from the process supervisor or Vajra stats output. -`/dev/null` and `nil` access logs remain disabled. If a reopened file cannot be -opened, Vajra keeps the previous sink and writes a diagnostic to stderr. +Windows does not implement `SIGUSR1` log reopening. Use stdout/stderr collection or restart the supervised process when replacing file-backed logs. -If access or error logs do not open on startup, check that the parent directory -exists and that the runtime user can write to it. Use an absolute path when the -working directory differs between local execution, Rails, and process managers. +`/dev/null` and `nil` access logs remain disabled. If a reopened file cannot be opened, Vajra keeps the previous sink and writes a diagnostic to stderr. + +If access or error logs do not open on startup, check that the parent directory exists and that the runtime user can write to it. Use an absolute path when the working directory differs between local execution, Rails, and process managers. ## Clean Checkout Fails @@ -309,8 +271,7 @@ cd docs bundle exec jekyll build ``` -If the docs build fails, fix the problem in `docs/`. Repository and package docs -use the same canonical paths, commands, and product naming. +If the docs build fails, fix the problem in `docs/`. Repository and package docs use the same canonical paths, commands, and product naming. ## Repository Shape diff --git a/docs/pages/05-development.md b/docs/pages/05-guides/10-development.md similarity index 88% rename from docs/pages/05-development.md rename to docs/pages/05-guides/10-development.md index a969871..3d42b30 100644 --- a/docs/pages/05-development.md +++ b/docs/pages/05-guides/10-development.md @@ -1,6 +1,7 @@ --- title: Development -nav_order: 17 +parent: Guides +nav_order: 10 permalink: /development/ --- @@ -10,7 +11,7 @@ Vajra development uses one canonical package, package-local checks, central docs ## Prerequisites -- Linux or macOS +- Linux, macOS, or 64-bit Windows - Ruby 3.2 or newer - Bundler - a C++ compiler and Ruby development headers for native extension work @@ -18,6 +19,8 @@ Vajra development uses one canonical package, package-local checks, central docs - `k6` for performance profiles outside the Linux test image - Docker for running repository checks inside the Linux test image +Windows native development requires a 64-bit RubyInstaller UCRT Ruby with its matching MSYS2 UCRT toolchain. MSVC-built Ruby is not supported. + Install h2spec with Go: ```bash @@ -34,8 +37,7 @@ scripts/ci-install-bundles scripts/run-all ``` -Those scripts run the gem and docs checks from one shared entrypoint. -`scripts/run-all` includes external HTTP/2 conformance checks through h2spec. +Those scripts run the gem and docs checks from one shared entrypoint. `scripts/run-all` includes external HTTP/2 conformance checks through h2spec. For Linux validation from another host platform, set `DOCKER=1` on any root script: @@ -108,10 +110,7 @@ scripts/run-h2spec-all `scripts/run-h2spec-all` starts a temporary h2c-enabled Vajra server and runs the full h2spec suite against it. -`scripts/run-performance-protocol` runs the protocol benchmark lanes. It uses k6 -for ordinary HTTP/1 and TLS HTTP/2 lanes, and a custom protocol driver for h2c, -h2c upgrade, upload, Rack hijack, tunnels, and same-connection HTTP/2 concurrent -stream coverage. +`scripts/run-performance-protocol` runs the protocol benchmark lanes. It uses k6 for ordinary HTTP/1 and TLS HTTP/2 lanes, and a custom protocol driver for h2c, h2c upgrade, upload, Rack hijack, tunnels, and same-connection HTTP/2 concurrent stream coverage. Docs are part of the product surface: diff --git a/docs/pages/11-api-reference.md b/docs/pages/11-api-reference.md index d06d3fb..4e3cfef 100644 --- a/docs/pages/11-api-reference.md +++ b/docs/pages/11-api-reference.md @@ -1,6 +1,6 @@ --- title: API Reference -nav_order: 9 +nav_order: 8 permalink: /api-reference/ --- @@ -10,111 +10,91 @@ permalink: /api-reference/ The top-level Ruby API owns configuration and the blocking server lifecycle. -| Method | Returns | Behavior | -| -------------------- | ------- | -------- | -| `Vajra.configure` | block result | Available only while the CLI loads `config/vajra.rb`; yields the current configuration object or evaluates a zero-argument block against it. | -| `Vajra.start(**options)` | `nil` | Validates options, installs Rack execution and tracing, then blocks while the native runtime serves. It returns after shutdown and worker draining complete. | -| `Vajra.stop` | `nil` | Requests graceful native shutdown. It may be called from another Ruby thread while `Vajra.start` is blocking. | +| Method | Returns | Behavior | +| ------------------------ | ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `Vajra.configure` | block result | Available only while the CLI loads `config/vajra.rb`; yields the current configuration object or evaluates a zero-argument block against it. | +| `Vajra.start(**options)` | `nil` | Validates options, installs Rack execution and tracing, then blocks while the native runtime serves. It returns after shutdown and worker draining complete. | +| `Vajra.stop` | `nil` | Requests graceful native shutdown. It may be called from another Ruby thread while `Vajra.start` is blocking. | -Invalid Ruby options raise `Vajra::Error` before native startup. Native startup -or shutdown failures raise `RuntimeError` with an `Unable to start Vajra` or -`Unable to stop Vajra` prefix. `Vajra.stop` requests shutdown; the thread -running `Vajra.start` remains the owner of final drain and telemetry cleanup. +Invalid Ruby options raise `Vajra::Error` before native startup. Native startup or shutdown failures raise `RuntimeError` with an `Unable to start Vajra` or `Unable to stop Vajra` prefix. `Vajra.stop` requests shutdown; the thread running `Vajra.start` remains the owner of final drain and telemetry cleanup. -The configuration keys and defaults are documented in -[Configuration]({% link pages/03-configuration.md %}). +The configuration keys and defaults are documented in [Configuration]({% link pages/03-configuration.md %}). ## Rack APIs -Vajra keeps application APIs Rack-compatible. The classes below are native -objects exposed in the Rack environment for request-body transport, full hijack, -and HTTP/2 stream tunnels. +Vajra keeps application APIs Rack-compatible. The classes below are native objects exposed in the Rack environment for request-body transport, full hijack, and HTTP/2 stream tunnels. ## `Vajra::NativeInput` `Vajra::NativeInput` is exposed as `env["rack.input"]`. -| Method | Returns | Behavior | -| ------------------------- | -------------------------- | ----------------------------------------- | -| `read` | String | Reads the remaining body. | -| `read(length)` | String or `nil` | Reads up to `length`; returns `nil` at EOF. | -| `read(length, outbuf)` | String or `nil` | Replaces `outbuf` with the returned data. | -| `gets(separator = "\n")` | String or `nil` | Reads one separator-delimited segment. | -| `each` | self or Enumerator | Yields lines from `gets`. | -| `rewind` | `0` | Rewinds a complete rewindable body. | -| `close` | `nil` | Closes the input object. | -| `external_encoding` | `Encoding::ASCII_8BIT` | Reports binary body encoding. | - -Negative read lengths raise `ArgumentError`. Reads on failed or closed native -input raise `IOError`. Blocking reads release the Ruby GVL while waiting for -bytes, EOF, close, or body failure. - -Bodies can be buffered in memory or native spill storage. `rewind` resets the -read offset and is intended for middleware that needs to replay a completed -body. +| Method | Returns | Behavior | +| ------------------------ | ---------------------- | ------------------------------------------- | +| `read` | String | Reads the remaining body. | +| `read(length)` | String or `nil` | Reads up to `length`; returns `nil` at EOF. | +| `read(length, outbuf)` | String or `nil` | Replaces `outbuf` with the returned data. | +| `gets(separator = "\n")` | String or `nil` | Reads one separator-delimited segment. | +| `each` | self or Enumerator | Yields lines from `gets`. | +| `rewind` | `0` | Rewinds a complete rewindable body. | +| `close` | `nil` | Closes the input object. | +| `external_encoding` | `Encoding::ASCII_8BIT` | Reports binary body encoding. | + +Negative read lengths raise `ArgumentError`. Reads on failed or closed native input raise `IOError`. Blocking reads release the Ruby GVL while waiting for bytes, EOF, close, or body failure. + +Bodies can be buffered in memory or native spill storage. `rewind` resets the read offset and is intended for middleware that needs to replay a completed body. ## `Vajra::NativeHijack` -`Vajra::NativeHijack` is the callable exposed at `env["rack.hijack"]` for -HTTP/1.x requests. +`Vajra::NativeHijack` is the callable exposed at `env["rack.hijack"]` for HTTP/1.x requests. -| Method | Returns | Behavior | -| ------ | -------------------------------------------- | ------------------------------ | -| `call` | Ruby `IO` or `Vajra::NativeTlsHijackIO` | Takes ownership of connection. | +| Method | Returns | Behavior | +| ------ | --------------------------------------- | ------------------------------ | +| `call` | Ruby `IO` or `Vajra::NativeTlsHijackIO` | Takes ownership of connection. | -The callable is single-use. It raises `IOError` when hijack is unavailable, -already called, already committed, or when `rack.input` has not been fully -consumed. +The callable is single-use. It raises `IOError` when hijack is unavailable, already called, already committed, or when `rack.input` has not been fully consumed. -Plain HTTP returns a Ruby `IO` created from the client file descriptor. TLS -HTTP/1.1 returns `Vajra::NativeTlsHijackIO`. +On POSIX, plain HTTP returns a Ruby `IO` created from the client file descriptor. On Windows, plain HTTP returns `Vajra::NativeTlsHijackIO` as a native socket-backed IO object because a Winsock `SOCKET` is not a Ruby file descriptor. TLS HTTP/1.1 returns `Vajra::NativeTlsHijackIO` on every platform. ## `Vajra::NativeTlsHijackIO` -TLS HTTP/1.1 full hijack returns an IO-like object backed by Vajra's TLS layer. -Ruby reads and writes decrypted bytes while Vajra handles TLS on the wire. +TLS HTTP/1.1 full hijack returns an IO-like object backed by Vajra's TLS layer. Ruby reads and writes decrypted bytes while Vajra handles TLS on the wire. -| Method | Returns | Behavior | -| ------------------- | ------------------- | ------------------------------------ | -| `write(string)` | bytes written | Writes all bytes or raises `IOError`. | -| `<<(string)` | self | Appends bytes and returns self. | -| `read(length = nil)` | String or `nil` | Reads bytes; `nil` at EOF for sized reads. | -| `readpartial(length)` | String | Reads at least one byte or raises `EOFError`. | -| `flush` | self | Validates the object and returns self. | -| `close` | `nil` | Shuts down TLS and closes the fd. | -| `closed?` | `true` or `false` | Reports close state. | +| Method | Returns | Behavior | +| --------------------- | ----------------- | ------------------------------------------------------ | +| `write(string)` | bytes written | Writes all bytes or raises `IOError`. | +| `<<(string)` | self | Appends bytes and returns self. | +| `read(length = nil)` | String or `nil` | Reads bytes; `nil` at EOF for sized reads. | +| `readpartial(length)` | String | Reads at least one byte or raises `EOFError`. | +| `flush` | self | Validates the object and returns self. | +| `close` | `nil` | Shuts down TLS when present and closes the connection. | +| `closed?` | `true` or `false` | Reports close state. | -Negative read lengths raise `ArgumentError`. Failed TLS reads or writes raise -`IOError`. +Negative read lengths raise `ArgumentError`. Failed TLS reads or writes raise `IOError`. ## `Vajra::HTTP2::Stream` -HTTP/2 Extended CONNECT requests expose a stream object at -`env["vajra.http2.stream"]`. - -| Method | Returns | Behavior | -| ------------------------------ | ---------------- | --------------------------------------- | -| `accept(status = 200, headers = {})` | self | Sends tunnel response headers. | -| `read(length = nil, outbuf = nil)` | String or `nil` | Reads inbound DATA bytes. | -| `write(string)` | bytes written | Queues outbound DATA after accept. | -| `flush` | self | Wakes the HTTP/2 session sender. | -| `close` | `nil` | Sends END_STREAM when possible. | -| `reset(error_code = :cancel)` | `nil` | Resets the stream. | -| `closed?` | `true` or `false` | Reports close, reset, or peer EOF. | -| `protocol` | String | Returns the Extended CONNECT protocol. | -| `stream_id` | Integer | Returns the HTTP/2 stream id. | - -`write` before `accept` raises `IOError`. A second `accept` raises `IOError`. -Reads and writes block when the native buffers or HTTP/2 flow-control windows -require it, and blocking waits release the Ruby GVL. Peer reset or local reset -wakes blocked readers and writers. +HTTP/2 Extended CONNECT requests expose a stream object at `env["vajra.http2.stream"]`. + +| Method | Returns | Behavior | +| ------------------------------------ | ----------------- | -------------------------------------- | +| `accept(status = 200, headers = {})` | self | Sends tunnel response headers. | +| `read(length = nil, outbuf = nil)` | String or `nil` | Reads inbound DATA bytes. | +| `write(string)` | bytes written | Queues outbound DATA after accept. | +| `flush` | self | Wakes the HTTP/2 session sender. | +| `close` | `nil` | Sends END_STREAM when possible. | +| `reset(error_code = :cancel)` | `nil` | Resets the stream. | +| `closed?` | `true` or `false` | Reports close, reset, or peer EOF. | +| `protocol` | String | Returns the Extended CONNECT protocol. | +| `stream_id` | Integer | Returns the HTTP/2 stream id. | + +`write` before `accept` raises `IOError`. A second `accept` raises `IOError`. Reads and writes block when the native buffers or HTTP/2 flow-control windows require it, and blocking waits release the Ruby GVL. Peer reset or local reset wakes blocked readers and writers. ## Rack Environment Keys -| Key | Protocol | Value | -| -------------------------------- | ------------------------- | ----------------------------- | -| `rack.input` | HTTP/1.x and HTTP/2 | `Vajra::NativeInput`. | -| `rack.hijack` | HTTP/1.x | `Vajra::NativeHijack`. | -| `vajra.http2.extended_connect` | HTTP/2 Extended CONNECT | `true`. | -| `vajra.http2.websocket` | HTTP/2 WebSocket CONNECT | `true` when protocol is websocket. | -| `vajra.http2.stream` | HTTP/2 Extended CONNECT | `Vajra::HTTP2::Stream`. | +| Key | Protocol | Value | +| ------------------------------ | ------------------------ | ---------------------------------- | +| `rack.input` | HTTP/1.x and HTTP/2 | `Vajra::NativeInput`. | +| `rack.hijack` | HTTP/1.x | `Vajra::NativeHijack`. | +| `vajra.http2.extended_connect` | HTTP/2 Extended CONNECT | `true`. | +| `vajra.http2.websocket` | HTTP/2 WebSocket CONNECT | `true` when protocol is websocket. | +| `vajra.http2.stream` | HTTP/2 Extended CONNECT | `Vajra::HTTP2::Stream`. | diff --git a/docs/pages/13-compatibility.md b/docs/pages/13-compatibility.md deleted file mode 100644 index 66daef7..0000000 --- a/docs/pages/13-compatibility.md +++ /dev/null @@ -1,90 +0,0 @@ ---- -title: Compatibility -nav_order: 15 -permalink: /compatibility/ ---- - -# Compatibility - -This page lists Vajra's documented support surface and known limitations. It is -grounded in the current gem, CLI, RBS, and native runtime configuration surface. - -## Ruby And Rack - -| Area | Status | -| ------------- | ------------------------------------------- | -| Ruby | Ruby 3.2 or newer. | -| Rack | Standard Rack three-element response shape. | -| Rails | Supported through Vajra's Rails handler. | -| Sinatra | Supported through Rack. | -| Roda | Supported through Rack. | -| Hanami | Supported through Rack. | - -## Platforms - -| Platform | Status | -| -------------- | ----------------------------------------------------- | -| Linux | Primary production and performance validation target. | -| macOS | Useful for development and local testing. | -| Windows | Not documented as a supported production target. | - -Build native extensions inside the deployment target. Do not copy a macOS-built -extension into a Linux image. - -## Protocols - -| Protocol Or Mode | Status | -| ---------------------------- | -------------------------------------------------------- | -| HTTP/1.0 | Supported. | -| HTTP/1.1 | Supported. | -| TLS HTTP/1.1 | Supported with certificate and private key. | -| HTTP/2 over TLS ALPN | Supported when `tls true` and `http2 true` are set. | -| h2c prior knowledge | Supported when `http2 true` is set. | -| HTTP/1.1 `Upgrade: h2c` | Supported when `http2 true` is set. | -| HTTP/2 Extended CONNECT | Supported through `Vajra::HTTP2::Stream`. | -| WebSocket-over-HTTP/2 | Supported as raw WebSocket frames over HTTP/2 DATA. | -| HTTP/2 server push | Not supported. | - -## Rack Features - -| Feature | Status | -| ------------------------ | ---------------------------------------------------------- | -| `rack.input` | `Vajra::NativeInput`. | -| Full Rack hijack | Supported for HTTP/1.x, including TLS HTTP/1.1. | -| Partial hijack | Not supported. | -| HTTP/2 bidirectional IO | Supported through Extended CONNECT stream tunnels. | -| Rewindable request body | Supported after body completion through native buffering. | -| `HEAD` responses | Headers are preserved; response body DATA is suppressed. | -| No-body statuses | `1xx`, `204`, `205`, and `304` do not send message bodies. | - -## Server Features - -| Feature | Status | -| ------------------------ | ------------------------------------------- | -| TCP host and port bind | Supported through `host` and `port`. | -| Unix socket bind | Not in the public supported config surface. | -| Worker processes | Supported through `workers`. | -| Rack execution threads | Supported through `threads`. | -| Access/error logs | Supported. | -| Stats endpoint | Supported. | -| Prometheus metrics | Supported. | -| OpenTelemetry tracing | Supported when configured. | -| Daemonization | Use an external supervisor. | -| Phased restart | Not a public Vajra feature. | -| Plugin API | Not provided. | - -## Config Surface - -Supported runtime settings are listed in [Configuration](/configuration/). -Unknown `Vajra.start` keywords fail as unknown start options. Unknown -`config/vajra.rb` directives fail while loading configuration. - -## Known Limitations - -- Control-plane endpoints are plain Rack routes and must be protected by the - deployment. -- HTTP/2 stream tunnels expose byte streams; WebSocket framing remains the - application's responsibility. -- Full hijack requires the request body to be fully consumed first. -- Native extension behavior should be validated on the same OS and architecture - used in production. diff --git a/docs/pages/14-glossary.md b/docs/pages/14-glossary.md deleted file mode 100644 index 1638064..0000000 --- a/docs/pages/14-glossary.md +++ /dev/null @@ -1,49 +0,0 @@ ---- -title: Glossary -nav_order: 18 -permalink: /glossary/ ---- - -# Glossary - -## Runtime Terms - -| Term | Meaning | -| -------------------- | ------------------------------------------------------------------- | -| Master process | Native runtime process that owns listener admission and supervision. | -| Worker process | Process that owns accepted sockets, protocol IO, and Rack execution. | -| Rack execution pool | Fixed Ruby thread pool that runs application code inside a worker. | -| Control plane | Internal stats and metrics routes configured by `stats_path` and `metrics_endpoint`. | -| Descriptor handoff | Native transfer of accepted client file descriptors to workers. | -| Drain | Shutdown phase where Vajra stops admitting work and waits for active Rack execution. | - -## Protocol Terms - -| Term | Meaning | -| --------------------- | ------------------------------------------------------------------- | -| ALPN | TLS protocol negotiation used to select `h2` or `http/1.1`. | -| h2c | Cleartext HTTP/2, either prior knowledge or HTTP/1.1 upgrade. | -| Extended CONNECT | HTTP/2 CONNECT request with a `:protocol` pseudo-header. | -| Stream tunnel | Bidirectional byte stream exposed as `Vajra::HTTP2::Stream`. | -| Flow-control credit | HTTP/2 capacity released as Rack consumes request or tunnel bytes. | -| Full hijack | Rack API that gives Ruby ownership of an HTTP/1.x client connection. | -| Partial hijack | Rack response-header hijack form; Vajra does not implement it. | - -## Rack Terms - -| Term | Meaning | -| ------------------ | -------------------------------------------------------------- | -| `rack.input` | Request body object, exposed by Vajra as `Vajra::NativeInput`. | -| Rack response | Three-element array: status, headers, body. | -| Body close | `close` call on Rack body after response conversion when present. | -| Rewind | Resetting request-body read offset after the body is complete. | - -## Observability Terms - -| Term | Meaning | -| ----------------- | -------------------------------------------------------------- | -| Stats endpoint | JSON runtime state snapshot. | -| Metrics endpoint | Prometheus text endpoint. | -| Access log | Per-request log line or JSON event. | -| Lifecycle event | Worker boot, readiness, stop, exit, replacement, or health event. | -| Native span | Vajra-emitted OpenTelemetry span when native tracing is enabled. | diff --git a/docs/pages/14-support.md b/docs/pages/14-support.md new file mode 100644 index 0000000..9720017 --- /dev/null +++ b/docs/pages/14-support.md @@ -0,0 +1,19 @@ +--- +title: Professional Support +nav_order: 9 +permalink: /support/ +--- + +# Professional Support + +Codevedas offers professional support for organizations running Vajra in production. + +## Support Benefits + +- **Expert assistance:** Work with engineers familiar with Vajra's Ruby integration, native runtime, protocols, worker model, and production operations. +- **Priority response:** Receive prioritized help for critical production issues. +- **Operational guidance:** Get help evaluating configuration, deployment, observability, performance, upgrades, and failure recovery. + +## Contact Codevedas + +To learn more about our support plans and how we can assist your organization, please write to us at [sales@codevedas.com](mailto:sales@codevedas.com). We look forward to partnering with you to ensure the success of your projects! diff --git a/gems/vajra/Gemfile b/gems/vajra/Gemfile index 8b13b18..1960fbd 100644 --- a/gems/vajra/Gemfile +++ b/gems/vajra/Gemfile @@ -11,6 +11,7 @@ gemspec gem 'bundler-audit', require: false gem 'falcon' +gem 'fiddle', platforms: %i[windows] gem 'hanami', '~> 2.3.2' gem 'hanami-controller', '~> 2.3.1' gem 'hanami-router', '~> 2.3.1' @@ -32,3 +33,4 @@ gem 'rubocop-rspec', require: false gem 'rubocop-thread_safety', '~> 0.7.2', require: false gem 'simplecov', '~> 0.22', require: false gem 'sinatra' +gem 'tzinfo-data', platforms: %i[windows jruby] diff --git a/gems/vajra/Gemfile.lock b/gems/vajra/Gemfile.lock index 9cfa693..a44c383 100644 --- a/gems/vajra/Gemfile.lock +++ b/gems/vajra/Gemfile.lock @@ -125,7 +125,7 @@ GEM fiber-annotation fiber-local (~> 1.1) json - crass (1.0.6) + crass (1.0.7) csv (3.3.5) date (3.5.1) diff-lcs (1.6.2) @@ -201,6 +201,7 @@ GEM fiber-local (1.1.0) fiber-storage fiber-storage (1.0.1) + fiddle (1.1.8) globalid (1.4.0) activesupport (>= 6.1) hanami (2.3.2) @@ -259,7 +260,7 @@ GEM localhost (1.8.0) bake logger (1.7.0) - loofah (2.25.1) + loofah (2.25.2) crass (~> 1.0.2) nokogiri (>= 1.12.0) mail (2.9.0) @@ -298,6 +299,8 @@ GEM racc (~> 1.4) nokogiri (1.19.4-arm64-darwin) racc (~> 1.4) + nokogiri (1.19.4-x64-mingw-ucrt) + racc (~> 1.4) nokogiri (1.19.4-x86_64-darwin) racc (~> 1.4) nokogiri (1.19.4-x86_64-linux-gnu) @@ -366,8 +369,8 @@ GEM activesupport (>= 5.0.0) minitest nokogiri (>= 1.6) - rails-html-sanitizer (1.7.0) - loofah (~> 2.25) + rails-html-sanitizer (1.7.1) + loofah (~> 2.25, >= 2.25.2) nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0) railties (8.1.3) actionpack (= 8.1.3) @@ -476,6 +479,8 @@ GEM tsort (0.2.0) tzinfo (2.0.6) concurrent-ruby (~> 1.0) + tzinfo-data (1.2026.3) + tzinfo (>= 1.0.0) unicode-display_width (3.2.0) unicode-emoji (~> 4.1) unicode-emoji (4.2.0) @@ -493,6 +498,7 @@ PLATFORMS arm-linux-gnu arm-linux-musl arm64-darwin + x64-mingw-ucrt x86_64-darwin x86_64-linux-gnu x86_64-linux-musl @@ -500,6 +506,7 @@ PLATFORMS DEPENDENCIES bundler-audit falcon + fiddle hanami (~> 2.3.2) hanami-controller (~> 2.3.1) hanami-router (~> 2.3.1) @@ -521,6 +528,7 @@ DEPENDENCIES rubocop-thread_safety (~> 0.7.2) simplecov (~> 0.22) sinatra + tzinfo-data vajra! BUNDLED WITH diff --git a/gems/vajra/README.md b/gems/vajra/README.md index 4d60954..969dda5 100644 --- a/gems/vajra/README.md +++ b/gems/vajra/README.md @@ -2,8 +2,7 @@ `vajra` is the canonical Ruby package for the Vajra server runtime. -It provides the Ruby entrypoints, packaging contract, executable, signatures, -and native-extension bridge for the Vajra server implementation. +It provides the Ruby entrypoints, packaging contract, executable, signatures, and native-extension bridge for the Vajra server implementation. ## Use This Package When @@ -43,19 +42,11 @@ bundle exec rbs -I sig validate bundle exec exe/vajra ``` -`bin/rspec-unit` runs the committed package spec suite, including the clean -Ruby/package behavior checks. `bin/rspec-e2e` runs the integration-style boot -check without coverage. `bin/clint` runs the native C++ lint lane, and -`bin/ctest` builds and runs the native C++ test suite, including lifecycle and -IPC contract coverage. +`bin/rspec-unit` runs the committed package spec suite, including the clean Ruby/package behavior checks. `bin/rspec-e2e` runs the integration-style boot check without coverage. `bin/clint` runs the native C++ lint lane, and `bin/ctest` builds and runs the native C++ test suite, including lifecycle and IPC contract coverage. ## Runtime Configuration -Vajra accepts runtime config from both `Vajra.start(...)` and environment variables. -Environment variables take precedence when both are present. The entries below -are common examples; see the complete -[configuration reference](https://vajra.codevedas.com/configuration/) for every -supported setting and default. +Vajra accepts runtime config from both `Vajra.start(...)` and environment variables. Environment variables take precedence when both are present. The entries below are common examples; see the complete [configuration reference](https://vajra.codevedas.com/configuration/) for every supported setting and default. - `port` - Ruby: `Vajra.start(port: 9292)` @@ -77,9 +68,7 @@ The `vajra` executable looks for app startup files in this order: 2. `config/vajra.rb` 3. `config.ru` -Use `config/vajra.rb` for Vajra-specific settings such as `port`, -`max_request_head_bytes`, and the `rails` adapter directive. Use `config.ru` -for Rack app boot in Sinatra, Roda, Hanami, and other Rack-first frameworks. +Use `config/vajra.rb` for Vajra-specific settings such as `port`, `max_request_head_bytes`, and the `rails` adapter directive. Use `config.ru` for Rack app boot in Sinatra, Roda, Hanami, and other Rack-first frameworks. ## Native Extension @@ -91,8 +80,9 @@ Use the package-local build flow to compile and refresh the extension: bundle exec rake clobber compile ``` -If the extension is missing or stale, `require "vajra"` raises an actionable -load error that points back to the package-local compile command. +If the extension is missing or stale, `require "vajra"` raises an actionable load error that points back to the package-local compile command. + +Windows requires a 64-bit RubyInstaller UCRT Ruby (`x64-mingw-ucrt`). MSVC-built Ruby (`x64-mswin64`) is not supported. ## Package Discipline diff --git a/gems/vajra/bin/ctest b/gems/vajra/bin/ctest index 49da682..f53791f 100755 --- a/gems/vajra/bin/ctest +++ b/gems/vajra/bin/ctest @@ -10,6 +10,17 @@ ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" BUILD_DIR="$ROOT_DIR/tmp/cpp-tests" rm -rf "$BUILD_DIR" -cmake -S "$ROOT_DIR/spec/cpp" -B "$BUILD_DIR" +configure_args=() +if [[ "${OS:-}" == "Windows_NT" && -n "$(command -v ninja 2>/dev/null)" ]]; then + configure_args+=("-G" "Ninja") + if command -v pkg-config >/dev/null 2>&1; then + configure_args+=("-DOPENSSL_ROOT_DIR=$(pkg-config --variable=prefix openssl)") + fi +fi +if (( ${#configure_args[@]} > 0 )); then + cmake -S "$ROOT_DIR/spec/cpp" -B "$BUILD_DIR" "${configure_args[@]}" +else + cmake -S "$ROOT_DIR/spec/cpp" -B "$BUILD_DIR" +fi cmake --build "$BUILD_DIR" ctest --test-dir "$BUILD_DIR" --output-on-failure "$@" diff --git a/gems/vajra/ext/vajra/extconf.rb b/gems/vajra/ext/vajra/extconf.rb index 24a2bb5..553df2c 100644 --- a/gems/vajra/ext/vajra/extconf.rb +++ b/gems/vajra/ext/vajra/extconf.rb @@ -13,28 +13,63 @@ if configured_c_compiler.include?('gcc') && configured_c_compiler.scan('clang').empty? removed_c_compiler_flags.push( '-Wno-constant-logical-operand', + '-Wno-dll-attribute-on-redeclaration', '-Wno-parentheses-equality', '-Wno-self-assign' ) end $CFLAGS = Shellwords.split($CFLAGS.to_s).reject { |flag| removed_c_compiler_flags.include?(flag) }.join(' ') +$CXXFLAGS = Shellwords.split($CXXFLAGS.to_s).reject { |flag| removed_c_compiler_flags.include?(flag) }.join(' ') $warnflags = Shellwords.split($warnflags.to_s).reject { |flag| removed_c_compiler_flags.include?(flag) }.join(' ') -append_cflags('-fvisibility=hidden') -pkg_config('openssl') || (have_library('ssl') && have_library('crypto')) || raise('OpenSSL development files are required') +host_os = RbConfig::CONFIG.fetch('host_os', '') +windows_mingw = host_os.include?('mingw') +windows_cygwin = host_os.include?('cygwin') +windows_msvc = host_os.include?('mswin') || configured_c_compiler.match?(%r{(?:^|[\\/])cl(?:\.exe)?(?:\s|$)}i) +raise 'Vajra supports Windows only with RubyInstaller UCRT/MinGW Ruby' if windows_msvc || windows_cygwin + +windows_host = windows_mingw +$defs.push('-DVAJRA_TEST_FAULT_INJECTION') if windows_host && ENV['VAJRA_TEST_FAULT_INJECTION'] == '1' + +if windows_mingw + mingw_warning_flags = %w[-Wall -Wextra -Wpedantic -Werror -Wno-cpp -Wno-unused-parameter] + # Ruby 3.2.11's public RString initializer is intentionally partial and trips + # newer MinGW GCC releases when extension warnings are promoted to errors. + mingw_warning_flags << '-Wno-missing-field-initializers' if RUBY_VERSION.start_with?('3.2.') + $CFLAGS = [$CFLAGS, *mingw_warning_flags].join(' ').strip + $CXXFLAGS = [$CXXFLAGS, '-std=c++17', *mingw_warning_flags].join(' ').strip +else + append_cflags('-fvisibility=hidden') + $CXXFLAGS = "#{$CXXFLAGS} -std=c++17".strip +end + +openssl_root = ENV.fetch('OPENSSL_ROOT_DIR', nil) +dir_config('openssl', File.join(openssl_root, 'include'), File.join(openssl_root, 'lib')) unless openssl_root.to_s.empty? +openssl_found = pkg_config('openssl') || (have_library('ssl') && have_library('crypto')) +raise('OpenSSL development files matching the Ruby compiler ABI are required') unless openssl_found + +if windows_host + have_library('ws2_32') || raise('Winsock development library is required') + have_library('psapi') || raise('Windows process API development library is required') +end vendor_nghttp2_include = File.join(__dir__, 'vendor', 'nghttp2', 'lib', 'includes') vendor_nghttp2_internal = File.join(__dir__, 'vendor', 'nghttp2', 'lib') $INCFLAGS = "-I#{vendor_nghttp2_include} -I#{vendor_nghttp2_internal} #{$INCFLAGS}".strip $defs.push( '-DNGHTTP2_STATICLIB', - '-DBUILDING_NGHTTP2', - '-DHAVE_ARPA_INET_H', - '-DHAVE_NETINET_IN_H' + '-DBUILDING_NGHTTP2' ) +$defs.push('-DNOMINMAX', '-DWIN32_LEAN_AND_MEAN') if windows_host +$defs.push('-DHAVE_ARPA_INET_H', '-DHAVE_NETINET_IN_H') unless windows_host source_files = Dir.glob('**/*.{c,cpp}', base: __dir__) +if windows_host + source_files.delete('runtime/native_runtime.cpp') +else + source_files.delete('runtime/native_runtime_windows.cpp') +end source_directories = source_files.map { |path| File.dirname(path) }.uniq.sort source_basenames = source_files.map { |path| File.basename(path) }.sort duplicate_basenames = source_basenames.tally.select { |_, count| count > 1 }.keys @@ -42,7 +77,6 @@ raise "duplicate native source basenames are not supported: #{duplicate_basenames.join(', ')}" unless duplicate_basenames.empty? # mkmf exposes these globals as the extension-source configuration surface. -$CXXFLAGS = "#{$CXXFLAGS} -std=c++17".strip $VPATH.concat( source_directories .reject { |directory| directory == '.' } diff --git a/gems/vajra/ext/vajra/lifecycle/lifecycle_controller.cpp b/gems/vajra/ext/vajra/lifecycle/lifecycle_controller.cpp index ed7b123..81a49fd 100644 --- a/gems/vajra/ext/vajra/lifecycle/lifecycle_controller.cpp +++ b/gems/vajra/ext/vajra/lifecycle/lifecycle_controller.cpp @@ -19,7 +19,7 @@ namespace Vajra listener_owned_(false), pending_stop_before_start_(false), port_(-1), - listener_fd_(-1), + listener_fd_(platform::kInvalidSocket), observer_() { } @@ -44,11 +44,11 @@ namespace Vajra listener_owned_ = false; pending_stop_before_start_ = false; port_ = -1; - listener_fd_ = -1; + listener_fd_ = platform::kInvalidSocket; return true; } - bool Controller::mark_listening(int listener_fd, int port) + bool Controller::mark_listening(platform::SocketHandle listener_fd, int port) { { std::lock_guard lock(mutex_); @@ -73,6 +73,30 @@ namespace Vajra return true; } + bool Controller::mark_dispatch_ready(int port) + { + Snapshot snapshot_value; + { + std::lock_guard lock(mutex_); + if (state_ == State::draining) + { + return false; + } + if (state_ != State::booting) + { + throw std::logic_error("dispatch worker can only become ready from booting"); + } + state_ = State::listening; + boot_readiness_ = BootReadiness::ready; + listener_owned_ = false; + port_ = port; + listener_fd_ = platform::kInvalidSocket; + snapshot_value = snapshot_unlocked(); + } + notify(HookPoint::boot_complete, snapshot_value); + return true; + } + void Controller::mark_boot_ready() { Snapshot snapshot_value; @@ -83,7 +107,7 @@ namespace Vajra return; } - const bool listener_bound = listener_owned_ && listener_fd_ >= 0; + const bool listener_bound = listener_owned_ && platform::socket_valid(listener_fd_); if (state_ == State::draining && listener_bound) { boot_readiness_ = BootReadiness::ready; @@ -186,7 +210,7 @@ namespace Vajra { listener_owned_ = false; pending_stop_before_start_ = false; - listener_fd_ = -1; + listener_fd_ = platform::kInvalidSocket; snapshot_value = snapshot_unlocked(); } else @@ -200,7 +224,7 @@ namespace Vajra boot_readiness_ = BootReadiness::pending; listener_owned_ = false; pending_stop_before_start_ = false; - listener_fd_ = -1; + listener_fd_ = platform::kInvalidSocket; snapshot_value = snapshot_unlocked(); notify_observer = true; } @@ -227,7 +251,7 @@ namespace Vajra last_stop_reason_ = reason; listener_owned_ = false; pending_stop_before_start_ = false; - listener_fd_ = -1; + listener_fd_ = platform::kInvalidSocket; snapshot_value = snapshot_unlocked(); } diff --git a/gems/vajra/ext/vajra/lifecycle/lifecycle_controller.hpp b/gems/vajra/ext/vajra/lifecycle/lifecycle_controller.hpp index 9ddd781..9cc8ce8 100644 --- a/gems/vajra/ext/vajra/lifecycle/lifecycle_controller.hpp +++ b/gems/vajra/ext/vajra/lifecycle/lifecycle_controller.hpp @@ -6,6 +6,8 @@ #ifndef VAJRA_LIFECYCLE_CONTROLLER_HPP #define VAJRA_LIFECYCLE_CONTROLLER_HPP +#include "platform/socket.hpp" + #include #include #include @@ -56,7 +58,7 @@ namespace Vajra StopReason last_stop_reason; bool listener_owned; int port; - int listener_fd; + platform::SocketHandle listener_fd; }; class Controller @@ -67,7 +69,8 @@ namespace Vajra Controller(); bool begin_startup(); - bool mark_listening(int listener_fd, int port); + bool mark_listening(platform::SocketHandle listener_fd, int port); + bool mark_dispatch_ready(int port); void mark_boot_ready(); void mark_serving(); void request_stop(StopReason reason); @@ -88,7 +91,7 @@ namespace Vajra bool listener_owned_; bool pending_stop_before_start_; int port_; - int listener_fd_; + platform::SocketHandle listener_fd_; Observer observer_; }; } diff --git a/gems/vajra/ext/vajra/listener/listener_socket.cpp b/gems/vajra/ext/vajra/listener/listener_socket.cpp index ffa22c5..9372f0e 100644 --- a/gems/vajra/ext/vajra/listener/listener_socket.cpp +++ b/gems/vajra/ext/vajra/listener/listener_socket.cpp @@ -5,15 +5,16 @@ #include "listener_socket.hpp" -#include #include #include #include -#include #include #include -#include -#include + +#ifndef _WIN32 +#include +#include +#endif namespace { @@ -27,7 +28,7 @@ namespace { return std::runtime_error( std::string("listener ") + stage + " failed for " + host + ":" + std::to_string(port) + ": " + - std::strerror(error_number)); + Vajra::platform::socket_error_message(error_number)); } std::runtime_error host_resolution_error(const std::string &host, int port, int status) @@ -40,6 +41,7 @@ namespace Vajra::listener::SocketBinding Vajra::listener::Socket::open(const std::string &host, int port, bool reuse_port) const { + platform::ensure_socket_runtime(); addrinfo hints{}; hints.ai_family = AF_INET; hints.ai_socktype = SOCK_STREAM; @@ -58,75 +60,86 @@ Vajra::listener::SocketBinding Vajra::listener::Socket::open(const std::string & } const std::unique_ptr addresses(result, freeaddrinfo); - int socket_fd = -1; + platform::SocketHandle socket_fd = platform::kInvalidSocket; int last_error = 0; SocketFailureStage last_failure_stage = SocketFailureStage::bind; for (addrinfo *candidate = addresses.get(); candidate != nullptr; candidate = candidate->ai_next) { - socket_fd = socket(candidate->ai_family, candidate->ai_socktype, candidate->ai_protocol); - if (socket_fd < 0) + socket_fd = platform::create_tcp_socket(candidate->ai_family, candidate->ai_socktype, candidate->ai_protocol); + if (!platform::socket_valid(socket_fd)) { - last_error = errno; + last_error = platform::socket_last_error(); last_failure_stage = SocketFailureStage::socket_create; continue; } int opt = 1; - if (setsockopt(socket_fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt)) < 0) + if (!platform::set_socket_option(socket_fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt))) { - const int error_number = errno; - close(socket_fd); - throw startup_error("socket option setup", host, port, error_number); + const int error_number = platform::socket_last_error(); + platform::close_socket(socket_fd); + throw std::runtime_error( + startup_error("socket option setup", host, port, error_number).what() + + std::string(" (native_handle=") + std::to_string(platform::socket_handle_value(socket_fd)) + ")"); } #ifdef SO_REUSEPORT - if (reuse_port && setsockopt(socket_fd, SOL_SOCKET, SO_REUSEPORT, &opt, sizeof(opt)) < 0) + if (reuse_port && !platform::set_socket_option(socket_fd, SOL_SOCKET, SO_REUSEPORT, &opt, sizeof(opt))) { - const int error_number = errno; - close(socket_fd); + const int error_number = platform::socket_last_error(); + platform::close_socket(socket_fd); throw startup_error("reuseport setup", host, port, error_number); } #else if (reuse_port) { - close(socket_fd); + platform::close_socket(socket_fd); throw std::runtime_error("listener reuse_port requested but SO_REUSEPORT is not available"); } #endif - if (bind(socket_fd, candidate->ai_addr, candidate->ai_addrlen) == 0) + if (platform::bind_socket(socket_fd, candidate->ai_addr, static_cast(candidate->ai_addrlen))) { break; } - last_error = errno; + last_error = platform::socket_last_error(); last_failure_stage = SocketFailureStage::bind; - close(socket_fd); - socket_fd = -1; + platform::close_socket(socket_fd); + socket_fd = platform::kInvalidSocket; } - if (socket_fd < 0) + if (!platform::socket_valid(socket_fd)) { throw startup_error(last_failure_stage == SocketFailureStage::socket_create ? "socket create" : "bind", host, port, last_error); } sockaddr_in bound_addr{}; socklen_t bound_addr_len = sizeof(bound_addr); - if (getsockname(socket_fd, reinterpret_cast(&bound_addr), &bound_addr_len) < 0) + if (!platform::socket_name(socket_fd, reinterpret_cast(&bound_addr), &bound_addr_len)) { - const int error_number = errno; - close(socket_fd); + const int error_number = platform::socket_last_error(); + platform::close_socket(socket_fd); throw startup_error("bound port discovery", host, port, error_number); } const int bound_port = ntohs(bound_addr.sin_port); - if (listen(socket_fd, 128) < 0) + if (!platform::listen_socket(socket_fd, 128)) { - const int error_number = errno; - close(socket_fd); + const int error_number = platform::socket_last_error(); + platform::close_socket(socket_fd); throw startup_error("listen", host, bound_port, error_number); } +#ifdef _WIN32 + if (!platform::set_socket_nonblocking(socket_fd, true)) + { + const int error_number = platform::socket_last_error(); + platform::close_socket(socket_fd); + throw startup_error("nonblocking setup", host, bound_port, error_number); + } +#endif + return SocketBinding{socket_fd, bound_port}; } diff --git a/gems/vajra/ext/vajra/listener/listener_socket.hpp b/gems/vajra/ext/vajra/listener/listener_socket.hpp index e85312e..28ec7c7 100644 --- a/gems/vajra/ext/vajra/listener/listener_socket.hpp +++ b/gems/vajra/ext/vajra/listener/listener_socket.hpp @@ -6,6 +6,8 @@ #ifndef VAJRA_LISTENER_SOCKET_HPP #define VAJRA_LISTENER_SOCKET_HPP +#include "platform/socket.hpp" + #include namespace Vajra @@ -14,7 +16,7 @@ namespace Vajra { struct SocketBinding { - int fd; + platform::SocketHandle fd; int port; }; diff --git a/gems/vajra/ext/vajra/platform/process.cpp b/gems/vajra/ext/vajra/platform/process.cpp new file mode 100644 index 0000000..5935a44 --- /dev/null +++ b/gems/vajra/ext/vajra/platform/process.cpp @@ -0,0 +1,54 @@ +// Copyright Codevedas Inc. 2025-present +// +// This source code is licensed under the MIT license found in the +// LICENSE file in the root directory of this source tree. + +#include "platform/process.hpp" + +#ifdef _WIN32 +#include +#endif + +Vajra::platform::ProcessId Vajra::platform::current_process_id() +{ +#ifdef _WIN32 + return GetCurrentProcessId(); +#else + return getpid(); +#endif +} + +Vajra::platform::ProcessId Vajra::platform::current_parent_process_id() +{ +#ifdef _WIN32 + const DWORD current_id = GetCurrentProcessId(); + const HANDLE snapshot = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0); + if (snapshot == INVALID_HANDLE_VALUE) + { + return kInvalidProcessId; + } + PROCESSENTRY32W entry{}; + entry.dwSize = sizeof(entry); + ProcessId parent_id = kInvalidProcessId; + if (Process32FirstW(snapshot, &entry) != FALSE) + { + do + { + if (entry.th32ProcessID == current_id) + { + parent_id = entry.th32ParentProcessID; + break; + } + } while (Process32NextW(snapshot, &entry) != FALSE); + } + CloseHandle(snapshot); + return parent_id; +#else + return getppid(); +#endif +} + +std::uint64_t Vajra::platform::process_id_value(ProcessId process_id) +{ + return static_cast(process_id); +} diff --git a/gems/vajra/ext/vajra/platform/process.hpp b/gems/vajra/ext/vajra/platform/process.hpp new file mode 100644 index 0000000..5212ec1 --- /dev/null +++ b/gems/vajra/ext/vajra/platform/process.hpp @@ -0,0 +1,43 @@ +// Copyright Codevedas Inc. 2025-present +// +// This source code is licensed under the MIT license found in the +// LICENSE file in the root directory of this source tree. + +#ifndef VAJRA_PLATFORM_PROCESS_HPP +#define VAJRA_PLATFORM_PROCESS_HPP + +#include + +#ifdef _WIN32 +#ifndef NOMINMAX +#define NOMINMAX +#endif +#ifndef WIN32_LEAN_AND_MEAN +#define WIN32_LEAN_AND_MEAN +#endif +#include +#else +#include +#include +#endif + +namespace Vajra::platform +{ +#ifdef _WIN32 + using ProcessId = DWORD; + using NativeProcessHandle = HANDLE; + constexpr ProcessId kInvalidProcessId = 0; + constexpr NativeProcessHandle kInvalidProcessHandle = nullptr; +#else + using ProcessId = pid_t; + using NativeProcessHandle = pid_t; + constexpr ProcessId kInvalidProcessId = -1; + constexpr NativeProcessHandle kInvalidProcessHandle = -1; +#endif + + ProcessId current_process_id(); + ProcessId current_parent_process_id(); + std::uint64_t process_id_value(ProcessId process_id); +} + +#endif diff --git a/gems/vajra/ext/vajra/platform/socket.cpp b/gems/vajra/ext/vajra/platform/socket.cpp new file mode 100644 index 0000000..abbbc04 --- /dev/null +++ b/gems/vajra/ext/vajra/platform/socket.cpp @@ -0,0 +1,545 @@ +// Copyright Codevedas Inc. 2025-present +// +// This source code is licensed under the MIT license found in the +// LICENSE file in the root directory of this source tree. + +#include "platform/socket.hpp" + +#include +#include +#include +#include +#include + +#ifdef _WIN32 +#include +#else +#include +#include +#include +#endif + +namespace +{ +#ifdef _WIN32 + HMODULE winsock_module() + { + static HMODULE module = []() + { + HMODULE loaded = GetModuleHandleW(L"Ws2_32.dll"); + if (loaded == nullptr) + { + loaded = LoadLibraryW(L"Ws2_32.dll"); + } + if (loaded == nullptr) + { + throw std::runtime_error("failed to load Ws2_32.dll"); + } + return loaded; + }(); + return module; + } + + template + Function winsock_function(const char *name) + { + const FARPROC procedure = GetProcAddress(winsock_module(), name); + if (procedure == nullptr) + { + throw std::runtime_error(std::string("failed to resolve Winsock function: ") + name); + } + static_assert(sizeof(Function) == sizeof(procedure)); + Function function = nullptr; + std::memcpy(&function, &procedure, sizeof(function)); + return function; + } + + using AcceptFunction = SOCKET(WSAAPI *)(SOCKET, sockaddr *, int *); + using BindFunction = int(WSAAPI *)(SOCKET, const sockaddr *, int); + using ConnectFunction = int(WSAAPI *)(SOCKET, const sockaddr *, int); + using ListenFunction = int(WSAAPI *)(SOCKET, int); + using NameFunction = int(WSAAPI *)(SOCKET, sockaddr *, int *); + using SocketOptionFunction = int(WSAAPI *)(SOCKET, int, int, const char *, int); + using GetSocketOptionFunction = int(WSAAPI *)(SOCKET, int, int, char *, int *); + using IoctlFunction = int(WSAAPI *)(SOCKET, long, u_long *); + using CloseSocketFunction = int(WSAAPI *)(SOCKET); + using ShutdownFunction = int(WSAAPI *)(SOCKET, int); + using ReceiveFunction = int(WSAAPI *)(SOCKET, char *, int, int); + using SendFunction = int(WSAAPI *)(SOCKET, const char *, int, int); + + AcceptFunction native_accept() + { + static const auto value = winsock_function("accept"); + return value; + } + BindFunction native_bind() + { + static const auto value = winsock_function("bind"); + return value; + } + ConnectFunction native_connect() + { + static const auto value = winsock_function("connect"); + return value; + } + ListenFunction native_listen() + { + static const auto value = winsock_function("listen"); + return value; + } + NameFunction native_getsockname() + { + static const auto value = winsock_function("getsockname"); + return value; + } + NameFunction native_getpeername() + { + static const auto value = winsock_function("getpeername"); + return value; + } + SocketOptionFunction native_setsockopt() + { + static const auto value = winsock_function("setsockopt"); + return value; + } + GetSocketOptionFunction native_getsockopt() + { + static const auto value = winsock_function("getsockopt"); + return value; + } + IoctlFunction native_ioctlsocket() + { + static const auto value = winsock_function("ioctlsocket"); + return value; + } + CloseSocketFunction native_closesocket() + { + static const auto value = winsock_function("closesocket"); + return value; + } + ShutdownFunction native_shutdown() + { + static const auto value = winsock_function("shutdown"); + return value; + } + ReceiveFunction native_recv() + { + static const auto value = winsock_function("recv"); + return value; + } + SendFunction native_send() + { + static const auto value = winsock_function("send"); + return value; + } + + int windows_socket_error_to_errno(int error_number) + { + switch (error_number) + { + case WSAEINTR: + return EINTR; + case WSAEWOULDBLOCK: + return EWOULDBLOCK; + case WSAECONNABORTED: + return ECONNABORTED; + case WSAECONNRESET: + return ECONNRESET; + case WSAENOTCONN: + return ENOTCONN; + case WSAETIMEDOUT: + return ETIMEDOUT; + case WSAEADDRINUSE: + return EADDRINUSE; + case WSAEACCES: + return EACCES; + case WSAEINVAL: + return EINVAL; + default: + return EIO; + } + } + + std::string windows_error_message(int error_number) + { + char *message = nullptr; + const DWORD length = FormatMessageA( + FORMAT_MESSAGE_ALLOCATE_BUFFER | FORMAT_MESSAGE_FROM_SYSTEM | FORMAT_MESSAGE_IGNORE_INSERTS, + nullptr, + static_cast(error_number), + 0, + reinterpret_cast(&message), + 0, + nullptr); + if (length == 0 || message == nullptr) + { + return "Winsock error " + std::to_string(error_number); + } + std::string result(message, length); + LocalFree(message); + while (!result.empty() && (result.back() == '\r' || result.back() == '\n')) + { + result.pop_back(); + } + return result; + } +#endif +} + +Vajra::platform::SocketRuntime::SocketRuntime() +{ +#ifdef _WIN32 + WSADATA data{}; + const int status = WSAStartup(MAKEWORD(2, 2), &data); + if (status != 0) + { + throw std::runtime_error("Winsock initialization failed: " + socket_error_message(status)); + } +#endif +} + +Vajra::platform::SocketRuntime::~SocketRuntime() +{ +#ifdef _WIN32 + WSACleanup(); +#endif +} + +void Vajra::platform::ensure_socket_runtime() +{ + static const SocketRuntime runtime; + (void)runtime; +} + +bool Vajra::platform::socket_valid(SocketHandle socket) +{ + return socket != kInvalidSocket; +} + +bool Vajra::platform::socket_open(SocketHandle socket) +{ + if (!socket_valid(socket)) + { + return false; + } + int socket_type = 0; +#ifdef _WIN32 + int length = sizeof(socket_type); + return native_getsockopt()(socket, SOL_SOCKET, SO_TYPE, reinterpret_cast(&socket_type), &length) == 0; +#else + socklen_t length = sizeof(socket_type); + return getsockopt(socket, SOL_SOCKET, SO_TYPE, &socket_type, &length) == 0; +#endif +} + +Vajra::platform::NativeSocketHandle Vajra::platform::native_socket_handle(SocketHandle socket) +{ + return socket; +} + +int Vajra::platform::openssl_socket_descriptor(SocketHandle socket) +{ + if (!socket_valid(socket)) + { + throw std::invalid_argument("cannot attach an invalid socket to OpenSSL"); + } + return static_cast(socket); +} + +std::uint64_t Vajra::platform::socket_handle_value(SocketHandle socket) +{ + return static_cast(socket); +} + +int Vajra::platform::socket_last_error() +{ +#ifdef _WIN32 + return WSAGetLastError(); +#else + return errno; +#endif +} + +std::string Vajra::platform::socket_error_message(int error_number) +{ +#ifdef _WIN32 + return windows_error_message(error_number); +#else + return std::strerror(error_number); +#endif +} + +bool Vajra::platform::socket_error_interrupted(int error_number) +{ +#ifdef _WIN32 + return error_number == WSAEINTR || error_number == EINTR; +#else + return error_number == EINTR; +#endif +} + +bool Vajra::platform::socket_error_would_block(int error_number) +{ +#ifdef _WIN32 + return error_number == WSAEWOULDBLOCK || error_number == EAGAIN || error_number == EWOULDBLOCK; +#else + return error_number == EAGAIN || error_number == EWOULDBLOCK; +#endif +} + +bool Vajra::platform::socket_error_disconnected(int error_number) +{ +#ifdef _WIN32 + return error_number == WSAECONNRESET || error_number == WSAECONNABORTED || + error_number == WSAENOTCONN || error_number == WSAESHUTDOWN || + error_number == ECONNRESET || error_number == ECONNABORTED || + error_number == ENOTCONN || error_number == EPIPE; +#else + return error_number == ECONNRESET || error_number == ECONNABORTED || error_number == ENOTCONN || error_number == EPIPE; +#endif +} + +void Vajra::platform::close_socket(SocketHandle socket) +{ + if (!socket_valid(socket)) + { + return; + } +#ifdef _WIN32 + native_closesocket()(socket); +#else + close(socket); +#endif +} + +void Vajra::platform::shutdown_socket(SocketHandle socket) +{ + if (!socket_valid(socket)) + { + return; + } +#ifdef _WIN32 + native_shutdown()(socket, SD_BOTH); +#else + shutdown(socket, SHUT_RDWR); +#endif +} + +bool Vajra::platform::shutdown_socket_write(SocketHandle socket) +{ + if (!socket_valid(socket)) + { + return false; + } +#ifdef _WIN32 + const int result = native_shutdown()(socket, SD_SEND); + if (result == SOCKET_ERROR) + { + errno = windows_socket_error_to_errno(WSAGetLastError()); + return false; + } +#else + const int result = shutdown(socket, SHUT_WR); +#endif + return result == 0; +} + +bool Vajra::platform::set_socket_inheritable(SocketHandle socket, bool inheritable) +{ +#ifdef _WIN32 + return SetHandleInformation( + reinterpret_cast(socket), + HANDLE_FLAG_INHERIT, + inheritable ? HANDLE_FLAG_INHERIT : 0) != 0; +#else + const int flags = fcntl(socket, F_GETFD); + if (flags < 0) + { + return false; + } + const int updated = inheritable ? flags & ~FD_CLOEXEC : flags | FD_CLOEXEC; + return fcntl(socket, F_SETFD, updated) == 0; +#endif +} + +Vajra::platform::SocketHandle Vajra::platform::create_tcp_socket(int family, int type, int protocol) +{ +#ifdef _WIN32 + return WSASocketW(family, type, protocol, nullptr, 0, WSA_FLAG_OVERLAPPED); +#else + return ::socket(family, type, protocol); +#endif +} + +bool Vajra::platform::set_socket_option( + SocketHandle socket, + int level, + int option, + const void *value, + socklen_t length) +{ +#ifdef _WIN32 + return native_setsockopt()(socket, level, option, static_cast(value), length) == 0; +#else + return setsockopt(socket, level, option, value, length) == 0; +#endif +} + +bool Vajra::platform::bind_socket(SocketHandle socket, const sockaddr *address, socklen_t address_length) +{ +#ifdef _WIN32 + return native_bind()(socket, address, address_length) == 0; +#else + return bind(socket, address, address_length) == 0; +#endif +} + +bool Vajra::platform::connect_socket(SocketHandle socket, const sockaddr *address, socklen_t address_length) +{ +#ifdef _WIN32 + return native_connect()(socket, address, static_cast(address_length)) == 0; +#else + return connect(socket, address, address_length) == 0; +#endif +} + +bool Vajra::platform::listen_socket(SocketHandle socket, int backlog) +{ +#ifdef _WIN32 + return native_listen()(socket, backlog) == 0; +#else + return listen(socket, backlog) == 0; +#endif +} + +bool Vajra::platform::socket_name(SocketHandle socket, sockaddr *address, socklen_t *address_length) +{ +#ifdef _WIN32 + return native_getsockname()(socket, address, address_length) == 0; +#else + return getsockname(socket, address, address_length) == 0; +#endif +} + +bool Vajra::platform::peer_name(SocketHandle socket, sockaddr *address, socklen_t *address_length) +{ +#ifdef _WIN32 + return native_getpeername()(socket, address, address_length) == 0; +#else + return getpeername(socket, address, address_length) == 0; +#endif +} + +bool Vajra::platform::set_socket_nonblocking(SocketHandle socket, bool nonblocking) +{ +#ifdef _WIN32 + u_long mode = nonblocking ? 1UL : 0UL; + return native_ioctlsocket()(socket, FIONBIO, &mode) == 0; +#else + const int flags = fcntl(socket, F_GETFL); + if (flags < 0) + { + return false; + } + const int updated = nonblocking ? flags | O_NONBLOCK : flags & ~O_NONBLOCK; + return fcntl(socket, F_SETFL, updated) == 0; +#endif +} + +Vajra::platform::SocketHandle Vajra::platform::accept_socket( + SocketHandle listener, + sockaddr *address, + socklen_t *address_length) +{ +#ifdef _WIN32 + return native_accept()(listener, address, address_length); +#else + return static_cast(accept(listener, address, address_length)); +#endif +} + +bool Vajra::platform::wait_socket(SocketHandle socket, WaitEvent event, int timeout_milliseconds) +{ +#ifdef _WIN32 + WSAPOLLFD descriptor{}; + descriptor.fd = socket; + descriptor.events = event == WaitEvent::read ? POLLRDNORM : POLLWRNORM; + const short ready_events = descriptor.events | POLLHUP | POLLERR | POLLNVAL; + for (;;) + { + const int result = WSAPoll(&descriptor, 1, timeout_milliseconds); + if (result > 0) + { + return (descriptor.revents & ready_events) != 0; + } + if (result == 0 || !socket_error_interrupted(socket_last_error())) + { + return false; + } + } +#else + pollfd descriptor{}; + descriptor.fd = socket; + descriptor.events = event == WaitEvent::read ? POLLIN : POLLOUT; + const short ready_events = descriptor.events | POLLHUP | POLLERR | POLLNVAL; + for (;;) + { + const int result = poll(&descriptor, 1, timeout_milliseconds); + if (result > 0) + { + return (descriptor.revents & ready_events) != 0; + } + if (result == 0 || !socket_error_interrupted(socket_last_error())) + { + return false; + } + } +#endif +} + +Vajra::platform::SignedSize Vajra::platform::receive_socket(SocketHandle socket, char *buffer, std::size_t length) +{ +#ifdef _WIN32 + const int chunk = static_cast(std::min(length, static_cast(std::numeric_limits::max()))); + const int result = native_recv()(socket, buffer, chunk, 0); + if (result == SOCKET_ERROR) + { + errno = windows_socket_error_to_errno(WSAGetLastError()); + } + return result; +#else + return recv(socket, buffer, length, 0); +#endif +} + +Vajra::platform::SignedSize Vajra::platform::peek_socket(SocketHandle socket, char *buffer, std::size_t length) +{ +#ifdef _WIN32 + const int chunk = static_cast(std::min(length, static_cast(std::numeric_limits::max()))); + const int result = native_recv()(socket, buffer, chunk, MSG_PEEK); + if (result == SOCKET_ERROR) + { + errno = windows_socket_error_to_errno(WSAGetLastError()); + } + return result; +#else + return recv(socket, buffer, length, MSG_PEEK); +#endif +} + +Vajra::platform::SignedSize Vajra::platform::send_socket(SocketHandle socket, const char *buffer, std::size_t length) +{ +#ifdef _WIN32 + const int chunk = static_cast(std::min(length, static_cast(std::numeric_limits::max()))); + const int result = native_send()(socket, buffer, chunk, 0); + if (result == SOCKET_ERROR) + { + errno = windows_socket_error_to_errno(WSAGetLastError()); + } + return result; +#elif defined(MSG_NOSIGNAL) + return send(socket, buffer, length, MSG_NOSIGNAL); +#else + return send(socket, buffer, length, 0); +#endif +} diff --git a/gems/vajra/ext/vajra/platform/socket.hpp b/gems/vajra/ext/vajra/platform/socket.hpp new file mode 100644 index 0000000..68cff28 --- /dev/null +++ b/gems/vajra/ext/vajra/platform/socket.hpp @@ -0,0 +1,86 @@ +// Copyright Codevedas Inc. 2025-present +// +// This source code is licensed under the MIT license found in the +// LICENSE file in the root directory of this source tree. + +#ifndef VAJRA_PLATFORM_SOCKET_HPP +#define VAJRA_PLATFORM_SOCKET_HPP + +#include +#include +#include + +#ifdef _WIN32 +#ifndef NOMINMAX +#define NOMINMAX +#endif +#include +#include +#else +#include +#include +#include +#include +#endif + +namespace Vajra::platform +{ +#ifdef _WIN32 + using SocketHandle = SOCKET; + using NativeSocketHandle = SOCKET; + using SignedSize = std::intptr_t; + constexpr SocketHandle kInvalidSocket = INVALID_SOCKET; +#else + using SocketHandle = int; + using NativeSocketHandle = int; + using SignedSize = ssize_t; + constexpr SocketHandle kInvalidSocket = -1; +#endif + + enum class WaitEvent : std::uint8_t + { + read, + write, + }; + + class SocketRuntime final + { + public: + SocketRuntime(); + ~SocketRuntime(); + + SocketRuntime(const SocketRuntime &) = delete; + SocketRuntime &operator=(const SocketRuntime &) = delete; + }; + + void ensure_socket_runtime(); + bool socket_valid(SocketHandle socket); + bool socket_open(SocketHandle socket); + NativeSocketHandle native_socket_handle(SocketHandle socket); + int openssl_socket_descriptor(SocketHandle socket); + std::uint64_t socket_handle_value(SocketHandle socket); + int socket_last_error(); + std::string socket_error_message(int error_number); + bool socket_error_interrupted(int error_number); + bool socket_error_would_block(int error_number); + bool socket_error_disconnected(int error_number); + void close_socket(SocketHandle socket); + void shutdown_socket(SocketHandle socket); + bool shutdown_socket_write(SocketHandle socket); + bool set_socket_inheritable(SocketHandle socket, bool inheritable); + bool set_socket_nonblocking(SocketHandle socket, bool nonblocking); + SocketHandle create_tcp_socket(int family, int type, int protocol); + bool set_socket_option(SocketHandle socket, int level, int option, const void *value, socklen_t length); + bool bind_socket(SocketHandle socket, const sockaddr *address, socklen_t address_length); + bool connect_socket(SocketHandle socket, const sockaddr *address, socklen_t address_length); + bool listen_socket(SocketHandle socket, int backlog); + bool socket_name(SocketHandle socket, sockaddr *address, socklen_t *address_length); + bool peer_name(SocketHandle socket, sockaddr *address, socklen_t *address_length); + SocketHandle accept_socket(SocketHandle listener, sockaddr *address, socklen_t *address_length); + bool wait_socket(SocketHandle socket, WaitEvent event, int timeout_milliseconds); + SignedSize receive_socket(SocketHandle socket, char *buffer, std::size_t length); + SignedSize peek_socket(SocketHandle socket, char *buffer, std::size_t length); + SignedSize send_socket(SocketHandle socket, const char *buffer, std::size_t length); +} + +#endif diff --git a/gems/vajra/ext/vajra/rack/http2_stream.cpp b/gems/vajra/ext/vajra/rack/http2_stream.cpp index 492da2d..93db041 100644 --- a/gems/vajra/ext/vajra/rack/http2_stream.cpp +++ b/gems/vajra/ext/vajra/rack/http2_stream.cpp @@ -184,13 +184,13 @@ namespace if (!NIL_P(headers_value)) { Check_Type(headers_value, T_HASH); - VALUE keys = rb_funcall(headers_value, rb_intern("keys"), 0); + VALUE keys = rb_funcallv(headers_value, rb_intern("keys"), 0, nullptr); for (long index = 0; index < RARRAY_LEN(keys); ++index) { VALUE key = rb_ary_entry(keys, index); VALUE value = rb_hash_aref(headers_value, key); - VALUE key_string = rb_funcall(key, id_to_s, 0); - VALUE value_string = rb_funcall(value, id_to_s, 0); + VALUE key_string = rb_funcallv(key, id_to_s, 0, nullptr); + VALUE value_string = rb_funcallv(value, id_to_s, 0, nullptr); headers.push_back(Vajra::response::Header{ std::string(RSTRING_PTR(key_string), static_cast(RSTRING_LEN(key_string))), std::string(RSTRING_PTR(value_string), static_cast(RSTRING_LEN(value_string)))}); diff --git a/gems/vajra/ext/vajra/rack/native_input.cpp b/gems/vajra/ext/vajra/rack/native_input.cpp index 4c06ec8..7b7167f 100644 --- a/gems/vajra/ext/vajra/rack/native_input.cpp +++ b/gems/vajra/ext/vajra/rack/native_input.cpp @@ -652,28 +652,13 @@ namespace return self; } - VALUE native_input_read(int argc, VALUE *argv, VALUE self) + VALUE native_input_read_impl( + VALUE length_value, + VALUE outbuf, + NativeInputWrapper *wrapper) { try { - VALUE length_value = Qnil; - VALUE outbuf = Qnil; - rb_scan_args(argc, argv, "02", &length_value, &outbuf); - - if (!NIL_P(length_value)) - { - const long requested_length = NUM2LONG(length_value); - if (requested_length < 0) - { - rb_raise(rb_eArgError, "negative length"); - } - if (requested_length == 0) - { - return replace_outbuf(outbuf, binary_string_from(std::string())); - } - } - - auto wrapper = native_input_wrapper_from(self); if (!wrapper->state) { return read_preloaded_native_input(*wrapper, length_value, outbuf); @@ -786,6 +771,31 @@ namespace return Qnil; } + VALUE native_input_read(int argc, VALUE *argv, VALUE self) + { + VALUE length_value = Qnil; + VALUE outbuf = Qnil; + rb_scan_args(argc, argv, "02", &length_value, &outbuf); + + if (!NIL_P(length_value)) + { + const long requested_length = NUM2LONG(length_value); + if (requested_length < 0) + { + rb_raise(rb_eArgError, "negative length"); + } + if (requested_length == 0) + { + return replace_outbuf(outbuf, rb_str_new("", 0)); + } + } + + // Keep all explicit Ruby non-local jumps in this POD-only entry frame. + // Ruby longjmp must not cross live C++ strings/shared_ptrs. + NativeInputWrapper *wrapper = native_input_wrapper_from(self); + return native_input_read_impl(length_value, outbuf, wrapper); + } + VALUE native_input_gets(int argc, VALUE *argv, VALUE self) { try diff --git a/gems/vajra/ext/vajra/rack/rack_request_executor.cpp b/gems/vajra/ext/vajra/rack/rack_request_executor.cpp index b81b6d0..f44f435 100644 --- a/gems/vajra/ext/vajra/rack/rack_request_executor.cpp +++ b/gems/vajra/ext/vajra/rack/rack_request_executor.cpp @@ -33,7 +33,7 @@ namespace std::unique_ptr Vajra::rack::RackExecutionTransport::start( const std::vector &env_entries, - int client_fd, + platform::SocketHandle client_fd, std::shared_ptr native_hijack_transport) const { (void)env_entries; diff --git a/gems/vajra/ext/vajra/rack/rack_request_executor.hpp b/gems/vajra/ext/vajra/rack/rack_request_executor.hpp index 85cac6b..db2de49 100644 --- a/gems/vajra/ext/vajra/rack/rack_request_executor.hpp +++ b/gems/vajra/ext/vajra/rack/rack_request_executor.hpp @@ -6,6 +6,7 @@ #ifndef VAJRA_RACK_REQUEST_EXECUTOR_HPP #define VAJRA_RACK_REQUEST_EXECUTOR_HPP +#include "platform/socket.hpp" #include "request/rack_env.hpp" #include "request/request_executor.hpp" @@ -57,18 +58,18 @@ namespace Vajra virtual bool async_completion_supported() const { return false; } virtual std::unique_ptr start( const std::vector &env_entries, - int client_fd, + platform::SocketHandle client_fd, std::shared_ptr native_hijack_transport = nullptr) const; virtual std::optional execute( const std::vector &env_entries, const std::string &request_body, - int client_fd, + platform::SocketHandle client_fd, std::shared_ptr http2_stream = nullptr, std::shared_ptr native_hijack_transport = nullptr) const = 0; virtual std::optional execute( const std::vector &env_entries, std::string &&request_body, - int client_fd, + platform::SocketHandle client_fd, std::shared_ptr http2_stream = nullptr, std::shared_ptr native_hijack_transport = nullptr) const { @@ -82,7 +83,7 @@ namespace Vajra virtual bool execute_async( std::vector env_entries, std::string request_body, - int client_fd, + platform::SocketHandle client_fd, std::shared_ptr http2_stream, std::shared_ptr native_hijack_transport, request::RequestExecutor::CompletionCallback callback) const diff --git a/gems/vajra/ext/vajra/rack/ruby_execution_bridge.cpp b/gems/vajra/ext/vajra/rack/ruby_execution_bridge.cpp index cfa848e..41c0d00 100644 --- a/gems/vajra/ext/vajra/rack/ruby_execution_bridge.cpp +++ b/gems/vajra/ext/vajra/rack/ruby_execution_bridge.cpp @@ -23,8 +23,9 @@ #include #include #include -#include +#ifndef _WIN32 #include +#endif namespace Vajra { @@ -33,7 +34,7 @@ namespace Vajra struct NativeHijackState { mutable std::mutex mutex; - int client_fd = -1; + Vajra::platform::SocketHandle client_fd = Vajra::platform::kInvalidSocket; VALUE rack_input = Qnil; std::shared_ptr input_state; std::shared_ptr transport; @@ -106,7 +107,7 @@ namespace struct NativeTlsHijackIOState { std::unique_ptr ssl; - int fd = -1; + Vajra::platform::SocketHandle fd = Vajra::platform::kInvalidSocket; int read_timeout_milliseconds = 0; int write_timeout_milliseconds = 0; bool closed = false; @@ -169,7 +170,7 @@ namespace VALUE ruby_string_from_header_value(VALUE value) { - return rb_funcall(value, id_to_s, 0); + return rb_funcallv(value, id_to_s, 0, nullptr); } VALUE frozen_ruby_key(const char *name) @@ -225,10 +226,10 @@ namespace SSL_shutdown(wrapper->state->ssl.get()); wrapper->state->ssl.reset(); } - if (wrapper->state->fd >= 0) + if (Vajra::platform::socket_valid(wrapper->state->fd)) { - close(wrapper->state->fd); - wrapper->state->fd = -1; + Vajra::platform::close_socket(wrapper->state->fd); + wrapper->state->fd = Vajra::platform::kInvalidSocket; } wrapper->state->closed = true; } @@ -252,7 +253,8 @@ namespace { NativeTlsHijackIOWrapper *wrapper = nullptr; TypedData_Get_Struct(self, NativeTlsHijackIOWrapper, &native_tls_hijack_io_type, wrapper); - if (wrapper == nullptr || !wrapper->state || wrapper->state->closed || wrapper->state->ssl == nullptr) + if (wrapper == nullptr || !wrapper->state || wrapper->state->closed || + !Vajra::platform::socket_valid(wrapper->state->fd)) { rb_raise(rb_eIOError, "rack.hijack IO is closed"); } @@ -281,62 +283,54 @@ namespace struct PollWaitContext { - int fd = -1; - short events = 0; + Vajra::platform::SocketHandle fd = Vajra::platform::kInvalidSocket; + Vajra::platform::WaitEvent event = Vajra::platform::WaitEvent::read; int timeout_milliseconds = 0; - int result = 0; - int error_number = 0; + bool ready = false; }; void *poll_without_gvl(void *data) { auto *context = static_cast(data); - pollfd descriptor{context->fd, context->events, 0}; - for (;;) - { - const int result = poll(&descriptor, 1, context->timeout_milliseconds); - if (result > 0) - { - context->result = (descriptor.revents & context->events) != 0 ? 1 : 0; - return nullptr; - } - if (result == 0) - { - context->result = 0; - return nullptr; - } - if (errno != EINTR) - { - context->result = -1; - context->error_number = errno; - return nullptr; - } - } + context->ready = Vajra::platform::wait_socket( + context->fd, + context->event, + context->timeout_milliseconds); + return nullptr; } - bool wait_for_tls_hijack_events(int fd, short events, int timeout_milliseconds) + bool wait_for_tls_hijack_events( + Vajra::platform::SocketHandle fd, + Vajra::platform::WaitEvent event, + int timeout_milliseconds) { - PollWaitContext context{fd, events, timeout_milliseconds, 0, 0}; + PollWaitContext context{fd, event, timeout_milliseconds, false}; rb_thread_call_without_gvl(poll_without_gvl, &context, RUBY_UBF_IO, nullptr); - return context.result > 0; + return context.ready; } bool wait_for_tls_hijack_ssl_error(const NativeTlsHijackIOState &state, int ssl_error) { if (ssl_error == SSL_ERROR_WANT_READ) { - return wait_for_tls_hijack_events(state.fd, POLLIN | POLLHUP | POLLERR, state.read_timeout_milliseconds); + return wait_for_tls_hijack_events( + state.fd, + Vajra::platform::WaitEvent::read, + state.read_timeout_milliseconds); } if (ssl_error == SSL_ERROR_WANT_WRITE) { - return wait_for_tls_hijack_events(state.fd, POLLOUT | POLLHUP | POLLERR, state.write_timeout_milliseconds); + return wait_for_tls_hijack_events( + state.fd, + Vajra::platform::WaitEvent::write, + state.write_timeout_milliseconds); } return false; } VALUE native_tls_hijack_io_new( std::unique_ptr ssl, - int fd, + Vajra::platform::SocketHandle fd, int read_timeout_seconds, int write_timeout_seconds) { @@ -360,22 +354,42 @@ namespace while (written < length) { - const int result = SSL_write( - wrapper->state->ssl.get(), - data + written, - static_cast(length - written)); + const int result = wrapper->state->ssl != nullptr + ? SSL_write( + wrapper->state->ssl.get(), + data + written, + static_cast(length - written)) + : static_cast(Vajra::platform::send_socket( + wrapper->state->fd, + data + written, + static_cast(length - written))); if (result > 0) { written += result; continue; } - const int ssl_error = SSL_get_error(wrapper->state->ssl.get(), result); - if (wait_for_tls_hijack_ssl_error(*wrapper->state, ssl_error)) + if (wrapper->state->ssl != nullptr) + { + const int ssl_error = SSL_get_error(wrapper->state->ssl.get(), result); + if (wait_for_tls_hijack_ssl_error(*wrapper->state, ssl_error)) + { + continue; + } + error_message = "TLS rack.hijack write failed: " + openssl_error_string(); + break; + } + const int socket_error = Vajra::platform::socket_last_error(); + if ((Vajra::platform::socket_error_interrupted(socket_error) || + Vajra::platform::socket_error_would_block(socket_error)) && + wait_for_tls_hijack_events( + wrapper->state->fd, + Vajra::platform::WaitEvent::write, + wrapper->state->write_timeout_milliseconds)) { continue; } - error_message = "TLS rack.hijack write failed: " + openssl_error_string(); + error_message = "rack.hijack write failed: " + Vajra::platform::socket_error_message(socket_error); break; } @@ -416,7 +430,12 @@ namespace for (;;) { const long target = read_all ? static_cast(buffer.size()) : requested_length - RSTRING_LEN(output); - const int result = SSL_read(wrapper->state->ssl.get(), buffer.data(), static_cast(target)); + const int result = wrapper->state->ssl != nullptr + ? SSL_read(wrapper->state->ssl.get(), buffer.data(), static_cast(target)) + : static_cast(Vajra::platform::receive_socket( + wrapper->state->fd, + buffer.data(), + static_cast(target))); if (result > 0) { rb_str_cat(output, buffer.data(), result); @@ -427,17 +446,37 @@ namespace continue; } - const int ssl_error = SSL_get_error(wrapper->state->ssl.get(), result); - if (ssl_error == SSL_ERROR_ZERO_RETURN) + if (wrapper->state->ssl == nullptr && result == 0) { eof = true; break; } - if (wait_for_tls_hijack_ssl_error(*wrapper->state, ssl_error)) + if (wrapper->state->ssl != nullptr) + { + const int ssl_error = SSL_get_error(wrapper->state->ssl.get(), result); + if (ssl_error == SSL_ERROR_ZERO_RETURN) + { + eof = true; + break; + } + if (wait_for_tls_hijack_ssl_error(*wrapper->state, ssl_error)) + { + continue; + } + error_message = "TLS rack.hijack read failed: " + openssl_error_string(); + break; + } + const int socket_error = Vajra::platform::socket_last_error(); + if ((Vajra::platform::socket_error_interrupted(socket_error) || + Vajra::platform::socket_error_would_block(socket_error)) && + wait_for_tls_hijack_events( + wrapper->state->fd, + Vajra::platform::WaitEvent::read, + wrapper->state->read_timeout_milliseconds)) { continue; } - error_message = "TLS rack.hijack read failed: " + openssl_error_string(); + error_message = "rack.hijack read failed: " + Vajra::platform::socket_error_message(socket_error); break; } @@ -467,23 +506,47 @@ namespace for (;;) { - const int result = SSL_read(wrapper->state->ssl.get(), buffer.data(), static_cast(buffer.size())); + const int result = wrapper->state->ssl != nullptr + ? SSL_read(wrapper->state->ssl.get(), buffer.data(), static_cast(buffer.size())) + : static_cast(Vajra::platform::receive_socket( + wrapper->state->fd, + buffer.data(), + buffer.size())); if (result > 0) { rb_str_cat(output, buffer.data(), result); return output; } - const int ssl_error = SSL_get_error(wrapper->state->ssl.get(), result); - if (ssl_error == SSL_ERROR_ZERO_RETURN) + if (wrapper->state->ssl == nullptr && result == 0) { rb_raise(rb_eEOFError, "end of file reached"); } - if (wait_for_tls_hijack_ssl_error(*wrapper->state, ssl_error)) + if (wrapper->state->ssl != nullptr) + { + const int ssl_error = SSL_get_error(wrapper->state->ssl.get(), result); + if (ssl_error == SSL_ERROR_ZERO_RETURN) + { + rb_raise(rb_eEOFError, "end of file reached"); + } + if (wait_for_tls_hijack_ssl_error(*wrapper->state, ssl_error)) + { + continue; + } + error_message = "TLS rack.hijack read failed: " + openssl_error_string(); + break; + } + const int socket_error = Vajra::platform::socket_last_error(); + if ((Vajra::platform::socket_error_interrupted(socket_error) || + Vajra::platform::socket_error_would_block(socket_error)) && + wait_for_tls_hijack_events( + wrapper->state->fd, + Vajra::platform::WaitEvent::read, + wrapper->state->read_timeout_milliseconds)) { continue; } - error_message = "TLS rack.hijack read failed: " + openssl_error_string(); + error_message = "rack.hijack read failed: " + Vajra::platform::socket_error_message(socket_error); break; } @@ -509,10 +572,10 @@ namespace SSL_shutdown(wrapper->state->ssl.get()); wrapper->state->ssl.reset(); } - if (wrapper->state->fd >= 0) + if (Vajra::platform::socket_valid(wrapper->state->fd)) { - close(wrapper->state->fd); - wrapper->state->fd = -1; + Vajra::platform::close_socket(wrapper->state->fd); + wrapper->state->fd = Vajra::platform::kInvalidSocket; } wrapper->state->closed = true; return Qnil; @@ -539,11 +602,11 @@ namespace { NativeHijackWrapper *wrapper = native_hijack_wrapper_from(self); std::string error_message; - int client_fd = -1; + Vajra::platform::SocketHandle client_fd = Vajra::platform::kInvalidSocket; std::shared_ptr transport; { std::lock_guard lock(wrapper->state->mutex); - if (wrapper->state->client_fd < 0) + if (!Vajra::platform::socket_valid(wrapper->state->client_fd)) { error_message = "rack.hijack is not available"; } @@ -593,10 +656,22 @@ namespace return transport->call(); } +#ifdef _WIN32 + if (!Vajra::platform::set_socket_nonblocking(client_fd, true)) + { + rb_raise(rb_eIOError, "rack.hijack could not configure the native socket"); + } + return native_tls_hijack_io_new(nullptr, client_fd, 30, 30); +#else + VALUE keywords = rb_hash_new(); rb_hash_aset(keywords, ID2SYM(rb_intern("autoclose")), Qtrue); - VALUE arguments[] = {INT2NUM(client_fd), keywords}; - return rb_funcallv_kw(rb_cIO, id_for_fd, 2, arguments, RB_PASS_KEYWORDS); + VALUE arguments[] = { + ULL2NUM(static_cast(client_fd)), + rb_str_new_cstr("r+"), + keywords}; + return rb_funcallv_kw(rb_cIO, id_for_fd, 3, arguments, RB_PASS_KEYWORDS); +#endif } VALUE native_hijack_new(std::shared_ptr state) @@ -638,10 +713,10 @@ namespace bool rack_env_supports_full_hijack( const std::vector &env_entries, - int client_fd, + Vajra::platform::SocketHandle client_fd, const std::shared_ptr &transport) { - if (client_fd < 0) + if (!Vajra::platform::socket_valid(client_fd)) { return false; } @@ -665,7 +740,7 @@ namespace std::shared_ptr install_hijack_if_supported( VALUE env, const std::vector &env_entries, - int client_fd, + Vajra::platform::SocketHandle client_fd, VALUE rack_input, std::shared_ptr input_state, std::shared_ptr transport) @@ -779,7 +854,7 @@ namespace VALUE protected_exception_message(VALUE data) { auto *exception = reinterpret_cast(data); - return rb_funcall(*exception, id_exception_message, 0); + return rb_funcallv(*exception, id_exception_message, 0, nullptr); } VALUE rack_header_each_callback(VALUE yielded, VALUE data, int argc, const VALUE *argv, VALUE blockarg); @@ -818,7 +893,7 @@ namespace return Qnil; } - return rb_funcall(body, id_close, 0); + return rb_funcallv(body, id_close, 0, nullptr); } VALUE rack_header_each_callback(VALUE yielded, VALUE data, int argc, const VALUE *argv, VALUE) @@ -1265,7 +1340,7 @@ VALUE Vajra::rack::RubyExecutionBridge::env_entries_array_from( VALUE Vajra::rack::RubyExecutionBridge::rack_env_from( const std::vector &env_entries, std::string request_body, - int client_fd, + Vajra::platform::SocketHandle client_fd, std::shared_ptr *hijack_state, std::shared_ptr http2_stream, std::shared_ptr native_hijack_transport) @@ -1302,7 +1377,7 @@ VALUE Vajra::rack::RubyExecutionBridge::rack_env_from( VALUE Vajra::rack::RubyExecutionBridge::rack_env_from( const std::vector &env_entries, VALUE rack_input, - int client_fd, + Vajra::platform::SocketHandle client_fd, std::shared_ptr input_state, std::shared_ptr *hijack_state, std::shared_ptr http2_stream, diff --git a/gems/vajra/ext/vajra/rack/ruby_execution_bridge.hpp b/gems/vajra/ext/vajra/rack/ruby_execution_bridge.hpp index 45111a4..2afad06 100644 --- a/gems/vajra/ext/vajra/rack/ruby_execution_bridge.hpp +++ b/gems/vajra/ext/vajra/rack/ruby_execution_bridge.hpp @@ -6,6 +6,7 @@ #ifndef VAJRA_RACK_RUBY_EXECUTION_BRIDGE_HPP #define VAJRA_RACK_RUBY_EXECUTION_BRIDGE_HPP +#include "platform/socket.hpp" #include "request/rack_env.hpp" #include "response/response.hpp" #include "ruby.h" @@ -58,14 +59,14 @@ namespace Vajra static VALUE rack_env_from( const std::vector &env_entries, std::string request_body, - int client_fd = -1, + platform::SocketHandle client_fd = platform::kInvalidSocket, std::shared_ptr *hijack_state = nullptr, std::shared_ptr http2_stream = nullptr, std::shared_ptr native_hijack_transport = nullptr); static VALUE rack_env_from( const std::vector &env_entries, VALUE rack_input, - int client_fd, + platform::SocketHandle client_fd, std::shared_ptr input_state, std::shared_ptr *hijack_state, std::shared_ptr http2_stream = nullptr, diff --git a/gems/vajra/ext/vajra/rack/ruby_rack_transport.cpp b/gems/vajra/ext/vajra/rack/ruby_rack_transport.cpp index db50aaf..ca2fa09 100644 --- a/gems/vajra/ext/vajra/rack/ruby_rack_transport.cpp +++ b/gems/vajra/ext/vajra/rack/ruby_rack_transport.cpp @@ -9,6 +9,7 @@ #include "rack/native_input.hpp" #include "rack/rack_execution_profiler.hpp" #include "rack/ruby_execution_bridge.hpp" +#include "platform/process.hpp" #include "runtime/runtime_logging.hpp" #include "runtime/runtime_state.hpp" #include "runtime/traceparent.hpp" @@ -22,7 +23,6 @@ #include #include #include -#include namespace { @@ -38,7 +38,7 @@ namespace const std::vector *env_entries; std::string *request_body; VALUE rack_input = Qnil; - int client_fd = -1; + Vajra::platform::SocketHandle client_fd = Vajra::platform::kInvalidSocket; std::shared_ptr input_state; std::shared_ptr hijack_state; std::shared_ptr http2_stream; @@ -130,7 +130,7 @@ namespace event.response_sent = true; event.connection_outcome = response.connection_behavior == Vajra::response::ConnectionBehavior::close ? "close" : "keepalive"; event.worker_index = static_cast(Vajra::runtime::current_worker_index()); - event.worker_pid = getpid(); + event.worker_pid = Vajra::platform::current_process_id(); event.trace_id = Vajra::runtime::traceparent_part(fields.traceparent, 1); event.span_id = Vajra::runtime::traceparent_part(fields.traceparent, 2); return event; @@ -367,7 +367,7 @@ namespace std::optional execute_rack_request( const std::vector &env_entries, std::string request_body, - int client_fd, + Vajra::platform::SocketHandle client_fd, std::shared_ptr http2_stream, std::shared_ptr native_hijack_transport, bool acquire_gvl) @@ -399,7 +399,7 @@ namespace std::optional execute_rack_request( const std::vector &env_entries, VALUE rack_input, - int client_fd, + Vajra::platform::SocketHandle client_fd, std::shared_ptr input_state, std::shared_ptr http2_stream, std::shared_ptr native_hijack_transport, @@ -448,7 +448,7 @@ namespace SameProcessRackTask( std::vector env_entries, std::shared_ptr input_state, - int client_fd, + Vajra::platform::SocketHandle client_fd, std::shared_ptr native_hijack_transport) : env_entries_(std::move(env_entries)), input_state_(std::move(input_state)), @@ -534,7 +534,7 @@ namespace std::vector env_entries_; std::shared_ptr input_state_; - int client_fd_ = -1; + Vajra::platform::SocketHandle client_fd_ = Vajra::platform::kInvalidSocket; std::shared_ptr native_hijack_transport_; mutable std::mutex mutex_; std::condition_variable condition_; @@ -549,7 +549,7 @@ namespace SameProcessDirectRackTask( std::vector env_entries, std::string request_body, - int client_fd, + Vajra::platform::SocketHandle client_fd, std::shared_ptr http2_stream = nullptr, std::shared_ptr native_hijack_transport = nullptr, Vajra::request::RequestExecutor::CompletionCallback callback = nullptr) @@ -655,7 +655,7 @@ namespace std::vector env_entries_; std::string request_body_; - int client_fd_ = -1; + Vajra::platform::SocketHandle client_fd_ = Vajra::platform::kInvalidSocket; std::shared_ptr http2_stream_; std::shared_ptr native_hijack_transport_; Vajra::request::RequestExecutor::CompletionCallback callback_; @@ -845,7 +845,7 @@ namespace public: SameProcessRackExecutionSession( std::vector env_entries, - int client_fd, + Vajra::platform::SocketHandle client_fd, std::shared_ptr native_hijack_transport) : input_state_(Vajra::rack::create_native_input_state()), task_(std::make_shared( @@ -980,7 +980,7 @@ namespace std::unique_ptr start( const std::vector &env_entries, - int client_fd, + Vajra::platform::SocketHandle client_fd, std::shared_ptr native_hijack_transport = nullptr) const override { return std::make_unique( @@ -992,7 +992,7 @@ namespace std::optional execute( const std::vector &env_entries, const std::string &request_body, - int client_fd, + Vajra::platform::SocketHandle client_fd, std::shared_ptr http2_stream = nullptr, std::shared_ptr native_hijack_transport = nullptr) const override { @@ -1027,7 +1027,7 @@ namespace std::optional execute( const std::vector &env_entries, std::string &&request_body, - int client_fd, + Vajra::platform::SocketHandle client_fd, std::shared_ptr http2_stream = nullptr, std::shared_ptr native_hijack_transport = nullptr) const override { @@ -1062,7 +1062,7 @@ namespace bool execute_async( std::vector env_entries, std::string request_body, - int client_fd, + Vajra::platform::SocketHandle client_fd, std::shared_ptr http2_stream, std::shared_ptr native_hijack_transport, Vajra::request::RequestExecutor::CompletionCallback callback) const override @@ -1158,7 +1158,7 @@ void Vajra::rack::shutdown_same_process_rack_execution_threads() std::optional Vajra::rack::execute_current_thread_rack_request( const std::vector &env_entries, const std::string &request_body, - int client_fd) + Vajra::platform::SocketHandle client_fd) { return execute_rack_request(env_entries, request_body, client_fd, nullptr, nullptr, false); } @@ -1166,7 +1166,7 @@ std::optional Vajra::rack::execute_current_thread_rac std::optional Vajra::rack::execute_current_thread_rack_request( const std::vector &env_entries, VALUE rack_input, - int client_fd, + Vajra::platform::SocketHandle client_fd, std::shared_ptr input_state) { return execute_rack_request(env_entries, rack_input, client_fd, std::move(input_state), nullptr, nullptr, false); diff --git a/gems/vajra/ext/vajra/rack/ruby_rack_transport.hpp b/gems/vajra/ext/vajra/rack/ruby_rack_transport.hpp index 51171e4..57bc2c9 100644 --- a/gems/vajra/ext/vajra/rack/ruby_rack_transport.hpp +++ b/gems/vajra/ext/vajra/rack/ruby_rack_transport.hpp @@ -22,11 +22,11 @@ namespace Vajra std::optional execute_current_thread_rack_request( const std::vector &env_entries, const std::string &request_body, - int client_fd = -1); + platform::SocketHandle client_fd = platform::kInvalidSocket); std::optional execute_current_thread_rack_request( const std::vector &env_entries, VALUE rack_input, - int client_fd = -1, + platform::SocketHandle client_fd = platform::kInvalidSocket, std::shared_ptr input_state = nullptr); } } diff --git a/gems/vajra/ext/vajra/request/http2_session.cpp b/gems/vajra/ext/vajra/request/http2_session.cpp index 970dfd1..25ed6e8 100644 --- a/gems/vajra/ext/vajra/request/http2_session.cpp +++ b/gems/vajra/ext/vajra/request/http2_session.cpp @@ -8,10 +8,12 @@ #include "http_field_utils.hpp" #include "rack/http2_stream.hpp" #include "rack/native_input.hpp" +#include "platform/process.hpp" #include "response/http_header_utils.hpp" #include "response/response_serializer.hpp" #include "runtime/runtime_logging.hpp" #include "runtime/runtime_state.hpp" +#include "vajra.hpp" #include @@ -39,7 +41,6 @@ #include #include #include -#include namespace { @@ -410,11 +411,18 @@ class Vajra::request::Http2Session::Impl final const bool has_work = has_pending_executions() || has_finished_executions(); const bool has_active_body_flow_control = has_active_request_body_flow_control(); + const bool runtime_stopping = + Vajra::runtime::runtime_shutdown_requested() || VajraNative::shutdown_requested(); + if (runtime_stopping && !has_work && !has_active_body_flow_control) + { + return; + } + if (!wants_read) { if (peer_goaway_received_) { - if (connection_.fd() >= 0 && connection_.wait_readable(0)) + if (platform::socket_open(connection_.fd()) && connection_.wait_readable(0)) { if (!receive_once(buffer)) { @@ -459,7 +467,7 @@ class Vajra::request::Http2Session::Impl final std::this_thread::sleep_for(std::chrono::milliseconds(kHttp2ActiveBodySleepMilliseconds)); continue; } - if (connection_.fd() < 0) + if (!platform::socket_open(connection_.fd())) { return; } @@ -1167,9 +1175,25 @@ class Vajra::request::Http2Session::Impl final const ssize_t result = connection_.write( reinterpret_cast(data + written), length - written); - if (result <= 0) + if (result < 0) + { + const int error_number = Vajra::platform::socket_last_error(); + if (Vajra::platform::socket_error_interrupted(error_number)) + { + continue; + } + if (Vajra::platform::socket_error_would_block(error_number) && + Vajra::platform::wait_socket(connection_.fd(), Vajra::platform::WaitEvent::write, 1000)) + { + continue; + } + throw std::runtime_error( + "HTTP/2 serialized write failed: error=" + std::to_string(error_number) + + " message=" + Vajra::platform::socket_error_message(error_number)); + } + if (result == 0) { - throw std::runtime_error("HTTP/2 serialized write failed"); + throw std::runtime_error("HTTP/2 serialized write failed: peer closed the connection"); } written += static_cast(result); } @@ -2664,13 +2688,16 @@ class Vajra::request::Http2Session::Impl final std::vector> finished; { std::unique_lock lock(finished_executions_mutex_); - if (!finished_executions_.empty() && - (pending_execution_count_.load(std::memory_order_acquire) > finished_executions_.size() || - has_higher_priority_pending_execution(finished_executions_))) + const auto coalesce_deadline = std::chrono::steady_clock::now() + + std::chrono::milliseconds(kHttp2PriorityCoalesceWaitMilliseconds); + while (!finished_executions_.empty() && + (pending_execution_count_.load(std::memory_order_acquire) > finished_executions_.size() || + has_higher_priority_pending_execution(finished_executions_))) { - finished_executions_condition_.wait_for( - lock, - std::chrono::milliseconds(kHttp2PriorityCoalesceWaitMilliseconds)); + if (finished_executions_condition_.wait_until(lock, coalesce_deadline) == std::cv_status::timeout) + { + break; + } } finished.swap(finished_executions_); } @@ -2904,7 +2931,10 @@ class Vajra::request::Http2Session::Impl final check( nghttp2_submit_data( session_.get(), - stream.tunnel_end_stream_queued && stream.response_chunk_index >= stream.response_body_chunks.size() ? NGHTTP2_FLAG_END_STREAM : NGHTTP2_FLAG_NONE, + static_cast( + stream.tunnel_end_stream_queued && stream.response_chunk_index >= stream.response_body_chunks.size() + ? NGHTTP2_FLAG_END_STREAM + : NGHTTP2_FLAG_NONE), stream_id, &provider), "nghttp2_submit_data"); @@ -3261,7 +3291,7 @@ class Vajra::request::Http2Session::Impl final "", "", "", - getpid(), + Vajra::platform::current_process_id(), static_cast(Vajra::runtime::current_worker_index()), "keepalive", "", diff --git a/gems/vajra/ext/vajra/request/request_body_reader.cpp b/gems/vajra/ext/vajra/request/request_body_reader.cpp index a975734..358d583 100644 --- a/gems/vajra/ext/vajra/request/request_body_reader.cpp +++ b/gems/vajra/ext/vajra/request/request_body_reader.cpp @@ -17,7 +17,6 @@ #include #include #include -#include #include #include @@ -69,7 +68,7 @@ namespace void wait_for_body_bytes(Vajra::transport::Connection &connection, BodyReadDeadline deadline) { - if (connection.fd() < 0) + if (connection.fd() == Vajra::platform::kInvalidSocket) { throw Vajra::request::BodyReadIncompleteError(); } @@ -397,7 +396,7 @@ namespace } Vajra::request::BodyReadResult Vajra::request::RequestBodyReader::stream_read( - int client_fd, + platform::SocketHandle client_fd, const ParsedRequest &request, const BodyChunkCallback &on_body_chunk, std::string buffered_bytes) const @@ -431,7 +430,7 @@ Vajra::request::BodyReadResult Vajra::request::RequestBodyReader::stream_read( } Vajra::request::BodyReadResult Vajra::request::RequestBodyReader::read( - int client_fd, + platform::SocketHandle client_fd, const ParsedRequest &request, std::string buffered_bytes) const { diff --git a/gems/vajra/ext/vajra/request/request_body_reader.hpp b/gems/vajra/ext/vajra/request/request_body_reader.hpp index 9370a15..e5df844 100644 --- a/gems/vajra/ext/vajra/request/request_body_reader.hpp +++ b/gems/vajra/ext/vajra/request/request_body_reader.hpp @@ -69,7 +69,7 @@ namespace Vajra } BodyReadResult stream_read( - int client_fd, + platform::SocketHandle client_fd, const ParsedRequest &request, const BodyChunkCallback &on_body_chunk, std::string buffered_bytes = "") const; @@ -80,7 +80,7 @@ namespace Vajra std::string buffered_bytes = "") const; BodyReadResult read( - int client_fd, + platform::SocketHandle client_fd, const ParsedRequest &request, std::string buffered_bytes = "") const; BodyReadResult read( diff --git a/gems/vajra/ext/vajra/request/request_context.hpp b/gems/vajra/ext/vajra/request/request_context.hpp index bdbbd5b..9d5cd5d 100644 --- a/gems/vajra/ext/vajra/request/request_context.hpp +++ b/gems/vajra/ext/vajra/request/request_context.hpp @@ -6,6 +6,7 @@ #ifndef VAJRA_REQUEST_CONTEXT_HPP #define VAJRA_REQUEST_CONTEXT_HPP +#include "platform/socket.hpp" #include "request_head_types.hpp" #include @@ -34,7 +35,7 @@ namespace Vajra { ParsedRequest request; SocketContext socket; - int client_fd = -1; + platform::SocketHandle client_fd = platform::kInvalidSocket; std::string request_body = ""; std::shared_ptr http2_stream; std::shared_ptr native_hijack_transport; diff --git a/gems/vajra/ext/vajra/request/request_head_parser.hpp b/gems/vajra/ext/vajra/request/request_head_parser.hpp index 40aacf2..519916a 100644 --- a/gems/vajra/ext/vajra/request/request_head_parser.hpp +++ b/gems/vajra/ext/vajra/request/request_head_parser.hpp @@ -9,8 +9,8 @@ #include "request_head_error.hpp" #include "request_head_types.hpp" #include "request_line_validation_pipeline.hpp" +#include "platform/socket.hpp" -#include #include #include @@ -224,8 +224,8 @@ namespace Vajra { const unsigned char character = static_cast(host[index]); const bool ascii_alphanumeric = (character >= 'a' && character <= 'z') || - (character >= 'A' && character <= 'Z') || - (character >= '0' && character <= '9'); + (character >= 'A' && character <= 'Z') || + (character >= '0' && character <= '9'); const bool unreserved = ascii_alphanumeric || character == '-' || character == '.' || character == '_' || character == '~'; if (unreserved) diff --git a/gems/vajra/ext/vajra/request/request_head_reader.cpp b/gems/vajra/ext/vajra/request/request_head_reader.cpp index 6624756..ead55cd 100644 --- a/gems/vajra/ext/vajra/request/request_head_reader.cpp +++ b/gems/vajra/ext/vajra/request/request_head_reader.cpp @@ -27,7 +27,7 @@ Vajra::request::HeadReader::HeadReader(std::size_t max_request_head_bytes, int c } Vajra::request::HeadReadResult Vajra::request::HeadReader::read( - int client_fd, + platform::SocketHandle client_fd, std::string buffered_bytes, int initial_timeout_seconds) const { diff --git a/gems/vajra/ext/vajra/request/request_head_reader.hpp b/gems/vajra/ext/vajra/request/request_head_reader.hpp index 0a74893..83c3746 100644 --- a/gems/vajra/ext/vajra/request/request_head_reader.hpp +++ b/gems/vajra/ext/vajra/request/request_head_reader.hpp @@ -32,7 +32,7 @@ namespace Vajra int continuation_timeout_seconds = 5); HeadReadResult read( - int client_fd, + platform::SocketHandle client_fd, std::string buffered_bytes = "", int initial_timeout_seconds = 30) const; HeadReadResult read( diff --git a/gems/vajra/ext/vajra/request/request_processor.cpp b/gems/vajra/ext/vajra/request/request_processor.cpp index f53052c..06dba17 100644 --- a/gems/vajra/ext/vajra/request/request_processor.cpp +++ b/gems/vajra/ext/vajra/request/request_processor.cpp @@ -12,6 +12,7 @@ #include "runtime/runtime_state.hpp" #include "runtime/traceparent.hpp" #include "rack/native_input.hpp" +#include "platform/process.hpp" #include "transport/tls_connection.hpp" #include @@ -20,7 +21,11 @@ #include #include #include +#ifdef _WIN32 +#include +#else #include +#endif #include #include @@ -193,7 +198,7 @@ namespace headers.user_agent, headers.referer, headers.request_id, - getpid(), + Vajra::platform::current_process_id(), static_cast(Vajra::runtime::current_worker_index()), connection_outcome, use_incoming_trace_context ? Vajra::runtime::traceparent_part(headers.traceparent, 1) : trace_id, @@ -218,7 +223,7 @@ namespace "", "", "", - getpid(), + Vajra::platform::current_process_id(), static_cast(Vajra::runtime::current_worker_index()), "close", "", @@ -448,7 +453,11 @@ namespace { if (fd_ >= 0) { +#ifdef _WIN32 + _close(fd_); +#else close(fd_); +#endif } } @@ -469,6 +478,29 @@ namespace private: std::chrono::steady_clock::time_point started_at_; }; + + class RequestActivityGuard + { + public: + explicit RequestActivityGuard(const std::function &callback) : callback_(callback) + { + if (callback_) + { + callback_(true); + } + } + + ~RequestActivityGuard() + { + if (callback_) + { + callback_(false); + } + } + + private: + const std::function &callback_; + }; } namespace Vajra @@ -521,7 +553,8 @@ Vajra::request::RequestProcessor::RequestProcessor( Vajra::request::RequestProcessingOutcome Vajra::request::RequestProcessor::handle( Vajra::transport::Connection &connection, - const SocketContext &socket_context) const + const SocketContext &socket_context, + const std::function &request_activity_callback) const { std::string buffered_bytes; bool first_request = true; @@ -534,7 +567,8 @@ Vajra::request::RequestProcessingOutcome Vajra::request::RequestProcessor::handl socket_context, std::move(buffered_bytes), first_request, - force_close); + force_close, + request_activity_callback); if (result.outcome != RequestProcessingOutcome::keep_alive) { @@ -557,7 +591,8 @@ Vajra::request::RequestProcessingResult Vajra::request::RequestProcessor::handle const SocketContext &socket_context, std::string buffered_bytes, bool first_request, - bool force_close_after_response) const + bool force_close_after_response, + const std::function &request_activity_callback) const { const auto started_at = std::chrono::steady_clock::now(); HeadReadResult head_read_result; @@ -613,6 +648,21 @@ Vajra::request::RequestProcessingResult Vajra::request::RequestProcessor::handle first_request}; } + RequestActivityGuard request_activity_guard(request_activity_callback); + constexpr std::string_view http2_prior_knowledge_head = "PRI * HTTP/2.0\r\n\r\n"; + if (http2_enabled_ && head_read_result.request_head == http2_prior_knowledge_head) + { + Http2Session session( + connection, + socket_context, + http2_config_, + request_executor_, + http2_execution_pool_, + head_read_result.request_head + std::move(head_read_result.trailing_bytes)); + session.run(); + return RequestProcessingResult{RequestProcessingOutcome::close, "", false}; + } + RequestWallClockRecorder request_wall_clock_recorder; const auto request_started_at = std::chrono::steady_clock::now(); diff --git a/gems/vajra/ext/vajra/request/request_processor.hpp b/gems/vajra/ext/vajra/request/request_processor.hpp index 0ea0f2c..f91c11b 100644 --- a/gems/vajra/ext/vajra/request/request_processor.hpp +++ b/gems/vajra/ext/vajra/request/request_processor.hpp @@ -17,6 +17,7 @@ #include #include +#include #include namespace Vajra @@ -58,14 +59,18 @@ namespace Vajra bool http2_enabled = false, Http2Config http2_config = {}); - RequestProcessingOutcome handle(Vajra::transport::Connection &connection, const SocketContext &socket_context) const; + RequestProcessingOutcome handle( + Vajra::transport::Connection &connection, + const SocketContext &socket_context, + const std::function &request_activity_callback = {}) const; std::shared_ptr http2_execution_pool() const; RequestProcessingResult handle_one( Vajra::transport::Connection &connection, const SocketContext &socket_context, std::string buffered_bytes = "", bool first_request = true, - bool force_close_after_response = false) const; + bool force_close_after_response = false, + const std::function &request_activity_callback = {}) const; private: Vajra::response::ConnectionBehavior connection_behavior_for(const ParsedRequest &request) const; diff --git a/gems/vajra/ext/vajra/response/response_writer.cpp b/gems/vajra/ext/vajra/response/response_writer.cpp index bcef814..3a5bcba 100644 --- a/gems/vajra/ext/vajra/response/response_writer.cpp +++ b/gems/vajra/ext/vajra/response/response_writer.cpp @@ -12,27 +12,25 @@ #include #include #include +#ifndef _WIN32 #include #include #include +#endif -namespace -{ -} - -void Vajra::response::ResponseWriter::prepare_client_socket(int client_fd) +void Vajra::response::ResponseWriter::prepare_client_socket(platform::SocketHandle client_fd) { int opt = 1; #ifdef TCP_NODELAY - (void)setsockopt(client_fd, IPPROTO_TCP, TCP_NODELAY, &opt, sizeof(opt)); + (void)platform::set_socket_option(client_fd, IPPROTO_TCP, TCP_NODELAY, &opt, sizeof(opt)); #endif #ifdef SO_NOSIGPIPE - (void)setsockopt(client_fd, SOL_SOCKET, SO_NOSIGPIPE, &opt, sizeof(opt)); + (void)platform::set_socket_option(client_fd, SOL_SOCKET, SO_NOSIGPIPE, &opt, sizeof(opt)); #endif (void)client_fd; } -bool Vajra::response::ResponseWriter::send(int client_fd, const Response &response) const +bool Vajra::response::ResponseWriter::send(platform::SocketHandle client_fd, const Response &response) const { prepare_client_socket(client_fd); Vajra::transport::PlainConnection connection(client_fd); @@ -180,7 +178,9 @@ void Vajra::response::ResponseWriter::log_serialization_error(const Serializatio std::cerr << "response serialization failed: " << error.what() << std::endl; } -bool Vajra::response::ResponseWriter::send_response_message(int client_fd, const std::string &response_message) const +bool Vajra::response::ResponseWriter::send_response_message( + platform::SocketHandle client_fd, + const std::string &response_message) const { prepare_client_socket(client_fd); Vajra::transport::PlainConnection connection(client_fd); diff --git a/gems/vajra/ext/vajra/response/response_writer.hpp b/gems/vajra/ext/vajra/response/response_writer.hpp index 598fb59..b6b74c6 100644 --- a/gems/vajra/ext/vajra/response/response_writer.hpp +++ b/gems/vajra/ext/vajra/response/response_writer.hpp @@ -20,8 +20,8 @@ namespace Vajra class ResponseWriter { public: - static void prepare_client_socket(int client_fd); - bool send(int client_fd, const Response &response) const; + static void prepare_client_socket(platform::SocketHandle client_fd); + bool send(platform::SocketHandle client_fd, const Response &response) const; bool send(Vajra::transport::Connection &connection, const Response &response) const; bool send(Vajra::transport::Connection &connection, const Response &response, bool suppress_body) const; Response success_response(ConnectionBehavior connection_behavior = ConnectionBehavior::close) const; @@ -32,7 +32,7 @@ namespace Vajra void log_request_head_error(const Vajra::request::HeadError &error) const; private: - bool send_response_message(int client_fd, const std::string &response_message) const; + bool send_response_message(platform::SocketHandle client_fd, const std::string &response_message) const; bool send_response_message(Vajra::transport::Connection &connection, const std::string &response_message) const; bool send_response_bytes(Vajra::transport::Connection &connection, const char *data, std::size_t length) const; const char *request_head_failure_label(Vajra::request::HeadFailureKind kind) const; diff --git a/gems/vajra/ext/vajra/runtime/native_runtime.cpp b/gems/vajra/ext/vajra/runtime/native_runtime.cpp index 5bf71cc..14099fa 100644 --- a/gems/vajra/ext/vajra/runtime/native_runtime.cpp +++ b/gems/vajra/ext/vajra/runtime/native_runtime.cpp @@ -152,7 +152,7 @@ namespace const VALUE tracing = rb_const_get(internal, rb_intern("Tracing")); if (rb_respond_to(tracing, id_after_fork)) { - rb_funcall(tracing, id_after_fork, 0); + rb_funcallv(tracing, id_after_fork, 0, nullptr); } return Qnil; } @@ -167,6 +167,29 @@ namespace } } + VALUE notify_tracing_before_worker_exit_protected(VALUE) + { + const ID id_before_worker_exit = rb_intern("before_worker_exit!"); + const VALUE vajra = rb_const_get(rb_cObject, rb_intern("Vajra")); + const VALUE internal = rb_const_get(vajra, rb_intern("Internal")); + const VALUE tracing = rb_const_get(internal, rb_intern("Tracing")); + if (rb_respond_to(tracing, id_before_worker_exit)) + { + rb_funcallv(tracing, id_before_worker_exit, 0, nullptr); + } + return Qnil; + } + + void notify_tracing_before_worker_exit() + { + int state = 0; + rb_protect(notify_tracing_before_worker_exit_protected, Qnil, &state); + if (state != 0) + { + rb_set_errinfo(Qnil); + } + } + void handle_signal(int sig) { if (sig == SIGINT || sig == SIGTERM) @@ -3851,7 +3874,11 @@ void Vajra::runtime::NativeRuntime::run_worker_process( } const auto boot_finished_at = std::chrono::steady_clock::now(); const std::chrono::duration boot_elapsed = boot_finished_at - boot_started_at; - log_worker_booted(worker_index, getpid(), boot_elapsed.count()); + log_worker_booted( + worker_index, + getpid(), + Vajra::platform::current_parent_process_id(), + boot_elapsed.count()); close(readiness_write_fd); while (!shutdown_requested_or_runtime_draining()) @@ -3937,6 +3964,7 @@ void Vajra::runtime::NativeRuntime::run_worker_process( } } Vajra::rack::shutdown_same_process_rack_execution_threads(); + notify_tracing_before_worker_exit(); Vajra::runtime::stop_runtime_tracing_worker(); Vajra::runtime::stop_runtime_logging_worker(); _exit(0); diff --git a/gems/vajra/ext/vajra/runtime/native_runtime.hpp b/gems/vajra/ext/vajra/runtime/native_runtime.hpp index 3312527..4a89b97 100644 --- a/gems/vajra/ext/vajra/runtime/native_runtime.hpp +++ b/gems/vajra/ext/vajra/runtime/native_runtime.hpp @@ -7,6 +7,7 @@ #define VAJRA_RUNTIME_NATIVE_RUNTIME_HPP #include "runtime/boot_contract.hpp" +#include "platform/process.hpp" #include "request/request_body_reader.hpp" #include "runtime/runtime_config.hpp" #include "runtime/runtime_state.hpp" @@ -19,7 +20,6 @@ #include #include #include -#include #include #include @@ -27,6 +27,9 @@ namespace Vajra { namespace runtime { +#ifdef _WIN32 + class WindowsWorkerSupervisor; +#endif struct HealthPolicy { std::int64_t overload_oldest_queue_age_nanoseconds = 0; @@ -97,7 +100,7 @@ namespace Vajra void replay_pending_stop_if_needed(); std::shared_ptr register_worker_runtime( std::size_t worker_index, - pid_t pid, + platform::ProcessId pid, std::vector control_channel_fds); void mark_worker_ready(const std::shared_ptr &worker_state); void mark_worker_stopping(const std::shared_ptr &worker_state); @@ -119,14 +122,14 @@ namespace Vajra bool spawn_worker_from_single_thread( std::size_t worker_index, const WorkerSpawnConfig &spawn_config, - pid_t &pid, + platform::ProcessId &pid, std::vector &parent_control_channels, BootDiagnostic &failure_diagnostic, int inherited_control_fd); bool boot_replacement_worker( const std::shared_ptr &worker_state, const WorkerSpawnConfig &spawn_config, - pid_t &pid, + platform::ProcessId &pid, std::vector &parent_control_channels, BootDiagnostic &failure_diagnostic); void clear_worker_runtime(); @@ -201,11 +204,14 @@ namespace Vajra std::atomic_bool debug_logging_{false}; std::vector> pending_replacements_; RuntimeState *runtime_state_ = nullptr; - pid_t worker_spawner_pid_ = -1; + platform::ProcessId worker_spawner_pid_ = platform::kInvalidProcessId; int worker_spawner_fd_ = -1; bool worker_exit_watcher_stop_requested_ = false; bool worker_exit_watcher_running_ = false; std::thread worker_exit_watcher_; +#ifdef _WIN32 + std::shared_ptr windows_supervisor_; +#endif }; } } diff --git a/gems/vajra/ext/vajra/runtime/native_runtime_windows.cpp b/gems/vajra/ext/vajra/runtime/native_runtime_windows.cpp new file mode 100644 index 0000000..fe8c24c --- /dev/null +++ b/gems/vajra/ext/vajra/runtime/native_runtime_windows.cpp @@ -0,0 +1,515 @@ +// Copyright Codevedas Inc. 2025-present +// +// This source code is licensed under the MIT license found in the +// LICENSE file in the root directory of this source tree. + +#ifdef _WIN32 + +#include "runtime/native_runtime.hpp" + +#include "rack/rack_request_executor.hpp" +#include "rack/ruby_rack_transport.hpp" +#include "runtime/boot_contract.hpp" +#include "runtime/runtime_logging.hpp" +#include "runtime/windows_worker_backend.hpp" +#include "vajra.hpp" + +#include "ruby/thread.h" + +#include +#include +#include +#include +#include +#include + +namespace +{ + std::atomic_bool shutting_down{false}; + + BOOL WINAPI handle_console_control(DWORD control_type) + { + switch (control_type) + { + case CTRL_C_EVENT: + case CTRL_BREAK_EVENT: + case CTRL_CLOSE_EVENT: + case CTRL_LOGOFF_EVENT: + case CTRL_SHUTDOWN_EVENT: + shutting_down.store(true, std::memory_order_release); + return TRUE; + default: + return FALSE; + } + } + + class ConsoleControlGuard final + { + public: + ConsoleControlGuard() + { +#ifdef VAJRA_TEST_FAULT_INJECTION + static std::atomic_bool handler_failure_injected{false}; + wchar_t test_fault[64]{}; + const DWORD test_fault_length = GetEnvironmentVariableW( + L"VAJRA_WINDOWS_TEST_FAULT", + test_fault, + static_cast(sizeof(test_fault) / sizeof(test_fault[0]))); + if (test_fault_length > 0 && test_fault_length < sizeof(test_fault) / sizeof(test_fault[0]) && + std::wstring(test_fault, test_fault_length) == L"console_handler_failure" && + !handler_failure_injected.exchange(true, std::memory_order_acq_rel)) + { + throw std::runtime_error("injected Windows console control handler failure"); + } +#endif + if (SetConsoleCtrlHandler(handle_console_control, TRUE) == 0) + { + throw std::runtime_error("failed to install Windows console control handler"); + } + } + + ~ConsoleControlGuard() + { + SetConsoleCtrlHandler(handle_console_control, FALSE); + } + }; + + void *run_server_without_gvl(void *data) + { + auto *server = static_cast(data); + server->start(); + return nullptr; + } + + void stop_server_without_gvl(void *data) + { + auto *server = static_cast(data); + server->stop(); + } + + void *run_windows_supervisor_without_gvl(void *data) + { + auto *supervisor = static_cast(data); + supervisor->run(); + return nullptr; + } + + void stop_windows_supervisor_without_gvl(void *data) + { + auto *supervisor = static_cast(data); + supervisor->request_stop(); + } + + bool start_called_from_ruby_main_thread() + { + return rb_equal(rb_thread_current(), rb_thread_main()) == Qtrue; + } +} + +Vajra::runtime::NativeRuntime &Vajra::runtime::NativeRuntime::instance() +{ + static NativeRuntime runtime; + return runtime; +} + +bool Vajra::runtime::NativeRuntime::shutdown_requested() +{ + return shutting_down.load(std::memory_order_acquire); +} + +bool Vajra::runtime::NativeRuntime::runtime_running() const +{ + std::lock_guard lock(server_mutex_); + return server_instance_ != nullptr || windows_supervisor_ != nullptr || worker_startup_in_progress_; +} + +bool Vajra::runtime::NativeRuntime::try_begin_startup() +{ + std::lock_guard lock(server_mutex_); + if (server_instance_ != nullptr || windows_supervisor_ != nullptr || worker_startup_in_progress_) + { + return false; + } + worker_startup_in_progress_ = true; + stop_requested_ = false; + runtime_shutdown_started_ = false; + return true; +} + +void Vajra::runtime::NativeRuntime::install_server_instance(std::shared_ptr server) +{ + std::lock_guard lock(server_mutex_); + server_instance_ = std::move(server); + worker_startup_in_progress_ = false; +} + +std::shared_ptr Vajra::runtime::NativeRuntime::take_server_instance() +{ + std::lock_guard lock(server_mutex_); + return std::exchange(server_instance_, nullptr); +} + +void Vajra::runtime::NativeRuntime::begin_runtime_shutdown() +{ + bool shutdown_started = false; + { + std::lock_guard lock(server_mutex_); + if (!runtime_shutdown_started_) + { + runtime_shutdown_started_ = true; + shutdown_started = true; + } + stop_requested_ = true; + } + shutting_down.store(true, std::memory_order_release); + mark_runtime_shutdown_requested(); + mark_worker_lifecycle(0, WorkerLifecycleState::stopping); + mark_worker_available(0, false); + if (shutdown_started) + { + log_runtime_shutdown_begin(); + } +} + +void Vajra::runtime::NativeRuntime::forward_shutdown_to_workers() +{ + stop(); +} + +void Vajra::runtime::NativeRuntime::stop() +{ + begin_runtime_shutdown(); + std::shared_ptr server; + std::shared_ptr supervisor; + { + std::lock_guard lock(server_mutex_); + server = server_instance_; + supervisor = windows_supervisor_; + } + if (server != nullptr) + { + server->stop(); + } + if (supervisor != nullptr) + { + supervisor->request_stop(); + } +} + +std::vector> Vajra::runtime::NativeRuntime::worker_states() const +{ + std::lock_guard lock(server_mutex_); + if (windows_supervisor_ != nullptr) + { + return windows_supervisor_->worker_states(); + } + return worker_states_; +} + +void Vajra::runtime::NativeRuntime::start(const RuntimeConfig &config) +{ + if (windows_worker_bootstrap_present()) + { + run_windows_worker_process(config); + return; + } + if (!try_begin_startup()) + { + std::cout << "Vajra already running" << std::endl; + return; + } + + shutting_down.store(false, std::memory_order_release); + try + { + ConsoleControlGuard console_control_guard; + if (!start_called_from_ruby_main_thread()) + { + throw std::runtime_error("worker-only Vajra.start must be invoked from the Ruby main thread"); + } + configure_runtime_logging( + config.structured_logs, + config.access_log, + config.error_log, + config.access_log_format); + configure_runtime_tracing( + config.trace_enabled, + config.trace_endpoint, + config.trace_service_name, + config.trace_enabled && !config.trace_otel_owner, + config.trace_resource_attributes, + config.trace_propagators); + const BootContractResult boot_result = BootContract::run( + BootContractConfig{config.port, config.max_request_head_bytes, "ruby_master_preload"}); + BootContract::ensure_ready(boot_result); + + if (config.workers > 0) + { + if (runtime_state_ != nullptr) + { + release_runtime_state(runtime_state_); + } + const std::wstring mapping_name = L"Local\\vajra-runtime-" + + std::to_wstring(platform::current_process_id()) + L"-" + + std::to_wstring(GetTickCount64()); + runtime_state_ = allocate_named_runtime_state(mapping_name); + auto supervisor = std::make_shared(config, runtime_state_); + { + std::lock_guard lock(server_mutex_); + windows_supervisor_ = supervisor; + worker_startup_in_progress_ = false; + } + start_runtime_logging_worker(); + start_runtime_tracing_worker(); + supervisor->start(); + rb_thread_call_without_gvl( + run_windows_supervisor_without_gvl, + supervisor.get(), + stop_windows_supervisor_without_gvl, + supervisor.get()); + const auto final_worker_states = supervisor->worker_states(); + const bool terminal_worker_failure = std::any_of( + final_worker_states.begin(), + final_worker_states.end(), + [](const std::shared_ptr &state) + { + return state && state->terminal_replacement_failure.load(std::memory_order_acquire); + }); + { + std::lock_guard lock(server_mutex_); + windows_supervisor_.reset(); + } + log_runtime_shutdown_complete(); + stop_runtime_tracing_worker(); + stop_runtime_logging_worker(); + release_runtime_state(runtime_state_); + runtime_state_ = nullptr; + if (terminal_worker_failure) + { + throw std::runtime_error("Windows worker replacement attempts exhausted"); + } + return; + } + + if (runtime_state_ != nullptr) + { + release_runtime_state(runtime_state_); + } + runtime_state_ = allocate_runtime_state(); + install_master_runtime_state(runtime_state_, 1, config.max_threads, config.socket_queue_capacity); + install_worker_runtime_state(runtime_state_, 0, platform::current_process_id()); + + start_runtime_logging_worker(); + start_runtime_tracing_worker(); + Vajra::rack::ensure_same_process_rack_execution_threads_started(); + mark_worker_lifecycle(0, WorkerLifecycleState::ready); + mark_worker_health(0, WorkerHealthState::healthy); + mark_worker_available(0, true); + if (debug_logging_enabled(config.log_level)) + { + log_worker_lifecycle_event( + "worker_ready", + 0, + platform::current_process_id(), + WorkerLifecycleState::ready, + WorkerHealthState::healthy, + WorkerRecoveryState::none, + true, + WorkerExitClassification::none, + false, + false, + 0); + } + auto rack_executor = std::make_shared( + std::shared_ptr{}, + Vajra::rack::ControlPlaneConfig{config.stats_path, config.metrics_endpoint}); + auto tls_context = config.tls + ? std::make_shared(Vajra::transport::TlsConfig{ + config.tls_certificate, + config.tls_private_key, + config.tls_ca_certificate, + config.tls_verify_mode, + config.tls_min_version, + config.alpn_protocols, + config.request_head_timeout_seconds, + config.first_data_timeout_seconds, + static_cast(config.request_timeout_seconds)}) + : nullptr; + const Vajra::request::Http2Config http2_config{ + config.http2_max_concurrent_streams, + config.http2_initial_window_size, + config.http2_max_frame_size, + config.http2_header_table_size, + config.max_request_head_bytes, + config.max_request_body_bytes, + config.max_keepalive_requests, + config.socket_queue_capacity}; + auto server = std::make_shared( + config.port, + config.host, + config.max_request_head_bytes, + rack_executor, + "windows_runtime", + "single_process", + config.workers, + "same_process_rack_execution", + debug_logging_enabled(config.log_level), + -1, + config.request_head_timeout_seconds, + config.first_data_timeout_seconds, + config.request_body_timeout_seconds, + config.persistent_timeout_seconds, + config.max_connections, + [this]() + { begin_runtime_shutdown(); }, + config.max_request_body_bytes, + config.max_keepalive_requests, + config.max_threads, + config.http2, + http2_config, + std::move(tls_context), + [host = config.host, + workers = config.workers, + min_threads = config.min_threads, + max_threads = config.max_threads](int bound_port) + { + log_runtime_banner_start(host, bound_port, workers, min_threads, max_threads); + flush_runtime_logs(); + }); + install_server_instance(server); + rb_thread_call_without_gvl( + run_server_without_gvl, + server.get(), + stop_server_without_gvl, + server.get()); + take_server_instance(); + mark_worker_available(0, false); + mark_worker_lifecycle(0, WorkerLifecycleState::exited); + log_runtime_shutdown_complete(); + stop_runtime_tracing_worker(); + stop_runtime_logging_worker(); + release_runtime_state(runtime_state_); + runtime_state_ = nullptr; + } + catch (...) + { + { + std::lock_guard lock(server_mutex_); + server_instance_.reset(); + windows_supervisor_.reset(); + worker_startup_in_progress_ = false; + } + if (runtime_state_ != nullptr) + { + release_runtime_state(runtime_state_); + runtime_state_ = nullptr; + } + stop_runtime_tracing_worker(); + stop_runtime_logging_worker(); + throw; + } +} + +bool VajraNative::shutdown_requested() +{ + return Vajra::runtime::NativeRuntime::shutdown_requested(); +} + +void VajraNative::begin_runtime_shutdown() +{ + Vajra::runtime::NativeRuntime::instance().begin_runtime_shutdown(); +} + +void VajraNative::start( + std::string host, + int port, + int workers, + std::size_t min_threads, + std::size_t max_threads, + std::size_t max_connections, + std::size_t socket_queue_capacity, + std::size_t max_request_head_bytes, + std::size_t max_request_body_bytes, + std::size_t max_keepalive_requests, + std::size_t request_timeout_seconds, + int request_head_timeout_seconds, + int first_data_timeout_seconds, + int request_body_timeout_seconds, + int persistent_timeout_seconds, + int worker_timeout_seconds, + bool tls, + std::string tls_certificate, + std::string tls_private_key, + std::string tls_ca_certificate, + std::string tls_verify_mode, + std::string tls_min_version, + std::vector alpn_protocols, + bool http2, + std::size_t http2_max_concurrent_streams, + std::size_t http2_initial_window_size, + std::size_t http2_max_frame_size, + std::size_t http2_header_table_size, + std::string log_level, + std::string access_log, + std::string error_log, + bool structured_logs, + std::string access_log_format, + std::string stats_path, + std::string metrics_endpoint, + bool trace_enabled, + std::string trace_endpoint, + std::string trace_service_name, + bool trace_otel_owner, + std::string trace_resource_attributes, + std::string trace_propagators) +{ + Vajra::runtime::NativeRuntime::instance().start(Vajra::runtime::RuntimeConfig{ + std::move(host), + port, + workers, + min_threads, + max_threads, + max_connections, + socket_queue_capacity, + max_request_head_bytes, + max_request_body_bytes, + max_keepalive_requests, + request_timeout_seconds, + request_head_timeout_seconds, + first_data_timeout_seconds, + request_body_timeout_seconds, + persistent_timeout_seconds, + worker_timeout_seconds, + tls, + std::move(tls_certificate), + std::move(tls_private_key), + std::move(tls_ca_certificate), + std::move(tls_verify_mode), + std::move(tls_min_version), + std::move(alpn_protocols), + http2, + http2_max_concurrent_streams, + http2_initial_window_size, + http2_max_frame_size, + http2_header_table_size, + std::move(log_level), + std::move(access_log), + std::move(error_log), + structured_logs, + std::move(access_log_format), + std::move(stats_path), + std::move(metrics_endpoint), + trace_enabled, + std::move(trace_endpoint), + std::move(trace_service_name), + trace_otel_owner, + std::move(trace_resource_attributes), + std::move(trace_propagators)}); +} + +void VajraNative::stop() +{ + Vajra::runtime::NativeRuntime::instance().stop(); +} + +#endif diff --git a/gems/vajra/ext/vajra/runtime/runtime_logging.cpp b/gems/vajra/ext/vajra/runtime/runtime_logging.cpp index 634c1db..a317ea6 100644 --- a/gems/vajra/ext/vajra/runtime/runtime_logging.cpp +++ b/gems/vajra/ext/vajra/runtime/runtime_logging.cpp @@ -5,6 +5,10 @@ #include "runtime/runtime_logging.hpp" #include "runtime/traceparent.hpp" +#include "platform/socket.hpp" +#include "transport/tls_connection.hpp" + +#include #if __has_include("ruby.h") #include "ruby.h" @@ -26,23 +30,32 @@ #include #include #include +#ifdef _WIN32 +#include +#include +#include +#else #include +#endif #include #include #include #include #include -#include #include +#ifndef _WIN32 +#include #include +#endif #include #include #include -#include #include #include #include +#ifndef _WIN32 #include +#endif std::string Vajra::runtime::runtime_environment_name() { @@ -107,7 +120,11 @@ namespace { if (owned_ && fd_ >= 0) { +#ifdef _WIN32 + _close(fd_); +#else close(fd_); +#endif } } @@ -177,7 +194,7 @@ namespace std::atomic_bool running{false}; std::atomic_bool stopping{false}; std::size_t in_flight = 0; - std::atomic owner_pid{-1}; + std::atomic owner_pid{Vajra::platform::kInvalidProcessId}; std::atomic pending{0}; LogNode *head = nullptr; std::atomic tail{nullptr}; @@ -238,7 +255,7 @@ namespace std::thread worker; std::atomic_bool running{false}; std::atomic_bool stopping{false}; - std::atomic owner_pid{-1}; + std::atomic owner_pid{Vajra::platform::kInvalidProcessId}; NativeOtlpEndpoint endpoint; std::string service_name; std::string resource_attributes; @@ -360,7 +377,7 @@ namespace VALUE callback = Qnil; std::string event_name; std::size_t worker_index = 0; - pid_t pid = 0; + Vajra::platform::ProcessId pid = Vajra::platform::kInvalidProcessId; std::string lifecycle_state; std::string health_state; std::string recovery_state; @@ -651,7 +668,11 @@ namespace std::size_t written = 0; while (written < length) { +#ifdef _WIN32 + const int result = _write(fd, data + written, static_cast(length - written)); +#else const ssize_t result = ::write(fd, data + written, length - written); +#endif if (result < 0) { if (errno == EINTR) @@ -669,7 +690,7 @@ namespace return true; } - void prepare_socket_write(int fd) + void prepare_socket_write(Vajra::platform::SocketHandle fd) { #ifdef SO_NOSIGPIPE int opt = 1; @@ -679,34 +700,6 @@ namespace #endif } - bool send_all(int fd, const char *data, std::size_t length) - { -#ifdef MSG_NOSIGNAL - constexpr int send_flags = MSG_NOSIGNAL; -#else - constexpr int send_flags = 0; -#endif - std::size_t sent_total = 0; - while (sent_total < length) - { - const ssize_t result = ::send(fd, data + sent_total, length - sent_total, send_flags); - if (result < 0) - { - if (errno == EINTR) - { - continue; - } - return false; - } - if (result == 0) - { - return false; - } - sent_total += static_cast(result); - } - return true; - } - void write_line_fd(int fd, const std::string &line) { std::string payload; @@ -724,7 +717,11 @@ namespace int open_log_fd(const std::string &path) { +#ifdef _WIN32 + return _open(path.c_str(), _O_WRONLY | _O_CREAT | _O_APPEND | _O_BINARY, _S_IREAD | _S_IWRITE); +#else return open(path.c_str(), O_WRONLY | O_CREAT | O_APPEND | O_CLOEXEC, 0644); +#endif } const std::string &cached_utc_timestamp() @@ -736,7 +733,11 @@ namespace if (now_time != cached_time || cached_timestamp.empty()) { std::tm utc_time{}; +#ifdef _WIN32 + gmtime_s(&utc_time, &now_time); +#else gmtime_r(&now_time, &utc_time); +#endif std::ostringstream timestamp; timestamp << std::put_time(&utc_time, "%Y-%m-%dT%H:%M:%SZ"); cached_time = now_time; @@ -826,18 +827,21 @@ namespace access_need_trace_context.store(needs.trace_context, std::memory_order_release); } - void signal_reopen_handler(int) - { - reopen_requested.store(true, std::memory_order_release); - } - void install_reopen_signal_handler() { +#ifdef _WIN32 + return; +#else + static const auto signal_reopen_handler = [](int) + { + reopen_requested.store(true, std::memory_order_release); + }; struct sigaction action{}; action.sa_handler = signal_reopen_handler; sigemptyset(&action.sa_mask); action.sa_flags = SA_RESTART; (void)sigaction(SIGUSR1, &action, nullptr); +#endif } bool reopen_configured_logs_locked(std::string &warning_message) @@ -1090,7 +1094,7 @@ namespace bool async_logger_owned_by_current_process() { return async_logger.running.load(std::memory_order_acquire) && - async_logger.owner_pid.load(std::memory_order_acquire) == getpid(); + async_logger.owner_pid.load(std::memory_order_acquire) == Vajra::platform::current_process_id(); } void release_log_node(LogNode *node) @@ -1356,7 +1360,7 @@ namespace void emit_runtime_lifecycle_callback( const char *event_name, std::size_t worker_index, - pid_t pid, + Vajra::platform::ProcessId pid, Vajra::runtime::WorkerLifecycleState lifecycle_state, Vajra::runtime::WorkerHealthState health_state, Vajra::runtime::WorkerRecoveryState recovery_state, @@ -1436,12 +1440,12 @@ namespace std::optional parse_native_otlp_endpoint(const std::string &endpoint) { - constexpr const char *prefix = "http://"; - if (endpoint.rfind(prefix, 0) != 0) + const std::size_t scheme_separator = endpoint.find("://"); + if (scheme_separator == std::string::npos || endpoint.substr(0, scheme_separator) != "https") { return std::nullopt; } - const std::string rest = endpoint.substr(std::char_traits::length(prefix)); + const std::string rest = endpoint.substr(scheme_separator + 3); const std::size_t slash = rest.find('/'); const std::string authority = slash == std::string::npos ? rest : rest.substr(0, slash); if (authority.empty()) @@ -1460,7 +1464,7 @@ namespace parsed.host = authority.substr(1, bracket - 1); if (bracket + 1 == authority.size()) { - parsed.port = "80"; + parsed.port = "443"; } else if (authority[bracket + 1] == ':' && bracket + 2 < authority.size()) { @@ -1480,7 +1484,7 @@ namespace return std::nullopt; } parsed.host = first_colon == std::string::npos ? authority : authority.substr(0, first_colon); - parsed.port = first_colon == std::string::npos ? "80" : authority.substr(first_colon + 1); + parsed.port = first_colon == std::string::npos ? "443" : authority.substr(first_colon + 1); } parsed.path = slash == std::string::npos ? "/" : rest.substr(slash); if (parsed.host.empty() || parsed.port.empty()) @@ -1783,26 +1787,84 @@ namespace return false; } const std::unique_ptr addresses(result, freeaddrinfo); - int fd = -1; + Vajra::platform::ensure_socket_runtime(); + Vajra::platform::SocketHandle fd = Vajra::platform::kInvalidSocket; for (addrinfo *cursor = addresses.get(); cursor != nullptr; cursor = cursor->ai_next) { - fd = socket(cursor->ai_family, cursor->ai_socktype, cursor->ai_protocol); - if (fd < 0) + fd = Vajra::platform::create_tcp_socket(cursor->ai_family, cursor->ai_socktype, cursor->ai_protocol); + if (!Vajra::platform::socket_valid(fd)) { continue; } - if (connect(fd, cursor->ai_addr, cursor->ai_addrlen) == 0) + if (Vajra::platform::connect_socket( + fd, + cursor->ai_addr, + static_cast(cursor->ai_addrlen))) { break; } - close(fd); - fd = -1; + Vajra::platform::close_socket(fd); + fd = Vajra::platform::kInvalidSocket; } - if (fd < 0) + if (!Vajra::platform::socket_valid(fd)) { return false; } prepare_socket_write(fd); + const std::unique_ptr context(SSL_CTX_new(TLS_client_method()), SSL_CTX_free); + if (context == nullptr || + SSL_CTX_set_min_proto_version(context.get(), TLS1_2_VERSION) != 1 || + SSL_CTX_set_default_verify_paths(context.get()) != 1) + { + Vajra::platform::close_socket(fd); + return false; + } + SSL_CTX_set_verify(context.get(), SSL_VERIFY_PEER, nullptr); + + const std::unique_ptr ssl(SSL_new(context.get()), SSL_free); + if (ssl == nullptr) + { + Vajra::platform::close_socket(fd); + return false; + } + in_addr ipv4{}; + in6_addr ipv6{}; + const bool numeric_host = inet_pton(AF_INET, endpoint.host.c_str(), &ipv4) == 1 || + inet_pton(AF_INET6, endpoint.host.c_str(), &ipv6) == 1; + X509_VERIFY_PARAM *verification = SSL_get0_param(ssl.get()); + const bool identity_configured = numeric_host + ? X509_VERIFY_PARAM_set1_ip_asc(verification, endpoint.host.c_str()) == 1 + : SSL_set1_host(ssl.get(), endpoint.host.c_str()) == 1 && + SSL_set_tlsext_host_name(ssl.get(), endpoint.host.c_str()) == 1; + if (!identity_configured) + { + Vajra::platform::close_socket(fd); + return false; + } +#ifdef _WIN32 + try + { + BIO *bio = Vajra::transport::new_socket_bio(fd); + SSL_set_bio(ssl.get(), bio, bio); + } + catch (const std::exception &) + { + Vajra::platform::close_socket(fd); + return false; + } +#else + if (SSL_set_fd(ssl.get(), Vajra::platform::openssl_socket_descriptor(fd)) != 1) + { + Vajra::platform::close_socket(fd); + return false; + } +#endif + if (SSL_connect(ssl.get()) != 1 || SSL_get_verify_result(ssl.get()) != X509_V_OK) + { + Vajra::platform::close_socket(fd); + return false; + } + std::ostringstream request; request << "POST " << endpoint.path << " HTTP/1.1\r\n" << "Host: " << endpoint.host_header << "\r\n" @@ -1810,8 +1872,28 @@ namespace << "Content-Length: " << body.size() << "\r\n" << "Connection: close\r\n\r\n"; const std::string head = request.str(); - const bool ok = send_all(fd, head.data(), head.size()) && send_all(fd, body.data(), body.size()); - close(fd); + const auto send_tls = [&ssl](const std::string &payload) + { + std::size_t written_total = 0; + while (written_total < payload.size()) + { + std::size_t written = 0; + if (SSL_write_ex( + ssl.get(), + payload.data() + written_total, + payload.size() - written_total, + &written) != 1 || + written == 0) + { + return false; + } + written_total += written; + } + return true; + }; + const bool ok = send_tls(head) && send_tls(body); + (void)SSL_shutdown(ssl.get()); + Vajra::platform::close_socket(fd); return ok; } @@ -1913,12 +1995,12 @@ namespace } std::lock_guard lock(native_otlp_exporter.mutex); if (native_otlp_exporter.running.load(std::memory_order_acquire) && - native_otlp_exporter.owner_pid.load(std::memory_order_acquire) == getpid()) + native_otlp_exporter.owner_pid.load(std::memory_order_acquire) == Vajra::platform::current_process_id()) { return; } if (native_otlp_exporter.running.load(std::memory_order_acquire) && - native_otlp_exporter.owner_pid.load(std::memory_order_acquire) != getpid()) + native_otlp_exporter.owner_pid.load(std::memory_order_acquire) != Vajra::platform::current_process_id()) { if (native_otlp_exporter.worker.joinable()) { @@ -1927,7 +2009,7 @@ namespace native_otlp_export_enabled.store(false, std::memory_order_release); native_otlp_exporter.stopping.store(false, std::memory_order_release); native_otlp_exporter.running.store(false, std::memory_order_release); - native_otlp_exporter.owner_pid.store(-1, std::memory_order_release); + native_otlp_exporter.owner_pid.store(Vajra::platform::kInvalidProcessId, std::memory_order_release); { const std::lock_guard queue_lock(request_observability_mutex); clear_native_otlp_span_events_locked(); @@ -1937,7 +2019,7 @@ namespace native_otlp_exporter.service_name = service_name.empty() ? "vajra" : service_name; native_otlp_exporter.resource_attributes = resource_attributes; native_otlp_exporter.stopping.store(false, std::memory_order_release); - native_otlp_exporter.owner_pid.store(getpid(), std::memory_order_release); + native_otlp_exporter.owner_pid.store(Vajra::platform::current_process_id(), std::memory_order_release); native_otlp_exporter.running.store(true, std::memory_order_release); native_otlp_export_enabled.store(true, std::memory_order_release); native_otlp_exporter.worker = std::thread(native_otlp_export_loop); @@ -1950,13 +2032,13 @@ namespace std::lock_guard lock(native_otlp_exporter.mutex); native_otlp_export_enabled.store(false, std::memory_order_release); native_otlp_exporter.stopping.store(true, std::memory_order_release); - if (native_otlp_exporter.owner_pid.load(std::memory_order_acquire) == getpid() && + if (native_otlp_exporter.owner_pid.load(std::memory_order_acquire) == Vajra::platform::current_process_id() && native_otlp_exporter.worker.joinable()) { worker = std::move(native_otlp_exporter.worker); } native_otlp_exporter.running.store(false, std::memory_order_release); - native_otlp_exporter.owner_pid.store(-1, std::memory_order_release); + native_otlp_exporter.owner_pid.store(Vajra::platform::kInvalidProcessId, std::memory_order_release); } request_span_condition.notify_all(); if (worker.joinable()) @@ -2066,7 +2148,7 @@ namespace void enqueue_runtime_lifecycle_span_event( const char *event_name, std::size_t worker_index, - pid_t pid, + Vajra::platform::ProcessId pid, Vajra::runtime::WorkerLifecycleState lifecycle_state, Vajra::runtime::WorkerHealthState health_state, Vajra::runtime::WorkerRecoveryState recovery_state, @@ -2305,12 +2387,12 @@ void Vajra::runtime::start_runtime_logging_worker() std::lock_guard lock(async_logger.mutex); if (async_logger.running.load(std::memory_order_acquire) && - async_logger.owner_pid.load(std::memory_order_acquire) == getpid()) + async_logger.owner_pid.load(std::memory_order_acquire) == Vajra::platform::current_process_id()) { return; } if (async_logger.running.load(std::memory_order_acquire) && - async_logger.owner_pid.load(std::memory_order_acquire) != getpid()) + async_logger.owner_pid.load(std::memory_order_acquire) != Vajra::platform::current_process_id()) { async_logger.running.store(false, std::memory_order_release); async_logger.stopping.store(false, std::memory_order_release); @@ -2327,7 +2409,7 @@ void Vajra::runtime::start_runtime_logging_worker() if (stub == nullptr) { async_logger.running.store(false, std::memory_order_release); - async_logger.owner_pid.store(-1, std::memory_order_release); + async_logger.owner_pid.store(Vajra::platform::kInvalidProcessId, std::memory_order_release); return; } async_logger.head = stub; @@ -2339,7 +2421,7 @@ void Vajra::runtime::start_runtime_logging_worker() async_logger.access_log_fd = config_snapshot.access_log_fd; async_logger.error_log_fd = config_snapshot.error_log_fd; async_logger.runtime_log_fd = config_snapshot.error_log_path.empty() ? STDOUT_FILENO : config_snapshot.error_log_fd; - async_logger.owner_pid.store(getpid(), std::memory_order_release); + async_logger.owner_pid.store(Vajra::platform::current_process_id(), std::memory_order_release); async_logger.stopping.store(false, std::memory_order_release); async_logger.running.store(true, std::memory_order_release); async_logger.worker = std::thread(async_logger_loop); @@ -2375,7 +2457,7 @@ void Vajra::runtime::stop_runtime_logging_worker() std::lock_guard pool_lock(async_logger.pool_mutex); reset_log_node_pool_locked(); } - async_logger.owner_pid.store(-1, std::memory_order_release); + async_logger.owner_pid.store(Vajra::platform::kInvalidProcessId, std::memory_order_release); } } @@ -2568,7 +2650,7 @@ void Vajra::runtime::log_runtime_banner_start( std::size_t min_threads, std::size_t max_threads) { - const pid_t pid = getpid(); + const Vajra::platform::ProcessId pid = Vajra::platform::current_process_id(); std::ostringstream line; line << "[" << pid << "] === vajra boot: " << utc_timestamp() << " ==="; write_runtime_line(line.str()); @@ -2585,7 +2667,7 @@ void Vajra::runtime::log_runtime_banner_start( void Vajra::runtime::log_worker_lifecycle_event( const char *event_name, std::size_t worker_index, - pid_t pid, + Vajra::platform::ProcessId pid, WorkerLifecycleState lifecycle_state, WorkerHealthState health_state, WorkerRecoveryState recovery_state, @@ -2733,10 +2815,14 @@ void Vajra::runtime::log_worker_bootstrap_ready( flush_runtime_streams(); } -void Vajra::runtime::log_worker_booted(int worker_index, pid_t pid, double boot_seconds) +void Vajra::runtime::log_worker_booted( + int worker_index, + Vajra::platform::ProcessId pid, + Vajra::platform::ProcessId master_pid, + double boot_seconds) { std::ostringstream message; - message << "[" << getppid() << "] - Worker " << worker_index + message << "[" << platform::process_id_value(master_pid) << "] - Worker " << worker_index << " (PID: " << pid << ") booted in " << std::fixed << std::setprecision(2) << boot_seconds << "s"; write_runtime_line(message.str()); @@ -2841,7 +2927,7 @@ void Vajra::runtime::emit_runtime_request_span_event(const RequestSpanEvent &eve void Vajra::runtime::log_runtime_shutdown_begin() { - write_runtime_line("[" + std::to_string(getpid()) + "] - Gracefully shutting down workers..."); + write_runtime_line("[" + std::to_string(Vajra::platform::current_process_id()) + "] - Gracefully shutting down workers..."); flush_runtime_streams(); } @@ -2853,7 +2939,7 @@ void Vajra::runtime::log_runtime_stop_completed() void Vajra::runtime::log_runtime_shutdown_complete() { - write_runtime_line("[" + std::to_string(getpid()) + "] === vajra shutdown: " + utc_timestamp() + " ==="); - write_runtime_line("[" + std::to_string(getpid()) + "] - Goodbye!"); + write_runtime_line("[" + std::to_string(Vajra::platform::current_process_id()) + "] === vajra shutdown: " + utc_timestamp() + " ==="); + write_runtime_line("[" + std::to_string(Vajra::platform::current_process_id()) + "] - Goodbye!"); flush_runtime_streams(); } diff --git a/gems/vajra/ext/vajra/runtime/runtime_logging.hpp b/gems/vajra/ext/vajra/runtime/runtime_logging.hpp index 04d6388..da8232a 100644 --- a/gems/vajra/ext/vajra/runtime/runtime_logging.hpp +++ b/gems/vajra/ext/vajra/runtime/runtime_logging.hpp @@ -8,11 +8,11 @@ #include "runtime/time_utils.hpp" #include "runtime/worker_pool.hpp" +#include "platform/process.hpp" #include #include #include -#include #include namespace Vajra @@ -76,7 +76,7 @@ namespace Vajra void log_worker_lifecycle_event( const char *event_name, std::size_t worker_index, - pid_t pid, + platform::ProcessId pid, WorkerLifecycleState lifecycle_state, WorkerHealthState health_state, WorkerRecoveryState recovery_state, @@ -90,7 +90,11 @@ namespace Vajra int port, const std::string &runtime_role, int worker_processes); - void log_worker_booted(int worker_index, pid_t pid, double boot_seconds); + void log_worker_booted( + int worker_index, + platform::ProcessId pid, + platform::ProcessId master_pid, + double boot_seconds); void log_runtime_error(const std::string &message); struct AccessLogEvent { @@ -105,7 +109,7 @@ namespace Vajra std::string user_agent; std::string referer; std::string request_id; - pid_t worker_pid = -1; + platform::ProcessId worker_pid = platform::kInvalidProcessId; int worker_index = -1; std::string connection_outcome; std::string trace_id; @@ -136,7 +140,7 @@ namespace Vajra bool response_sent = false; std::string connection_outcome; int worker_index = -1; - pid_t worker_pid = -1; + platform::ProcessId worker_pid = platform::kInvalidProcessId; std::string trace_id; std::string span_id; std::string error_message; diff --git a/gems/vajra/ext/vajra/runtime/runtime_state.cpp b/gems/vajra/ext/vajra/runtime/runtime_state.cpp index 807a0b8..e200da6 100644 --- a/gems/vajra/ext/vajra/runtime/runtime_state.cpp +++ b/gems/vajra/ext/vajra/runtime/runtime_state.cpp @@ -14,9 +14,15 @@ #include #include #include +#ifdef _WIN32 +#include +#include +#include +#else #include -#include #include +#endif +#include namespace { @@ -31,6 +37,15 @@ namespace Vajra::runtime::RuntimeState *installed_runtime_state = nullptr; thread_local Vajra::runtime::WorkerRuntimeState *installed_worker_state = nullptr; thread_local std::size_t installed_worker_index = 0; +#ifdef _WIN32 + std::mutex runtime_mapping_mutex; + struct RuntimeMapping + { + HANDLE handle = nullptr; + bool owns_state = false; + }; + std::unordered_map runtime_mapping_handles; +#endif std::int64_t steady_clock_nanoseconds() { @@ -76,7 +91,7 @@ namespace } #if defined(__linux__) - std::int64_t rss_bytes_for_pid_from_proc(pid_t pid) + std::int64_t rss_bytes_for_pid_from_proc(Vajra::platform::ProcessId pid) { std::ifstream statm("/proc/" + std::to_string(pid) + "/statm"); long long total_pages = 0; @@ -97,7 +112,29 @@ namespace } #endif - std::int64_t rss_bytes_for_pid_from_ps(pid_t pid) +#ifdef _WIN32 + std::int64_t rss_bytes_for_pid_from_windows(Vajra::platform::ProcessId pid) + { + if (pid <= 0) + { + return -1; + } + const HANDLE process = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION | PROCESS_VM_READ, FALSE, static_cast(pid)); + if (process == nullptr) + { + return -1; + } + PROCESS_MEMORY_COUNTERS_EX counters{}; + counters.cb = sizeof(counters); + const BOOL sampled = GetProcessMemoryInfo( + process, + reinterpret_cast(&counters), + sizeof(counters)); + CloseHandle(process); + return sampled == 0 ? -1 : static_cast(counters.WorkingSetSize); + } +#else + std::int64_t rss_bytes_for_pid_from_ps(Vajra::platform::ProcessId pid) { if (pid <= 0) { @@ -129,11 +166,12 @@ namespace return static_cast(rss_kilobytes) * 1024; } +#endif - std::int64_t cached_rss_bytes_for_pid_from_ps(pid_t pid) + std::int64_t cached_rss_bytes_for_pid_from_ps(Vajra::platform::ProcessId pid) { static std::mutex cache_mutex; - static std::unordered_map cache; + static std::unordered_map cache; const auto now = std::chrono::steady_clock::now(); { @@ -145,7 +183,11 @@ namespace } } +#ifdef _WIN32 + const std::int64_t bytes = rss_bytes_for_pid_from_windows(pid); +#else const std::int64_t bytes = rss_bytes_for_pid_from_ps(pid); +#endif { std::lock_guard lock(cache_mutex); cache[pid] = RssSample{bytes, now}; @@ -154,7 +196,7 @@ namespace return bytes; } - std::int64_t rss_bytes_for_pid(pid_t pid) + std::int64_t rss_bytes_for_pid(Vajra::platform::ProcessId pid) { if (pid <= 0) { @@ -296,6 +338,9 @@ namespace Vajra::runtime::RuntimeState *Vajra::runtime::allocate_runtime_state() { +#ifdef _WIN32 + return allocate_named_runtime_state(L""); +#else void *region = mmap(nullptr, sizeof(RuntimeState), PROT_READ | PROT_WRITE, MAP_SHARED | MAP_ANON, -1, 0); if (region == MAP_FAILED) { @@ -303,7 +348,66 @@ Vajra::runtime::RuntimeState *Vajra::runtime::allocate_runtime_state() } return new (region) RuntimeState(); +#endif +} + +#ifdef _WIN32 +Vajra::runtime::RuntimeState *Vajra::runtime::allocate_named_runtime_state(const std::wstring &mapping_name) +{ + SECURITY_ATTRIBUTES attributes{}; + attributes.nLength = sizeof(attributes); + attributes.bInheritHandle = TRUE; + const HANDLE mapping = CreateFileMappingW( + INVALID_HANDLE_VALUE, + &attributes, + PAGE_READWRITE, + 0, + static_cast(sizeof(RuntimeState)), + mapping_name.empty() ? nullptr : mapping_name.c_str()); + if (mapping == nullptr) + { + throw std::runtime_error("failed to create shared Windows runtime state mapping"); + } + void *region = MapViewOfFile(mapping, FILE_MAP_ALL_ACCESS, 0, 0, sizeof(RuntimeState)); + if (region == nullptr) + { + CloseHandle(mapping); + throw std::runtime_error("failed to map shared Windows runtime state"); + } + auto *state = new (region) RuntimeState(); + { + const std::lock_guard lock(runtime_mapping_mutex); + runtime_mapping_handles.emplace(state, RuntimeMapping{mapping, true}); + } + return state; +} + +Vajra::runtime::RuntimeState *Vajra::runtime::attach_runtime_state(HANDLE mapping_handle) +{ + if (mapping_handle == nullptr || mapping_handle == INVALID_HANDLE_VALUE) + { + throw std::runtime_error("invalid inherited Windows runtime state mapping handle"); + } + void *region = MapViewOfFile(mapping_handle, FILE_MAP_ALL_ACCESS, 0, 0, sizeof(RuntimeState)); + if (region == nullptr) + { + throw std::runtime_error("failed to attach inherited Windows runtime state mapping"); + } + auto *state = static_cast(region); + { + const std::lock_guard lock(runtime_mapping_mutex); + runtime_mapping_handles.emplace(state, RuntimeMapping{mapping_handle, false}); + } + return state; +} + +HANDLE Vajra::runtime::runtime_state_mapping_handle(RuntimeState *state) +{ + const std::lock_guard lock(runtime_mapping_mutex); + const auto entry = runtime_mapping_handles.find(state); + return entry == runtime_mapping_handles.end() ? nullptr : entry->second.handle; } +#endif void Vajra::runtime::release_runtime_state(RuntimeState *state) { @@ -315,8 +419,32 @@ void Vajra::runtime::release_runtime_state(RuntimeState *state) installed_worker_state = nullptr; installed_worker_index = 0; } +#ifdef _WIN32 + HANDLE mapping = nullptr; + bool owns_state = false; + { + const std::lock_guard lock(runtime_mapping_mutex); + const auto entry = runtime_mapping_handles.find(state); + if (entry != runtime_mapping_handles.end()) + { + mapping = entry->second.handle; + owns_state = entry->second.owns_state; + runtime_mapping_handles.erase(entry); + } + } + if (owns_state) + { + state->~RuntimeState(); + } + UnmapViewOfFile(state); + if (mapping != nullptr) + { + CloseHandle(mapping); + } +#else state->~RuntimeState(); munmap(state, sizeof(RuntimeState)); +#endif } } @@ -334,14 +462,14 @@ void Vajra::runtime::install_master_runtime_state( return; } - state->master_pid.store(getpid(), std::memory_order_release); + state->master_pid.store(platform::current_process_id(), std::memory_order_release); state->worker_count.store(static_cast(worker_count), std::memory_order_release); state->threads_per_worker.store(static_cast(threads_per_worker), std::memory_order_release); state->socket_queue_capacity.store(static_cast(socket_queue_capacity), std::memory_order_release); state->shutdown_requested.store(false, std::memory_order_release); } -void Vajra::runtime::install_worker_runtime_state(RuntimeState *state, std::size_t worker_index, pid_t pid) +void Vajra::runtime::install_worker_runtime_state(RuntimeState *state, std::size_t worker_index, platform::ProcessId pid) { installed_runtime_state = state; attach_current_thread_to_worker_runtime_state(worker_index); @@ -414,6 +542,12 @@ void Vajra::runtime::mark_runtime_shutdown_requested() } } +bool Vajra::runtime::runtime_shutdown_requested() +{ + return installed_runtime_state != nullptr && + installed_runtime_state->shutdown_requested.load(std::memory_order_acquire); +} + void Vajra::runtime::mark_worker_lifecycle(std::size_t worker_index, WorkerLifecycleState lifecycle_state) { WorkerRuntimeState *state = worker_state_at(worker_index); diff --git a/gems/vajra/ext/vajra/runtime/runtime_state.hpp b/gems/vajra/ext/vajra/runtime/runtime_state.hpp index b969bcd..a4da827 100644 --- a/gems/vajra/ext/vajra/runtime/runtime_state.hpp +++ b/gems/vajra/ext/vajra/runtime/runtime_state.hpp @@ -12,7 +12,9 @@ #include #include #include -#include +#ifdef _WIN32 +#include +#endif namespace Vajra { @@ -22,7 +24,7 @@ namespace Vajra struct WorkerRuntimeState { - std::atomic pid{0}; + std::atomic pid{platform::kInvalidProcessId}; std::atomic lifecycle_state{static_cast(WorkerLifecycleState::booting)}; std::atomic health_state{static_cast(WorkerHealthState::healthy)}; std::atomic recovery_state{static_cast(WorkerRecoveryState::none)}; @@ -66,7 +68,7 @@ namespace Vajra struct RuntimeState { - std::atomic master_pid{0}; + std::atomic master_pid{platform::kInvalidProcessId}; std::atomic listener_fd{-1}; std::atomic worker_count{0}; std::atomic threads_per_worker{0}; @@ -76,6 +78,11 @@ namespace Vajra }; RuntimeState *allocate_runtime_state(); +#ifdef _WIN32 + RuntimeState *allocate_named_runtime_state(const std::wstring &mapping_name); + RuntimeState *attach_runtime_state(HANDLE mapping_handle); + HANDLE runtime_state_mapping_handle(RuntimeState *state); +#endif void release_runtime_state(RuntimeState *state); void install_master_runtime_state( @@ -83,7 +90,7 @@ namespace Vajra std::size_t worker_count, std::size_t threads_per_worker, std::size_t socket_queue_capacity); - void install_worker_runtime_state(RuntimeState *state, std::size_t worker_index, pid_t pid); + void install_worker_runtime_state(RuntimeState *state, std::size_t worker_index, platform::ProcessId pid); void attach_current_thread_to_worker_runtime_state(std::size_t worker_index); void detach_worker_runtime_state(); @@ -94,6 +101,7 @@ namespace Vajra void set_runtime_listener_fd(int listener_fd); int runtime_listener_fd(); void mark_runtime_shutdown_requested(); + bool runtime_shutdown_requested(); void mark_worker_lifecycle(std::size_t worker_index, WorkerLifecycleState lifecycle_state); void mark_worker_recovery(std::size_t worker_index, WorkerRecoveryState recovery_state); diff --git a/gems/vajra/ext/vajra/runtime/time_utils.cpp b/gems/vajra/ext/vajra/runtime/time_utils.cpp index 07ce757..19a7c32 100644 --- a/gems/vajra/ext/vajra/runtime/time_utils.cpp +++ b/gems/vajra/ext/vajra/runtime/time_utils.cpp @@ -15,7 +15,11 @@ std::string Vajra::runtime::utc_timestamp() const auto now = std::chrono::system_clock::now(); const std::time_t now_time = std::chrono::system_clock::to_time_t(now); std::tm utc_time{}; +#ifdef _WIN32 + gmtime_s(&utc_time, &now_time); +#else gmtime_r(&now_time, &utc_time); +#endif std::ostringstream timestamp; timestamp << std::put_time(&utc_time, "%Y-%m-%dT%H:%M:%SZ"); diff --git a/gems/vajra/ext/vajra/runtime/windows_worker_backend.cpp b/gems/vajra/ext/vajra/runtime/windows_worker_backend.cpp new file mode 100644 index 0000000..44faf0f --- /dev/null +++ b/gems/vajra/ext/vajra/runtime/windows_worker_backend.cpp @@ -0,0 +1,1655 @@ +// Copyright Codevedas Inc. 2025-present +// +// This source code is licensed under the MIT license found in the +// LICENSE file in the root directory of this source tree. + +#ifdef _WIN32 + +#include "runtime/windows_worker_backend.hpp" + +#include "listener/listener_socket.hpp" +#include "platform/process.hpp" +#include "platform/socket.hpp" +#include "rack/rack_request_executor.hpp" +#include "rack/ruby_rack_transport.hpp" +#include "runtime/boot_contract.hpp" +#include "runtime/native_runtime.hpp" +#include "runtime/runtime_logging.hpp" +#include "server.hpp" +#include "vajra.hpp" + +#include "ruby/thread.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace +{ + constexpr std::uint32_t kFrameMagic = 0x56574a52; + constexpr std::uint16_t kFrameVersion = 1; + constexpr std::size_t kMaximumFramePayload = 1024 * 1024; + constexpr DWORD kPipeBufferBytes = 64 * 1024; + constexpr wchar_t kWorkerPipeEnvironment[] = L"VAJRA_WINDOWS_WORKER_PIPE"; + constexpr wchar_t kRuntimeMappingEnvironment[] = L"VAJRA_WINDOWS_RUNTIME_MAPPING_HANDLE"; + constexpr wchar_t kParentShutdownEnvironment[] = L"VAJRA_WINDOWS_PARENT_SHUTDOWN_HANDLE"; +#ifdef VAJRA_TEST_FAULT_INJECTION + constexpr wchar_t kTestFaultEnvironment[] = L"VAJRA_WINDOWS_TEST_FAULT"; +#endif + + enum class FrameKind : std::uint16_t + { + bootstrap = 1, + ready = 2, + socket_dispatch = 3, + socket_ack = 4, + shutdown = 5, + stopped = 6, + failure = 7, + }; + +#pragma pack(push, 1) + struct FrameHeader + { + std::uint32_t magic = kFrameMagic; + std::uint16_t version = kFrameVersion; + FrameKind kind = FrameKind::failure; + std::uint32_t payload_length = 0; + std::uint32_t worker_index = 0; + std::uint64_t generation = 0; + std::uint64_t sequence = 0; + }; + + struct SocketDispatchPayload + { + WSAPROTOCOL_INFOW protocol_info{}; + }; + + struct SocketAckPayload + { + std::uint8_t accepted = 0; + std::int32_t error_code = 0; + }; +#pragma pack(pop) + + class Handle final + { + public: + Handle() = default; + explicit Handle(HANDLE value) : value_(value) {} + ~Handle() { reset(); } + Handle(const Handle &) = delete; + Handle &operator=(const Handle &) = delete; + Handle(Handle &&other) noexcept : value_(std::exchange(other.value_, nullptr)) {} + Handle &operator=(Handle &&other) noexcept + { + if (this != &other) + { + reset(std::exchange(other.value_, nullptr)); + } + return *this; + } + HANDLE get() const { return value_; } + HANDLE release() { return std::exchange(value_, nullptr); } + void reset(HANDLE value = nullptr) + { + if (value_ != nullptr && value_ != INVALID_HANDLE_VALUE) + { + CloseHandle(value_); + } + value_ = value; + } + explicit operator bool() const { return value_ != nullptr && value_ != INVALID_HANDLE_VALUE; } + + private: + HANDLE value_ = nullptr; + }; + + std::runtime_error windows_error(const std::string &operation, DWORD error = GetLastError()) + { + return std::runtime_error(operation + " failed with Windows error " + std::to_string(error)); + } + + std::wstring environment_value(const wchar_t *name) + { + const DWORD length = GetEnvironmentVariableW(name, nullptr, 0); + if (length == 0) + { + return L""; + } + std::wstring value(length, L'\0'); + const DWORD written = GetEnvironmentVariableW(name, value.data(), length); + if (written == 0 || written >= length) + { + return L""; + } + value.resize(written); + return value; + } + + std::uint64_t unsigned_value(const std::wstring &value, const char *name) + { + if (value.empty()) + { + throw std::runtime_error(std::string("missing Windows worker bootstrap value: ") + name); + } + wchar_t *end = nullptr; + errno = 0; + const unsigned long long parsed = std::wcstoull(value.c_str(), &end, 10); + if (errno != 0 || end == value.c_str() || *end != L'\0') + { + throw std::runtime_error(std::string("invalid Windows worker bootstrap value: ") + name); + } + return static_cast(parsed); + } + + std::wstring unique_name(const wchar_t *kind, std::size_t index, std::uint64_t generation) + { + std::wostringstream name; + name << L"Local\\vajra-" << kind << L'-' << GetCurrentProcessId() << L'-' + << GetTickCount64() << L'-' << index << L'-' << generation; + return name.str(); + } + + std::wstring pipe_path(const std::wstring &name) + { + return L"\\\\.\\pipe\\" + name; + } + + void cancel_and_drain_overlapped(HANDLE object, OVERLAPPED &overlapped) + { + if (CancelIoEx(object, &overlapped) == 0 && GetLastError() != ERROR_NOT_FOUND) + { + return; + } + DWORD ignored = 0; + (void)GetOverlappedResult(object, &overlapped, &ignored, TRUE); + } + + bool wait_overlapped(HANDLE object, OVERLAPPED &overlapped, DWORD timeout, DWORD *transferred) + { + const DWORD wait_status = WaitForSingleObject(overlapped.hEvent, timeout); + if (wait_status != WAIT_OBJECT_0) + { + cancel_and_drain_overlapped(object, overlapped); + return false; + } + return GetOverlappedResult(object, &overlapped, transferred, FALSE) != 0; + } + + bool connect_named_pipe(HANDLE pipe, HANDLE process, DWORD timeout) + { + Handle event(CreateEventW(nullptr, TRUE, FALSE, nullptr)); + if (!event) + { + return false; + } + OVERLAPPED overlapped{}; + overlapped.hEvent = event.get(); + if (ConnectNamedPipe(pipe, &overlapped) != 0) + { + return true; + } + const DWORD error = GetLastError(); + if (error == ERROR_PIPE_CONNECTED) + { + return true; + } + if (error != ERROR_IO_PENDING) + { + return false; + } + HANDLE objects[] = {event.get(), process}; + const DWORD wait_status = WaitForMultipleObjects(2, objects, FALSE, timeout); + if (wait_status != WAIT_OBJECT_0) + { + cancel_and_drain_overlapped(pipe, overlapped); + return false; + } + DWORD transferred = 0; + return GetOverlappedResult(pipe, &overlapped, &transferred, FALSE) != 0; + } + + bool write_message(HANDLE pipe, const void *data, DWORD length, DWORD timeout) + { + Handle event(CreateEventW(nullptr, TRUE, FALSE, nullptr)); + if (!event) + { + return false; + } + OVERLAPPED overlapped{}; + overlapped.hEvent = event.get(); + DWORD transferred = 0; + if (WriteFile(pipe, data, length, &transferred, &overlapped) != 0) + { + return transferred == length; + } + if (GetLastError() != ERROR_IO_PENDING || !wait_overlapped(pipe, overlapped, timeout, &transferred)) + { + return false; + } + return transferred == length; + } + + bool read_message(HANDLE pipe, void *data, DWORD length, DWORD timeout) + { + Handle event(CreateEventW(nullptr, TRUE, FALSE, nullptr)); + if (!event) + { + return false; + } + OVERLAPPED overlapped{}; + overlapped.hEvent = event.get(); + DWORD transferred = 0; + if (ReadFile(pipe, data, length, &transferred, &overlapped) != 0) + { + return transferred == length; + } + if (GetLastError() != ERROR_IO_PENDING || !wait_overlapped(pipe, overlapped, timeout, &transferred)) + { + return false; + } + return transferred == length; + } + + bool write_frame( + HANDLE pipe, + FrameKind kind, + std::size_t worker_index, + std::uint64_t generation, + std::uint64_t sequence, + const std::vector &payload, + DWORD timeout) + { + if (payload.size() > kMaximumFramePayload || payload.size() > std::numeric_limits::max()) + { + return false; + } + const FrameHeader header{ + kFrameMagic, + kFrameVersion, + kind, + static_cast(payload.size()), + static_cast(worker_index), + generation, + sequence}; + if (!write_message(pipe, &header, sizeof(header), timeout)) + { + return false; + } + return payload.empty() || write_message(pipe, payload.data(), static_cast(payload.size()), timeout); + } + + bool read_frame(HANDLE pipe, FrameHeader &header, std::vector &payload, DWORD timeout) + { + if (!read_message(pipe, &header, sizeof(header), timeout) || + header.magic != kFrameMagic || header.version != kFrameVersion || + header.payload_length > kMaximumFramePayload) + { + return false; + } + payload.resize(header.payload_length); + return payload.empty() || read_message(pipe, payload.data(), header.payload_length, timeout); + } + + bool child_write_message(HANDLE pipe, const void *data, DWORD length) + { + DWORD transferred = 0; + return WriteFile(pipe, data, length, &transferred, nullptr) != 0 && transferred == length; + } + + bool child_read_message(HANDLE pipe, void *data, DWORD length) + { + DWORD transferred = 0; + return ReadFile(pipe, data, length, &transferred, nullptr) != 0 && transferred == length; + } + + bool child_write_frame( + HANDLE pipe, + FrameKind kind, + std::size_t worker_index, + std::uint64_t generation, + std::uint64_t sequence, + const std::vector &payload) + { + if (payload.size() > kMaximumFramePayload || payload.size() > std::numeric_limits::max()) + { + return false; + } + const FrameHeader header{ + kFrameMagic, + kFrameVersion, + kind, + static_cast(payload.size()), + static_cast(worker_index), + generation, + sequence}; + return child_write_message(pipe, &header, sizeof(header)) && + (payload.empty() || child_write_message(pipe, payload.data(), static_cast(payload.size()))); + } + + bool child_read_frame(HANDLE pipe, FrameHeader &header, std::vector &payload) + { + if (!child_read_message(pipe, &header, sizeof(header)) || + header.magic != kFrameMagic || header.version != kFrameVersion || + header.payload_length > kMaximumFramePayload) + { + return false; + } + payload.resize(header.payload_length); + return payload.empty() || child_read_message(pipe, payload.data(), header.payload_length); + } + + class BinaryWriter final + { + public: + template + void scalar(Value value) + { + static_assert(std::is_trivially_copyable_v); + const auto *bytes = reinterpret_cast(&value); + data_.insert(data_.end(), bytes, bytes + sizeof(value)); + } + + void string(const std::string &value) + { + if (value.size() > std::numeric_limits::max()) + { + throw std::runtime_error("Windows worker bootstrap string is too large"); + } + scalar(static_cast(value.size())); + data_.insert(data_.end(), value.begin(), value.end()); + } + + void strings(const std::vector &values) + { + scalar(static_cast(values.size())); + for (const std::string &value : values) + { + string(value); + } + } + + std::vector finish() { return std::move(data_); } + + private: + std::vector data_; + }; + + class BinaryReader final + { + public: + explicit BinaryReader(const std::vector &data) : data_(data) {} + + template + Value scalar() + { + static_assert(std::is_trivially_copyable_v); + require(sizeof(Value)); + Value value{}; + std::memcpy(&value, data_.data() + offset_, sizeof(Value)); + offset_ += sizeof(Value); + return value; + } + + std::string string() + { + const std::uint32_t length = scalar(); + require(length); + std::string value(reinterpret_cast(data_.data() + offset_), length); + offset_ += length; + return value; + } + + std::vector strings() + { + const std::uint32_t count = scalar(); + if (count > 1024) + { + throw std::runtime_error("Windows worker bootstrap string list is too large"); + } + std::vector values; + values.reserve(count); + for (std::uint32_t index = 0; index < count; ++index) + { + values.push_back(string()); + } + return values; + } + + void ensure_complete() const + { + if (offset_ != data_.size()) + { + throw std::runtime_error("Windows worker bootstrap contains trailing bytes"); + } + } + + private: + void require(std::size_t length) + { + if (length > data_.size() - std::min(offset_, data_.size())) + { + throw std::runtime_error("truncated Windows worker bootstrap payload"); + } + } + const std::vector &data_; + std::size_t offset_ = 0; + }; + + std::vector serialize_config(const Vajra::runtime::RuntimeConfig &config) + { + BinaryWriter writer; + writer.string(config.host); + writer.scalar(config.port); + writer.scalar(config.workers); + writer.scalar(config.min_threads); + writer.scalar(config.max_threads); + writer.scalar(config.max_connections); + writer.scalar(config.socket_queue_capacity); + writer.scalar(config.max_request_head_bytes); + writer.scalar(config.max_request_body_bytes); + writer.scalar(config.max_keepalive_requests); + writer.scalar(config.request_timeout_seconds); + writer.scalar(config.request_head_timeout_seconds); + writer.scalar(config.first_data_timeout_seconds); + writer.scalar(config.request_body_timeout_seconds); + writer.scalar(config.persistent_timeout_seconds); + writer.scalar(config.worker_timeout_seconds); + writer.scalar(config.tls ? 1 : 0); + writer.string(config.tls_certificate); + writer.string(config.tls_private_key); + writer.string(config.tls_ca_certificate); + writer.string(config.tls_verify_mode); + writer.string(config.tls_min_version); + writer.strings(config.alpn_protocols); + writer.scalar(config.http2 ? 1 : 0); + writer.scalar(config.http2_max_concurrent_streams); + writer.scalar(config.http2_initial_window_size); + writer.scalar(config.http2_max_frame_size); + writer.scalar(config.http2_header_table_size); + writer.string(config.log_level); + writer.string(config.access_log); + writer.string(config.error_log); + writer.scalar(config.structured_logs ? 1 : 0); + writer.string(config.access_log_format); + writer.string(config.stats_path); + writer.string(config.metrics_endpoint); + writer.scalar(config.trace_enabled ? 1 : 0); + writer.string(config.trace_endpoint); + writer.string(config.trace_service_name); + writer.scalar(config.trace_otel_owner ? 1 : 0); + writer.string(config.trace_resource_attributes); + writer.string(config.trace_propagators); + return writer.finish(); + } + + Vajra::runtime::RuntimeConfig deserialize_config(const std::vector &payload) + { + BinaryReader reader(payload); + Vajra::runtime::RuntimeConfig config{ + reader.string(), + reader.scalar(), + reader.scalar(), + static_cast(reader.scalar()), + static_cast(reader.scalar()), + static_cast(reader.scalar()), + static_cast(reader.scalar()), + static_cast(reader.scalar()), + static_cast(reader.scalar()), + static_cast(reader.scalar()), + static_cast(reader.scalar()), + reader.scalar(), + reader.scalar(), + reader.scalar(), + reader.scalar(), + reader.scalar(), + reader.scalar() != 0, + reader.string(), + reader.string(), + reader.string(), + reader.string(), + reader.string(), + reader.strings(), + reader.scalar() != 0, + static_cast(reader.scalar()), + static_cast(reader.scalar()), + static_cast(reader.scalar()), + static_cast(reader.scalar()), + reader.string(), + reader.string(), + reader.string(), + reader.scalar() != 0, + reader.string(), + reader.string(), + reader.string(), + reader.scalar() != 0, + reader.string(), + reader.string(), + reader.scalar() != 0, + reader.string(), + reader.string()}; + reader.ensure_complete(); + return config; + } + + std::shared_ptr build_dispatch_server( + const Vajra::runtime::RuntimeConfig &config, + std::size_t worker_index) + { + auto rack_executor = std::make_shared( + std::shared_ptr{}, + Vajra::rack::ControlPlaneConfig{config.stats_path, config.metrics_endpoint}); + auto tls_context = config.tls + ? std::make_shared(Vajra::transport::TlsConfig{ + config.tls_certificate, + config.tls_private_key, + config.tls_ca_certificate, + config.tls_verify_mode, + config.tls_min_version, + config.alpn_protocols, + config.request_head_timeout_seconds, + config.first_data_timeout_seconds, + static_cast(config.request_timeout_seconds)}) + : nullptr; + const Vajra::request::Http2Config http2_config{ + config.http2_max_concurrent_streams, + config.http2_initial_window_size, + config.http2_max_frame_size, + config.http2_header_table_size, + config.max_request_head_bytes, + config.max_request_body_bytes, + config.max_keepalive_requests, + config.socket_queue_capacity}; + return std::make_shared( + config.port, + config.host, + config.max_request_head_bytes, + rack_executor, + "windows_worker_" + std::to_string(worker_index), + "worker_process", + config.workers, + "same_process_rack_execution", + Vajra::runtime::debug_logging_enabled(config.log_level), + Vajra::platform::kInvalidSocket, + config.request_head_timeout_seconds, + config.first_data_timeout_seconds, + config.request_body_timeout_seconds, + config.persistent_timeout_seconds, + config.max_connections, + std::function{}, + config.max_request_body_bytes, + config.max_keepalive_requests, + config.max_threads, + config.http2, + http2_config, + std::move(tls_context)); + } + + std::vector environment_block_with( + const std::vector> &updates) + { + LPWCH current = GetEnvironmentStringsW(); + if (current == nullptr) + { + throw windows_error("GetEnvironmentStringsW"); + } + std::vector entries; + for (const wchar_t *cursor = current; *cursor != L'\0'; cursor += std::wcslen(cursor) + 1) + { + const std::wstring entry(cursor); + bool replaced = false; + for (const auto &[name, value] : updates) + { + const std::wstring prefix = name + L"="; + if (entry.size() >= prefix.size() && _wcsnicmp(entry.c_str(), prefix.c_str(), prefix.size()) == 0) + { + replaced = true; + break; + } + } + if (!replaced) + { + entries.push_back(entry); + } + } + FreeEnvironmentStringsW(current); + for (const auto &[name, value] : updates) + { + entries.push_back(name + L"=" + value); + } + std::sort(entries.begin(), entries.end(), [](const std::wstring &left, const std::wstring &right) + { return _wcsicmp(left.c_str(), right.c_str()) < 0; }); + std::vector block; + for (const std::wstring &entry : entries) + { + block.insert(block.end(), entry.begin(), entry.end()); + block.push_back(L'\0'); + } + block.push_back(L'\0'); + return block; + } + + std::vector bytes_of(const void *data, std::size_t length) + { + const auto *begin = static_cast(data); + return std::vector(begin, begin + length); + } +} + +struct Vajra::runtime::WindowsWorkerSupervisor::Implementation +{ + struct Worker + { + std::size_t index = 0; + std::uint64_t generation = 0; + std::uint64_t sequence = 0; + std::uint64_t replacement_failures = 0; + Handle pipe; + Handle process; + platform::ProcessId process_id = platform::kInvalidProcessId; + std::shared_ptr state; + bool ready = false; + bool replacement_pending = false; + }; + + RuntimeConfig config; + RuntimeState *runtime_state = nullptr; + listener::SocketBinding listener_binding{platform::kInvalidSocket, -1}; + Handle job; + Handle parent_shutdown_event; + Handle supervisor_stop_event; + mutable std::mutex mutex; + std::vector> workers; + std::vector> public_states; + std::atomic_bool stop_requested{false}; + std::once_flag cleanup_once; + std::size_t next_worker = 0; + std::deque pending_clients; +#ifdef VAJRA_TEST_FAULT_INJECTION + std::wstring test_fault = environment_value(kTestFaultEnvironment); + bool test_fault_injected = false; + std::uint64_t successful_dispatches = 0; +#endif + + explicit Implementation(RuntimeConfig value, RuntimeState *state) + : config(std::move(value)), runtime_state(state) + { + } + + DWORD io_timeout() const + { + const int seconds = std::max(1, config.worker_timeout_seconds); + return static_cast(std::min( + static_cast(seconds) * 1000, + std::numeric_limits::max())); + } + + void initialize_job() + { + job.reset(CreateJobObjectW(nullptr, nullptr)); + if (!job) + { + throw windows_error("CreateJobObjectW"); + } + JOBOBJECT_EXTENDED_LIMIT_INFORMATION limits{}; + limits.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE; + if (SetInformationJobObject(job.get(), JobObjectExtendedLimitInformation, &limits, sizeof(limits)) == 0) + { + throw windows_error("SetInformationJobObject"); + } + + SECURITY_ATTRIBUTES attributes{}; + attributes.nLength = sizeof(attributes); + attributes.bInheritHandle = TRUE; + parent_shutdown_event.reset(CreateEventW(&attributes, TRUE, FALSE, nullptr)); + if (!parent_shutdown_event) + { + throw windows_error("CreateEventW"); + } + supervisor_stop_event.reset(CreateEventW(nullptr, TRUE, FALSE, nullptr)); + if (!supervisor_stop_event) + { + throw windows_error("CreateEventW"); + } + } + + bool spawn_worker(Worker &worker) + { + worker.generation += 1; +#ifdef VAJRA_TEST_FAULT_INJECTION + if (test_fault == L"replacement_exhaustion" && worker.generation > 1) + { + return false; + } +#endif + worker.sequence = 0; + worker.ready = false; + const std::wstring pipe_name = unique_name(L"worker", worker.index, worker.generation); + const std::wstring full_pipe_path = pipe_path(pipe_name); + worker.pipe.reset(CreateNamedPipeW( + full_pipe_path.c_str(), + PIPE_ACCESS_DUPLEX | FILE_FLAG_OVERLAPPED, + PIPE_TYPE_MESSAGE | PIPE_READMODE_MESSAGE | PIPE_WAIT | PIPE_REJECT_REMOTE_CLIENTS, + 1, + kPipeBufferBytes, + kPipeBufferBytes, + 0, + nullptr)); + if (!worker.pipe) + { + return false; + } + + const HANDLE mapping = runtime_state_mapping_handle(runtime_state); + if (mapping == nullptr) + { + throw std::runtime_error("Windows runtime state mapping handle is unavailable"); + } + auto duplicate_inheritable = [](DWORD standard_handle) -> Handle + { + const HANDLE source = GetStdHandle(standard_handle); + if (source == nullptr || source == INVALID_HANDLE_VALUE) + { + return Handle{}; + } + HANDLE duplicate = nullptr; + if (DuplicateHandle( + GetCurrentProcess(), + source, + GetCurrentProcess(), + &duplicate, + 0, + TRUE, + DUPLICATE_SAME_ACCESS) == 0) + { + return Handle{}; + } + return Handle(duplicate); + }; + SECURITY_ATTRIBUTES standard_handle_attributes{}; + standard_handle_attributes.nLength = sizeof(standard_handle_attributes); + standard_handle_attributes.bInheritHandle = TRUE; + Handle child_stdin(CreateFileW( + L"NUL", + GENERIC_READ, + FILE_SHARE_READ | FILE_SHARE_WRITE, + &standard_handle_attributes, + OPEN_EXISTING, + FILE_ATTRIBUTE_NORMAL, + nullptr)); + if (!child_stdin) + { + return false; + } + Handle child_stdout = duplicate_inheritable(STD_OUTPUT_HANDLE); + Handle child_stderr = duplicate_inheritable(STD_ERROR_HANDLE); + std::vector inherited_handles{mapping, parent_shutdown_event.get()}; + for (const Handle *standard : {&child_stdin, &child_stdout, &child_stderr}) + { + if (*standard) + { + inherited_handles.push_back(standard->get()); + } + } + SIZE_T attribute_bytes = 0; + InitializeProcThreadAttributeList(nullptr, 1, 0, &attribute_bytes); + std::vector attribute_storage(attribute_bytes); + auto *attributes = reinterpret_cast(attribute_storage.data()); + if (InitializeProcThreadAttributeList(attributes, 1, 0, &attribute_bytes) == 0) + { + return false; + } + struct AttributeGuard + { + LPPROC_THREAD_ATTRIBUTE_LIST value; + ~AttributeGuard() { DeleteProcThreadAttributeList(value); } + } attribute_guard{attributes}; + if (UpdateProcThreadAttribute( + attributes, + 0, + PROC_THREAD_ATTRIBUTE_HANDLE_LIST, + inherited_handles.data(), + inherited_handles.size() * sizeof(HANDLE), + nullptr, + nullptr) == 0) + { + return false; + } + + STARTUPINFOEXW startup{}; + startup.StartupInfo.cb = sizeof(startup); + startup.lpAttributeList = attributes; + if (child_stdin && child_stdout && child_stderr) + { + startup.StartupInfo.dwFlags |= STARTF_USESTDHANDLES; + startup.StartupInfo.hStdInput = child_stdin.get(); + startup.StartupInfo.hStdOutput = child_stdout.get(); + startup.StartupInfo.hStdError = child_stderr.get(); + } + PROCESS_INFORMATION process{}; + std::wstring command_line(GetCommandLineW()); + std::vector mutable_command(command_line.begin(), command_line.end()); + mutable_command.push_back(L'\0'); + std::array temporary_directory{}; + const DWORD temporary_length = GetTempPathW( + static_cast(temporary_directory.size()), + temporary_directory.data()); + if (temporary_length == 0 || temporary_length >= temporary_directory.size()) + { + return false; + } + const std::wstring worker_pidfile = + std::wstring(temporary_directory.data(), temporary_length) + + L"vajra-worker-" + std::to_wstring(GetCurrentProcessId()) + L"-" + + std::to_wstring(worker.index) + L"-" + std::to_wstring(worker.generation) + L".pid"; + const std::vector environment = environment_block_with({{kWorkerPipeEnvironment, full_pipe_path}, + {kRuntimeMappingEnvironment, std::to_wstring(reinterpret_cast(mapping))}, + {kParentShutdownEnvironment, std::to_wstring(reinterpret_cast(parent_shutdown_event.get()))}, + {L"PIDFILE", worker_pidfile}}); + const DWORD creation_flags = CREATE_SUSPENDED | CREATE_NEW_PROCESS_GROUP | + EXTENDED_STARTUPINFO_PRESENT | CREATE_UNICODE_ENVIRONMENT; + if (CreateProcessW( + nullptr, + mutable_command.data(), + nullptr, + nullptr, + TRUE, + creation_flags, + const_cast(environment.data()), + nullptr, + &startup.StartupInfo, + &process) == 0) + { + return false; + } + Handle process_handle(process.hProcess); + Handle thread_handle(process.hThread); + if (AssignProcessToJobObject(job.get(), process_handle.get()) == 0 || ResumeThread(thread_handle.get()) == static_cast(-1)) + { + TerminateProcess(process_handle.get(), 1); + return false; + } + const DWORD connect_timeout = std::max(io_timeout(), 30'000); + if (!connect_named_pipe(worker.pipe.get(), process_handle.get(), connect_timeout)) + { + DWORD exit_code = STILL_ACTIVE; + GetExitCodeProcess(process_handle.get(), &exit_code); + TerminateProcess(process_handle.get(), 1); + throw std::runtime_error( + "Windows worker failed to connect its bootstrap pipe: index=" + std::to_string(worker.index) + + " exit_code=" + std::to_string(exit_code) + + " windows_error=" + std::to_string(GetLastError())); + } + + worker.process_id = process.dwProcessId; + worker.process = std::move(process_handle); + if (!write_frame( + worker.pipe.get(), + FrameKind::bootstrap, + worker.index, + worker.generation, + 0, + serialize_config(config), + io_timeout())) + { + TerminateProcess(worker.process.get(), 1); + return false; + } + FrameHeader ready_header{}; + std::vector ready_payload; + if (!read_frame(worker.pipe.get(), ready_header, ready_payload, io_timeout()) || + ready_header.kind != FrameKind::ready || ready_header.worker_index != worker.index || + ready_header.generation != worker.generation || !ready_payload.empty()) + { + TerminateProcess(worker.process.get(), 1); + return false; + } + + worker.state = std::make_shared(worker.index, worker.process_id, std::vector{}); + worker.state->lifecycle_state.store(WorkerLifecycleState::ready, std::memory_order_release); + worker.state->health_state.store(WorkerHealthState::healthy, std::memory_order_release); + worker.state->available.store(true, std::memory_order_release); + worker.state->channel_generation.store(worker.generation, std::memory_order_release); + worker.state->last_progress_nanoseconds.store( + std::chrono::duration_cast( + std::chrono::steady_clock::now().time_since_epoch()) + .count(), + std::memory_order_release); + install_worker_runtime_state(runtime_state, worker.index, worker.process_id); + detach_worker_runtime_state(); + mark_worker_lifecycle(worker.index, WorkerLifecycleState::ready); + mark_worker_health(worker.index, WorkerHealthState::healthy); + mark_worker_available(worker.index, true); + worker.ready = true; + return true; + } + + void start() + { + initialize_job(); + listener::Socket listener_socket; + listener_binding = listener_socket.open(config.host, config.port); + config.port = listener_binding.port; + install_master_runtime_state(runtime_state, config.workers, config.max_threads, config.socket_queue_capacity); + + workers.reserve(static_cast(config.workers)); + public_states.reserve(static_cast(config.workers)); + std::vector worker_boot_seconds; + worker_boot_seconds.reserve(static_cast(config.workers)); + for (int index = 0; index < config.workers; ++index) + { + const auto boot_started = std::chrono::steady_clock::now(); + auto worker = std::make_unique(); + worker->index = static_cast(index); + if (!spawn_worker(*worker)) + { + throw std::runtime_error("Windows worker failed to become ready: index=" + std::to_string(index)); + } + const double elapsed_boot_seconds = std::chrono::duration( + std::chrono::steady_clock::now() - boot_started) + .count(); + worker_boot_seconds.push_back(elapsed_boot_seconds); + public_states.push_back(worker->state); + workers.push_back(std::move(worker)); + } + log_runtime_banner_start( + config.host, + listener_binding.port, + config.workers, + config.min_threads, + config.max_threads); + for (std::size_t index = 0; index < workers.size(); ++index) + { + const Worker &worker = *workers[index]; + log_worker_booted( + static_cast(index), + worker.process_id, + Vajra::platform::current_process_id(), + worker_boot_seconds[index]); + if (debug_logging_enabled(config.log_level)) + { + log_worker_lifecycle_event( + "worker_registered", + worker.index, + worker.process_id, + WorkerLifecycleState::booting, + WorkerHealthState::healthy, + WorkerRecoveryState::none, + false, + WorkerExitClassification::none, + false, + false, + 0); + log_worker_lifecycle_event( + "worker_ready", + worker.index, + worker.process_id, + WorkerLifecycleState::ready, + WorkerHealthState::healthy, + WorkerRecoveryState::none, + true, + WorkerExitClassification::none, + false, + false, + 0); + } + } + flush_runtime_logs(); + } + + bool replace_worker(Worker &worker) + { + const bool observe_unexpected_exit = !worker.replacement_pending; + const std::uint64_t replacement_attempts = worker.state + ? worker.state->replacement_attempt_count.load(std::memory_order_acquire) + 1 + : 1; + const std::uint64_t replacement_successes = worker.state + ? worker.state->replacement_success_count.load(std::memory_order_acquire) + : 0; + const std::uint64_t replacement_failures = worker.state + ? worker.state->replacement_failure_count.load(std::memory_order_acquire) + : 0; + const std::uint64_t unexpected_exits = worker.state + ? worker.state->unexpected_exit_count.load(std::memory_order_acquire) + + (observe_unexpected_exit ? 1 : 0) + : (observe_unexpected_exit ? 1 : 0); + const std::int64_t unexpected_exit_time = observe_unexpected_exit + ? std::chrono::duration_cast( + std::chrono::steady_clock::now().time_since_epoch()) + .count() + : (worker.state ? worker.state->last_unexpected_exit_nanoseconds.load( + std::memory_order_acquire) + : 0); + worker.replacement_pending = true; + worker.ready = false; + mark_worker_available(worker.index, false); + mark_worker_lifecycle(worker.index, WorkerLifecycleState::exited); + if (worker.state) + { + worker.state->available.store(false, std::memory_order_release); + worker.state->lifecycle_state.store(WorkerLifecycleState::exited, std::memory_order_release); + worker.state->replacement_attempt_count.store(replacement_attempts, std::memory_order_release); + worker.state->unexpected_exit_count.store(unexpected_exits, std::memory_order_release); + worker.state->last_unexpected_exit_nanoseconds.store(unexpected_exit_time, std::memory_order_release); + } + mark_worker_unexpected_exit(worker.index, unexpected_exits, unexpected_exit_time); + worker.pipe.reset(); + if (worker.process && WaitForSingleObject(worker.process.get(), 0) == WAIT_TIMEOUT) + { + TerminateProcess(worker.process.get(), 1); + WaitForSingleObject(worker.process.get(), 5000); + if (worker.state) + { + const std::uint64_t escalations = worker.state->timeout_escalation_count.fetch_add( + 1, + std::memory_order_acq_rel) + + 1; + mark_worker_timeout_escalations(worker.index, escalations); + } + } + worker.process.reset(); + if (stop_requested.load(std::memory_order_acquire)) + { + return false; + } + if (!spawn_worker(worker)) + { + worker.replacement_failures += 1; + if (worker.state) + { + worker.state->replacement_attempt_count.store(replacement_attempts, std::memory_order_release); + worker.state->replacement_success_count.store(replacement_successes, std::memory_order_release); + worker.state->replacement_failure_count.store( + replacement_failures + 1, + std::memory_order_release); + } + if (worker.replacement_failures >= 3) + { + if (worker.state) + { + worker.state->terminal_replacement_failure.store(true, std::memory_order_release); + worker.state->recovery_state.store(WorkerRecoveryState::terminal_failure, std::memory_order_release); + } + mark_worker_terminal_replacement_failure(worker.index, true); + if (debug_logging_enabled(config.log_level)) + { + log_worker_lifecycle_event( + "worker_replacement_terminal_failure", + worker.index, + worker.process_id, + WorkerLifecycleState::exited, + WorkerHealthState::wedged, + WorkerRecoveryState::terminal_failure, + false, + WorkerExitClassification::unexpected_exit, + true, + false, + static_cast(replacement_attempts)); + } + stop_requested.store(true, std::memory_order_release); + } + return false; + } + worker.replacement_failures = 0; + worker.replacement_pending = false; + if (worker.state) + { + worker.state->replacement_attempt_count.store(replacement_attempts, std::memory_order_release); + worker.state->replacement_success_count.store(replacement_successes + 1, std::memory_order_release); + worker.state->replacement_failure_count.store(replacement_failures, std::memory_order_release); + worker.state->unexpected_exit_count.store(unexpected_exits, std::memory_order_release); + worker.state->last_unexpected_exit_nanoseconds.store(unexpected_exit_time, std::memory_order_release); + } + { + const std::lock_guard lock(mutex); + public_states[worker.index] = worker.state; + } + mark_worker_replacement_counters( + worker.index, + replacement_attempts, + replacement_successes + 1, + replacement_failures); + if (debug_logging_enabled(config.log_level)) + { + log_worker_lifecycle_event( + "worker_replacement_ready", + worker.index, + worker.process_id, + WorkerLifecycleState::ready, + WorkerHealthState::healthy, + WorkerRecoveryState::none, + true, + WorkerExitClassification::none, + false, + false, + static_cast(replacement_attempts)); + } + return true; + } + + Worker *next_ready_worker() + { + if (workers.empty()) + { + return nullptr; + } + for (std::size_t attempt = 0; attempt < workers.size(); ++attempt) + { + Worker &worker = *workers[next_worker++ % workers.size()]; + if (worker.ready && WaitForSingleObject(worker.process.get(), 0) == WAIT_TIMEOUT) + { + return &worker; + } + if (worker.ready) + { + replace_worker(worker); + } + } + return nullptr; + } + + bool dispatch_socket(Worker &worker, platform::SocketHandle client) + { + SocketDispatchPayload dispatch{}; + if (WSADuplicateSocketW(client, worker.process_id, &dispatch.protocol_info) != 0) + { + return false; + } + const std::uint64_t sequence = ++worker.sequence; +#ifdef VAJRA_TEST_FAULT_INJECTION + const bool duplicate_ready = test_fault == L"duplicate_dispatch" && successful_dispatches > 0; + const bool frame_fault = test_fault == L"malformed_frame" || test_fault == L"oversized_frame" || + test_fault == L"unknown_frame" || test_fault == L"stale_generation" || + test_fault == L"duplicate_dispatch" || test_fault == L"partial_socket_transfer"; + const bool inject_frame = !test_fault_injected && frame_fault && + (test_fault != L"duplicate_dispatch" || duplicate_ready); + if (inject_frame) + { + FrameHeader header{ + kFrameMagic, + kFrameVersion, + FrameKind::socket_dispatch, + static_cast(sizeof(dispatch)), + static_cast(worker.index), + worker.generation, + sequence}; + if (test_fault == L"malformed_frame") + { + header.magic = 0; + } + else if (test_fault == L"oversized_frame") + { + header.payload_length = static_cast(kMaximumFramePayload + 1); + } + else if (test_fault == L"unknown_frame") + { + header.kind = static_cast(std::numeric_limits::max()); + } + else if (test_fault == L"stale_generation") + { + header.generation = worker.generation == 0 ? 1 : worker.generation - 1; + } + else if (test_fault == L"duplicate_dispatch") + { + header.sequence = sequence - 1; + } + else if (test_fault == L"partial_socket_transfer") + { + // Keep the valid header and truncate only its protocol-info payload. + } + else + { + return false; + } + test_fault_injected = true; + const DWORD dispatch_bytes = test_fault == L"partial_socket_transfer" + ? static_cast(sizeof(dispatch) / 2) + : static_cast(sizeof(dispatch)); + if (!write_message(worker.pipe.get(), &header, sizeof(header), io_timeout()) || + (test_fault != L"oversized_frame" && + !write_message(worker.pipe.get(), &dispatch, dispatch_bytes, io_timeout()))) + { + replace_worker(worker); + return false; + } + } + else +#endif + if (!write_frame( + worker.pipe.get(), + FrameKind::socket_dispatch, + worker.index, + worker.generation, + sequence, + bytes_of(&dispatch, sizeof(dispatch)), + io_timeout())) + { + replace_worker(worker); + return false; + } + FrameHeader ack_header{}; + std::vector ack_payload; + if (!read_frame(worker.pipe.get(), ack_header, ack_payload, io_timeout()) || + ack_header.kind != FrameKind::socket_ack || ack_header.worker_index != worker.index || + ack_header.generation != worker.generation || ack_header.sequence != sequence || + ack_payload.size() != sizeof(SocketAckPayload)) + { + replace_worker(worker); + return false; + } + SocketAckPayload ack{}; + std::memcpy(&ack, ack_payload.data(), sizeof(ack)); + if (ack.accepted != 0) + { +#ifdef VAJRA_TEST_FAULT_INJECTION + successful_dispatches += 1; +#endif + return true; + } + return false; + } + + void run() + { + while (!stop_requested.load(std::memory_order_acquire)) + { + if (VajraNative::shutdown_requested()) + { + VajraNative::begin_runtime_shutdown(); + request_stop(); + break; + } + if (!pending_clients.empty()) + { + Worker *worker = next_ready_worker(); + if (worker != nullptr) + { + const platform::SocketHandle client = pending_clients.front(); + pending_clients.pop_front(); + (void)dispatch_socket(*worker, client); + platform::close_socket(client); + continue; + } + } + const std::size_t queue_capacity = std::max(1, config.socket_queue_capacity); + const bool queue_has_capacity = pending_clients.size() < queue_capacity; + if (!queue_has_capacity || !platform::wait_socket(listener_binding.fd, platform::WaitEvent::read, 100)) + { + for (auto &worker : workers) + { + if (worker->ready && WaitForSingleObject(worker->process.get(), 0) == WAIT_OBJECT_0) + { + replace_worker(*worker); + } + else if (!worker->ready && worker->replacement_pending && + (!worker->state || !worker->state->terminal_replacement_failure.load(std::memory_order_acquire))) + { + replace_worker(*worker); + } + } + if (!queue_has_capacity) + { + WaitForSingleObject(supervisor_stop_event.get(), 1); + } + continue; + } + sockaddr_storage address{}; + socklen_t address_length = sizeof(address); + const platform::SocketHandle client = platform::accept_socket( + listener_binding.fd, + reinterpret_cast(&address), + &address_length); + if (!platform::socket_valid(client)) + { + continue; + } + platform::set_socket_inheritable(client, false); + pending_clients.push_back(client); + } + cleanup(); + } + + void request_stop() + { + stop_requested.store(true, std::memory_order_release); + if (supervisor_stop_event) + { + SetEvent(supervisor_stop_event.get()); + } + } + + void cleanup() + { + request_stop(); + std::call_once(cleanup_once, [this]() + { + mark_runtime_shutdown_requested(); + if (platform::socket_valid(listener_binding.fd)) + { + platform::shutdown_socket(listener_binding.fd); + platform::close_socket(listener_binding.fd); + listener_binding.fd = platform::kInvalidSocket; + } + while (!pending_clients.empty()) + { + platform::close_socket(pending_clients.front()); + pending_clients.pop_front(); + } + for (auto &worker : workers) + { + worker->ready = false; + mark_worker_available(worker->index, false); + mark_worker_lifecycle(worker->index, WorkerLifecycleState::stopping); + (void)write_frame( + worker->pipe.get(), + FrameKind::shutdown, + worker->index, + worker->generation, + ++worker->sequence, + {}, + io_timeout()); + } + const auto deadline = std::chrono::steady_clock::now() + + std::chrono::seconds(std::max(1, config.worker_timeout_seconds)); + for (auto &worker : workers) + { + const auto remaining = std::chrono::duration_cast( + deadline - std::chrono::steady_clock::now()) + .count(); + const DWORD wait_time = remaining <= 0 ? 0 : static_cast(remaining); + if (worker->process && WaitForSingleObject(worker->process.get(), wait_time) != WAIT_OBJECT_0) + { + TerminateProcess(worker->process.get(), 1); + WaitForSingleObject(worker->process.get(), 5000); + if (worker->state) + { + worker->state->timeout_escalation_count.fetch_add(1, std::memory_order_acq_rel); + } + } + mark_worker_lifecycle(worker->index, WorkerLifecycleState::exited); + worker->pipe.reset(); + worker->process.reset(); + } + if (parent_shutdown_event) + { + SetEvent(parent_shutdown_event.get()); + } }); + } +}; + +bool Vajra::runtime::windows_worker_bootstrap_present() +{ + return !environment_value(kWorkerPipeEnvironment).empty(); +} + +void Vajra::runtime::run_windows_worker_process(const RuntimeConfig &invocation_config) +{ + (void)invocation_config; + platform::ensure_socket_runtime(); + const std::wstring pipe = environment_value(kWorkerPipeEnvironment); + const std::uint64_t mapping_value = unsigned_value( + environment_value(kRuntimeMappingEnvironment), + "runtime mapping handle"); + const std::uint64_t shutdown_value = unsigned_value( + environment_value(kParentShutdownEnvironment), + "parent shutdown handle"); + Handle control(CreateFileW( + pipe.c_str(), + GENERIC_READ | GENERIC_WRITE, + 0, + nullptr, + OPEN_EXISTING, + 0, + nullptr)); + if (!control) + { + throw windows_error("worker CreateFileW(named pipe)"); + } + DWORD mode = PIPE_READMODE_MESSAGE; + if (SetNamedPipeHandleState(control.get(), &mode, nullptr, nullptr) == 0) + { + throw windows_error("SetNamedPipeHandleState"); + } + + FrameHeader bootstrap_header{}; + std::vector bootstrap_payload; + if (!child_read_frame(control.get(), bootstrap_header, bootstrap_payload) || + bootstrap_header.kind != FrameKind::bootstrap) + { + throw std::runtime_error("invalid Windows worker bootstrap frame"); + } + const RuntimeConfig config = deserialize_config(bootstrap_payload); +#ifdef VAJRA_TEST_FAULT_INJECTION + const std::wstring test_fault = environment_value(kTestFaultEnvironment); + if (test_fault == L"bootstrap_failure") + { + throw std::runtime_error("injected Windows worker bootstrap failure"); + } + if (test_fault == L"readiness_timeout") + { + Sleep(static_cast(std::max(2, config.worker_timeout_seconds + 1)) * 1000); + } +#endif + const BootContractResult boot_result = BootContract::run( + BootContractConfig{ + config.port, + config.max_request_head_bytes, + "ruby_worker_bootstrap"}); + BootContract::ensure_ready(boot_result); + auto *runtime_state = attach_runtime_state(reinterpret_cast(static_cast(mapping_value))); + struct RuntimeStateGuard + { + RuntimeState *state; + ~RuntimeStateGuard() { release_runtime_state(state); } + } state_guard{runtime_state}; + const std::size_t worker_index = bootstrap_header.worker_index; + const std::uint64_t generation = bootstrap_header.generation; + install_worker_runtime_state(runtime_state, worker_index, platform::current_process_id()); + configure_runtime_logging( + config.structured_logs, + config.access_log, + config.error_log, + config.access_log_format); + configure_runtime_tracing( + config.trace_enabled, + config.trace_endpoint, + config.trace_service_name, + config.trace_enabled && !config.trace_otel_owner, + config.trace_resource_attributes, + config.trace_propagators); + start_runtime_logging_worker(); + start_runtime_tracing_worker(); + Vajra::rack::ensure_same_process_rack_execution_threads_started(); + auto server = build_dispatch_server(config, worker_index); + server->start_dispatch_worker(); + mark_worker_lifecycle(worker_index, WorkerLifecycleState::ready); + mark_worker_health(worker_index, WorkerHealthState::healthy); + mark_worker_available(worker_index, true); + if (!child_write_frame(control.get(), FrameKind::ready, worker_index, generation, 0, {})) + { + throw std::runtime_error("failed to acknowledge Windows worker readiness"); + } + + struct WorkerLoopContext + { + HANDLE control; + HANDLE parent_shutdown; + std::size_t worker_index; + std::uint64_t generation; + std::uint64_t last_dispatch_sequence; + int worker_timeout_seconds; +#ifdef VAJRA_TEST_FAULT_INJECTION + std::wstring test_fault; +#endif + std::shared_ptr server; + std::exception_ptr failure; + } loop_context{ + control.get(), + reinterpret_cast(static_cast(shutdown_value)), + worker_index, + generation, + 0, + config.worker_timeout_seconds, +#ifdef VAJRA_TEST_FAULT_INJECTION + test_fault, +#endif + server, + nullptr}; + rb_thread_call_without_gvl( + [](void *value) -> void * + { + auto &context = *static_cast(value); + try + { + for (;;) + { + if (WaitForSingleObject(context.parent_shutdown, 0) == WAIT_OBJECT_0) + { + break; + } + FrameHeader header{}; + std::vector payload; + if (!child_read_frame(context.control, header, payload)) + { + break; + } + if (header.worker_index != context.worker_index || header.generation != context.generation) + { + break; + } + if (header.kind == FrameKind::shutdown) + { +#ifdef VAJRA_TEST_FAULT_INJECTION + if (context.test_fault == L"drain_timeout") + { + Sleep(static_cast(std::max(2, context.worker_timeout_seconds + 1)) * 1000); + } +#endif + break; + } + if (header.kind != FrameKind::socket_dispatch || payload.size() != sizeof(SocketDispatchPayload) || + header.sequence == 0 || header.sequence <= context.last_dispatch_sequence) + { + child_write_frame(context.control, FrameKind::failure, context.worker_index, context.generation, header.sequence, {}); + break; + } + context.last_dispatch_sequence = header.sequence; +#ifdef VAJRA_TEST_FAULT_INJECTION + if (context.generation == 1 && context.test_fault == L"dispatch_timeout") + { + Sleep(static_cast(std::max(2, context.worker_timeout_seconds + 1)) * 1000); + } + if (context.generation == 1 && + (context.test_fault == L"worker_crash" || context.test_fault == L"replacement_exhaustion")) + { + ExitProcess(86); + } +#endif + SocketDispatchPayload dispatch{}; + std::memcpy(&dispatch, payload.data(), sizeof(dispatch)); + const platform::SocketHandle client = WSASocketW( + FROM_PROTOCOL_INFO, + FROM_PROTOCOL_INFO, + FROM_PROTOCOL_INFO, + &dispatch.protocol_info, + 0, + WSA_FLAG_OVERLAPPED); + SocketAckPayload ack{}; + if (platform::socket_valid(client)) + { + platform::set_socket_inheritable(client, false); + ack.accepted = context.server->dispatch_client(client) ? 1 : 0; + if (ack.accepted == 0) + { + ack.error_code = platform::socket_last_error(); + } + } + else + { + ack.error_code = platform::socket_last_error(); + } + if (!child_write_frame( + context.control, + FrameKind::socket_ack, + context.worker_index, + context.generation, + header.sequence, + bytes_of(&ack, sizeof(ack)))) + { + break; + } + } + + mark_worker_available(context.worker_index, false); + mark_worker_lifecycle(context.worker_index, WorkerLifecycleState::stopping); + mark_runtime_shutdown_requested(); + NativeRuntime::instance().begin_runtime_shutdown(); + context.server->finish_dispatch_worker(); + mark_worker_lifecycle(context.worker_index, WorkerLifecycleState::exited); + child_write_frame(context.control, FrameKind::stopped, context.worker_index, context.generation, 0, {}); + } + catch (...) + { + context.failure = std::current_exception(); + } + return nullptr; + }, + &loop_context, + nullptr, + nullptr); + if (loop_context.failure) + { + std::rethrow_exception(loop_context.failure); + } + stop_runtime_tracing_worker(); + stop_runtime_logging_worker(); +} + +Vajra::runtime::WindowsWorkerSupervisor::WindowsWorkerSupervisor(RuntimeConfig config, RuntimeState *runtime_state) + : implementation_(std::make_unique(std::move(config), runtime_state)) +{ +} + +Vajra::runtime::WindowsWorkerSupervisor::~WindowsWorkerSupervisor() +{ + implementation_->cleanup(); +} + +void Vajra::runtime::WindowsWorkerSupervisor::start() +{ + implementation_->start(); +} + +void Vajra::runtime::WindowsWorkerSupervisor::run() +{ + implementation_->run(); +} + +void Vajra::runtime::WindowsWorkerSupervisor::request_stop() +{ + implementation_->request_stop(); +} + +std::vector> +Vajra::runtime::WindowsWorkerSupervisor::worker_states() const +{ + const std::lock_guard lock(implementation_->mutex); + return implementation_->public_states; +} + +#endif diff --git a/gems/vajra/ext/vajra/runtime/windows_worker_backend.hpp b/gems/vajra/ext/vajra/runtime/windows_worker_backend.hpp new file mode 100644 index 0000000..779bef9 --- /dev/null +++ b/gems/vajra/ext/vajra/runtime/windows_worker_backend.hpp @@ -0,0 +1,46 @@ +// Copyright Codevedas Inc. 2025-present +// +// This source code is licensed under the MIT license found in the +// LICENSE file in the root directory of this source tree. + +#ifndef VAJRA_RUNTIME_WINDOWS_WORKER_BACKEND_HPP +#define VAJRA_RUNTIME_WINDOWS_WORKER_BACKEND_HPP + +#ifdef _WIN32 + +#include "runtime/runtime_config.hpp" +#include "runtime/runtime_state.hpp" +#include "runtime/worker_pool.hpp" + +#include +#include +#include +#include + +namespace Vajra::runtime +{ + bool windows_worker_bootstrap_present(); + void run_windows_worker_process(const RuntimeConfig &invocation_config); + + class WindowsWorkerSupervisor final + { + public: + WindowsWorkerSupervisor(RuntimeConfig config, RuntimeState *runtime_state); + ~WindowsWorkerSupervisor(); + + WindowsWorkerSupervisor(const WindowsWorkerSupervisor &) = delete; + WindowsWorkerSupervisor &operator=(const WindowsWorkerSupervisor &) = delete; + + void start(); + void run(); + void request_stop(); + std::vector> worker_states() const; + + private: + struct Implementation; + std::unique_ptr implementation_; + }; +} + +#endif +#endif diff --git a/gems/vajra/ext/vajra/runtime/worker_pool.hpp b/gems/vajra/ext/vajra/runtime/worker_pool.hpp index f7097eb..c9d0bb9 100644 --- a/gems/vajra/ext/vajra/runtime/worker_pool.hpp +++ b/gems/vajra/ext/vajra/runtime/worker_pool.hpp @@ -11,7 +11,7 @@ #include #include #include -#include +#include "platform/process.hpp" #include #include @@ -61,7 +61,7 @@ namespace Vajra { SharedWorkerState( std::size_t index, - pid_t worker_pid, + platform::ProcessId worker_pid, std::vector parent_control_channels) : worker_index(index), control_channel_fds(std::move(parent_control_channels)), @@ -77,7 +77,7 @@ namespace Vajra mutable std::mutex request_channel_mutex; std::vector request_channel_fds; std::atomic request_channel_count{0}; - std::atomic pid; + std::atomic pid; std::atomic lifecycle_state{WorkerLifecycleState::booting}; std::atomic health_state{WorkerHealthState::healthy}; std::atomic_bool available{false}; diff --git a/gems/vajra/ext/vajra/server.cpp b/gems/vajra/ext/vajra/server.cpp index 371e85b..b3bc022 100644 --- a/gems/vajra/ext/vajra/server.cpp +++ b/gems/vajra/ext/vajra/server.cpp @@ -6,23 +6,25 @@ #include "server.hpp" #include "vajra.hpp" #include "response/response_writer.hpp" +#include "request/http2_session.hpp" #include "runtime/time_utils.hpp" #include "runtime/runtime_state.hpp" #include -#include #include #include #include -#include #include #include -#include #include #include +#ifndef _WIN32 +#include +#include #include #include #include +#endif #include namespace @@ -33,8 +35,6 @@ namespace constexpr std::size_t kListenerFdCost = 1; constexpr std::size_t kAcceptedClientSocketFdCost = 1; - void log_client_socket_interrupt_failed(int client_fd, const char *error_message); - std::size_t checked_add(std::size_t left, std::size_t right, const char *error_message) { if (right > (std::numeric_limits::max() - left)) @@ -55,6 +55,9 @@ namespace std::size_t safe_tracked_client_descriptor_limit() { +#ifdef _WIN32 + return std::numeric_limits::max(); +#else rlimit fd_limit{}; if (getrlimit(RLIMIT_NOFILE, &fd_limit) != 0 || fd_limit.rlim_cur == RLIM_INFINITY) { @@ -72,65 +75,48 @@ namespace } return available_fds - fixed_fd_cost; - } - -#if !defined(__linux__) || !defined(SOCK_CLOEXEC) - int set_fd_cloexec(int fd) - { - const int existing_flags = fcntl(fd, F_GETFD); - if (existing_flags < 0) - { - return -1; - } - - return fcntl(fd, F_SETFD, existing_flags | FD_CLOEXEC); - } #endif + } - int accept_client_cloexec(int listener_fd, sockaddr *client_addr, socklen_t *client_len) + Vajra::platform::SocketHandle accept_client_cloexec( + Vajra::platform::SocketHandle listener_fd, + sockaddr *client_addr, + socklen_t *client_len) { #if defined(__linux__) && defined(SOCK_CLOEXEC) return accept4(listener_fd, client_addr, client_len, SOCK_CLOEXEC); #else - const int client_fd = accept(listener_fd, client_addr, client_len); - if (client_fd < 0) + const Vajra::platform::SocketHandle client_fd = + Vajra::platform::accept_socket(listener_fd, client_addr, client_len); + if (!Vajra::platform::socket_valid(client_fd)) { return client_fd; } - if (set_fd_cloexec(client_fd) != 0) + if (!Vajra::platform::set_socket_inheritable(client_fd, false)) + { + Vajra::platform::close_socket(client_fd); + return Vajra::platform::kInvalidSocket; + } +#ifdef _WIN32 + if (!Vajra::platform::set_socket_nonblocking(client_fd, false)) { - const int error_number = errno; - close(client_fd); - errno = error_number; - return -1; + Vajra::platform::close_socket(client_fd); + return Vajra::platform::kInvalidSocket; } +#endif return client_fd; #endif } - bool shutdown_interrupt_succeeded_or_expected(int fd, int original_fd) + bool shutdown_interrupt_succeeded_or_expected( + Vajra::platform::SocketHandle fd, + Vajra::platform::SocketHandle original_fd) { - for (;;) - { - if (shutdown(fd, SHUT_RDWR) == 0) - { - return true; - } - - if (errno == EINTR) - { - continue; - } - if (errno == ENOTCONN || errno == EINVAL || errno == ENOTSOCK) - { - return true; - } - - log_client_socket_interrupt_failed(original_fd, std::strerror(errno)); - return false; - } + Vajra::platform::shutdown_socket(fd); + (void)original_fd; + return true; } std::string socket_address(sockaddr_in address) @@ -144,11 +130,14 @@ namespace return buffer; } - Vajra::request::SocketContext socket_context_for(int client_fd, const sockaddr_in &client_addr, int fallback_port) + Vajra::request::SocketContext socket_context_for( + Vajra::platform::SocketHandle client_fd, + const sockaddr_in &client_addr, + int fallback_port) { sockaddr_in local_addr{}; socklen_t local_addr_length = sizeof(local_addr); - if (getsockname(client_fd, reinterpret_cast(&local_addr), &local_addr_length) != 0) + if (!Vajra::platform::socket_name(client_fd, reinterpret_cast(&local_addr), &local_addr_length)) { local_addr.sin_family = AF_INET; local_addr.sin_addr.s_addr = htonl(INADDR_ANY); @@ -236,7 +225,7 @@ namespace << " stop_reason=" << stop_reason_name(snapshot.last_stop_reason) << " port=" << snapshot.port << " listener_owned=" << (snapshot.listener_owned ? "true" : "false") - << " listener_fd=" << snapshot.listener_fd + << " listener_fd=" << Vajra::platform::socket_handle_value(snapshot.listener_fd) << " mode=" << runtime_mode << " process_role=" << process_role << " request_execution_role=" << request_execution_role @@ -277,15 +266,16 @@ namespace Vajra::lifecycle::StopReason::none, false, -1, - -1, + Vajra::platform::kInvalidSocket, }; log_runtime_event("booting", snapshot, process_role, runtime_mode, worker_processes, request_execution_role, std::cout); } void log_listening_banner(const std::string &host, int port) { - std::cout << "[" << getpid() << "] * Listening on http://" << host << ":" << port << std::endl; - std::cout << "[" << getpid() << "] Use Ctrl-C to stop" << std::endl; + const Vajra::platform::ProcessId process_id = Vajra::platform::current_process_id(); + std::cout << "[" << process_id << "] * Listening on http://" << host << ":" << port << std::endl; + std::cout << "[" << process_id << "] Use Ctrl-C to stop" << std::endl; std::ostringstream message; message << "listening on port " << port; log_message("lifecycle", message.str(), std::cout); @@ -305,20 +295,6 @@ namespace log_message("error", message.str(), std::cerr); } - void log_poll_failed(const char *error_message) - { - std::ostringstream message; - message << "poll failed: " << error_message; - log_message("error", message.str(), std::cerr); - } - - void log_poll_listener_event(short revents) - { - std::ostringstream message; - message << "poll reported listener error: revents=" << revents; - log_message("error", message.str(), std::cerr); - } - void log_connection_rejected(std::size_t max_connections) { std::ostringstream message; @@ -326,23 +302,19 @@ namespace log_message("error", message.str(), std::cerr); } - void log_handler_thread_failure(const Vajra::request::SocketContext &socket_context, int client_fd, const std::string &message) + void log_handler_thread_failure( + const Vajra::request::SocketContext &socket_context, + Vajra::platform::SocketHandle client_fd, + const std::string &message) { std::ostringstream error_message; error_message << "handler thread failed for client=" << socket_context.remote_address << ':' << socket_context.remote_port - << " client_fd=" << client_fd + << " client_fd=" << Vajra::platform::socket_handle_value(client_fd) << " error=" << message; log_message("error", error_message.str(), std::cerr); } - void log_client_socket_interrupt_failed(int client_fd, const char *error_message) - { - std::ostringstream message; - message << "client socket interrupt failed: client_fd=" << client_fd << " error=" << error_message; - log_message("error", message.str(), std::cerr); - } - void log_client_socket_interrupt_aborted(const char *error_message) { std::ostringstream message; @@ -378,14 +350,20 @@ Vajra::Server::Server( int worker_processes, std::string request_execution_role, bool debug_logging, - int inherited_listener_fd, + platform::SocketHandle inherited_listener_fd, int request_head_timeout_seconds, int first_data_timeout_seconds, int request_body_timeout_seconds, int persistent_timeout_seconds, std::size_t max_connections, std::function shutdown_begin_callback, - std::size_t max_request_body_bytes) + std::size_t max_request_body_bytes, + std::size_t max_keepalive_requests, + std::size_t http2_execution_threads, + bool http2_enabled, + request::Http2Config http2_config, + std::shared_ptr tls_context, + std::function boot_ready_callback) : host_(std::move(host)), port_(port), server_fd_(inherited_listener_fd), @@ -397,8 +375,14 @@ Vajra::Server::Server( first_data_timeout_seconds, request_body_timeout_seconds, persistent_timeout_seconds, - 0, - std::move(request_executor)), + max_keepalive_requests, + request_executor, + http2_execution_threads, + http2_enabled, + http2_config), + request_executor_(std::move(request_executor)), + http2_config_(std::move(http2_config)), + tls_context_(std::move(tls_context)), lifecycle_(), process_role_(std::move(process_role)), runtime_mode_(std::move(runtime_mode)), @@ -407,7 +391,8 @@ Vajra::Server::Server( debug_logging_(debug_logging), max_connections_(max_connections), max_tracked_client_descriptors_(safe_tracked_client_descriptor_limit()), - shutdown_begin_callback_(std::move(shutdown_begin_callback)) + shutdown_begin_callback_(std::move(shutdown_begin_callback)), + boot_ready_callback_(std::move(boot_ready_callback)) { if (max_tracked_client_descriptors_ < active_client_descriptor_reservation()) { @@ -461,25 +446,25 @@ Vajra::Server::~Server() const lifecycle::Snapshot snapshot = lifecycle_.snapshot(); if (snapshot.state == lifecycle::State::draining || snapshot.state == lifecycle::State::failed) { - interrupt_active_client_sockets(); + interrupt_active_client_sockets(true); } join_handler_threads(); } void Vajra::Server::close_listener_fd(bool interrupt_accept) { - const int listener_fd = server_fd_.exchange(-1); - if (listener_fd < 0) + const platform::SocketHandle listener_fd = server_fd_.exchange(platform::kInvalidSocket); + if (!platform::socket_valid(listener_fd)) { return; } if (interrupt_accept) { - shutdown(listener_fd, SHUT_RDWR); + platform::shutdown_socket(listener_fd); } - close(listener_fd); + platform::close_socket(listener_fd); } void Vajra::Server::join_handler_threads() @@ -545,7 +530,7 @@ void Vajra::Server::start_handler_threads() } } -std::uint64_t Vajra::Server::register_active_client_fd(int client_fd) +std::uint64_t Vajra::Server::register_active_client_fd(platform::SocketHandle client_fd) { const std::size_t descriptor_count = active_client_descriptor_reservation(); const std::uint64_t client_token = next_active_client_token_.fetch_add(1, std::memory_order_acq_rel) + 1; @@ -558,25 +543,27 @@ std::uint64_t Vajra::Server::register_active_client_fd(int client_fd) throw std::runtime_error("active client descriptor budget exhausted"); } - active_client_fds_.emplace(client_token, ActiveClientRegistration{client_fd, true, descriptor_count}); + active_client_fds_.emplace(client_token, ActiveClientRegistration{client_fd, true, false, descriptor_count}); active_tracked_client_descriptors_ += descriptor_count; } return client_token; } -void Vajra::Server::unregister_active_client_fd(int client_fd, std::uint64_t client_token) +bool Vajra::Server::unregister_active_client_fd(platform::SocketHandle client_fd, std::uint64_t client_token) { std::size_t descriptor_count = 0; + bool should_close = false; { std::lock_guard lock(active_client_fds_mutex_); const auto active_client_fd = active_client_fds_.find(client_token); if (active_client_fd == active_client_fds_.end() || active_client_fd->second.original_fd != client_fd) { - return; + return false; } descriptor_count = active_client_fd->second.descriptor_count; + should_close = active_client_fd->second.open; active_client_fd->second.open = false; active_client_fds_.erase(active_client_fd); if (active_tracked_client_descriptors_ >= descriptor_count) @@ -588,9 +575,29 @@ void Vajra::Server::unregister_active_client_fd(int client_fd, std::uint64_t cli active_tracked_client_descriptors_ = 0; } } + return should_close; } -void Vajra::Server::interrupt_active_client_sockets() noexcept +void Vajra::Server::set_active_client_request_state(std::uint64_t client_token, bool request_active) +{ + const lifecycle::State lifecycle_state = lifecycle_.snapshot().state; + std::lock_guard lock(active_client_fds_mutex_); + const auto registration = active_client_fds_.find(client_token); + if (registration != active_client_fds_.end()) + { + registration->second.request_active = request_active; + if (!request_active && lifecycle_state == lifecycle::State::draining && registration->second.open) + { + shutdown_interrupt_succeeded_or_expected( + registration->second.original_fd, + registration->second.original_fd); + platform::close_socket(registration->second.original_fd); + registration->second.open = false; + } + } +} + +void Vajra::Server::interrupt_active_client_sockets(bool include_active_requests) noexcept { try { @@ -598,12 +605,13 @@ void Vajra::Server::interrupt_active_client_sockets() noexcept for (auto &[client_token, registration] : active_client_fds_) { (void)client_token; - if (!registration.open) + if (!registration.open || (!include_active_requests && registration.request_active)) { continue; } shutdown_interrupt_succeeded_or_expected(registration.original_fd, registration.original_fd); + platform::close_socket(registration.original_fd); registration.open = false; } } @@ -637,6 +645,21 @@ void Vajra::Server::enqueue_pending_client(PendingClient client) void Vajra::Server::run_handler_thread() { +#ifdef _WIN32 + struct WorkerRuntimeAttachment final + { + WorkerRuntimeAttachment() + { + Vajra::runtime::attach_current_thread_to_worker_runtime_state(0); + } + + ~WorkerRuntimeAttachment() + { + Vajra::runtime::detach_worker_runtime_state(); + } + } worker_runtime_attachment; +#endif + for (;;) { PendingClient client{}; @@ -668,9 +691,41 @@ void Vajra::Server::handle_pending_client(PendingClient client) bool hijacked = false; try { - Vajra::transport::PlainConnection connection(client.fd); - const Vajra::request::RequestProcessingOutcome outcome = request_processor_.handle(connection, client.socket_context); - hijacked = outcome == Vajra::request::RequestProcessingOutcome::hijacked; + if (tls_context_) + { + Vajra::transport::TlsConnection connection(client.fd, *tls_context_); + connection.handshake(); + client.socket_context.scheme = "https"; + if (connection.protocol() == "h2") + { + Vajra::request::Http2Session session( + connection, + client.socket_context, + http2_config_, + request_executor_, + request_processor_.http2_execution_pool()); + session.run(); + } + else + { + const Vajra::request::RequestProcessingOutcome outcome = request_processor_.handle( + connection, + client.socket_context, + [this, token = client.token](bool active) + { set_active_client_request_state(token, active); }); + hijacked = outcome == Vajra::request::RequestProcessingOutcome::hijacked; + } + } + else + { + Vajra::transport::PlainConnection connection(client.fd); + const Vajra::request::RequestProcessingOutcome outcome = request_processor_.handle( + connection, + client.socket_context, + [this, token = client.token](bool active) + { set_active_client_request_state(token, active); }); + hijacked = outcome == Vajra::request::RequestProcessingOutcome::hijacked; + } } catch (const std::exception &error) { @@ -680,10 +735,10 @@ void Vajra::Server::handle_pending_client(PendingClient client) { log_handler_thread_failure(client.socket_context, client.fd, "unknown exception"); } - unregister_active_client_fd(client.fd, client.token); - if (!hijacked && client.fd >= 0) + const bool should_close = unregister_active_client_fd(client.fd, client.token); + if (should_close && !hijacked && platform::socket_valid(client.fd)) { - close(client.fd); + platform::close_socket(client.fd); } Vajra::runtime::note_worker_connection_closed(); } @@ -699,7 +754,7 @@ void Vajra::Server::start() log_booting_event(process_role_, runtime_mode_, worker_processes_, request_execution_role_, debug_logging_); listener::SocketBinding binding{server_fd_.load(), port_}; - if (binding.fd < 0) + if (!platform::socket_valid(binding.fd)) { try { @@ -714,13 +769,17 @@ void Vajra::Server::start() port_ = binding.port; server_fd_.store(binding.fd); - if (!lifecycle_.mark_listening(binding.fd, binding.port) || server_fd_.load() < 0) + if (!lifecycle_.mark_listening(binding.fd, binding.port) || !platform::socket_valid(server_fd_.load())) { close_listener_fd(false); lifecycle_.finish_stop(); return; } lifecycle_.mark_boot_ready(); + if (boot_ready_callback_) + { + boot_ready_callback_(port_); + } log_listening_banner(host_, port_); try @@ -746,45 +805,13 @@ void Vajra::Server::start() break; } - const int listener_fd = server_fd_.load(); - if (listener_fd < 0) + const platform::SocketHandle listener_fd = server_fd_.load(); + if (!platform::socket_valid(listener_fd)) { break; } - pollfd listener_descriptor{listener_fd, POLLIN, 0}; - const int poll_result = poll(&listener_descriptor, 1, kHandlerReapPollTimeoutMilliseconds); - if (poll_result == 0) - { - reap_completed_handler_threads(); - continue; - } - if (poll_result < 0) - { - if (errno == EINTR) - { - continue; - } - - log_poll_failed(std::strerror(errno)); - continue; - } - if ((listener_descriptor.revents & (POLLERR | POLLHUP | POLLNVAL)) != 0) - { - const lifecycle::Snapshot error_snapshot = lifecycle_.snapshot(); - if (error_snapshot.state == lifecycle::State::draining || - error_snapshot.state == lifecycle::State::failed || - server_fd_.load() < 0 || - VajraNative::shutdown_requested()) - { - break; - } - - log_poll_listener_event(listener_descriptor.revents); - lifecycle_.mark_failed(lifecycle::StopReason::listener_failure); - break; - } - if ((listener_descriptor.revents & POLLIN) == 0) + if (!platform::wait_socket(listener_fd, platform::WaitEvent::read, kHandlerReapPollTimeoutMilliseconds)) { reap_completed_handler_threads(); continue; @@ -793,8 +820,11 @@ void Vajra::Server::start() sockaddr_in client_addr{}; socklen_t client_len = sizeof(client_addr); - const int client_fd = accept_client_cloexec(listener_fd, reinterpret_cast(&client_addr), &client_len); - if (client_fd < 0) + const platform::SocketHandle client_fd = accept_client_cloexec( + listener_fd, + reinterpret_cast(&client_addr), + &client_len); + if (!platform::socket_valid(client_fd)) { if (lifecycle_.snapshot().state == lifecycle::State::draining || VajraNative::shutdown_requested()) { @@ -810,12 +840,17 @@ void Vajra::Server::start() break; } - if (errno == EINTR) + const int error_number = platform::socket_last_error(); + if (listener_fd != server_fd_.load()) + { + break; + } + if (platform::socket_error_interrupted(error_number) || platform::socket_error_would_block(error_number)) { continue; } - log_accept_failed(std::strerror(errno)); + log_accept_failed(platform::socket_error_message(error_number).c_str()); continue; } @@ -831,7 +866,7 @@ void Vajra::Server::start() { active_connection_count_.fetch_sub(1, std::memory_order_acq_rel); log_connection_rejected(max_connections_); - close(client_fd); + platform::close_socket(client_fd); continue; } Vajra::response::ResponseWriter::prepare_client_socket(client_fd); @@ -845,7 +880,7 @@ void Vajra::Server::start() { active_connection_count_.fetch_sub(1, std::memory_order_acq_rel); log_active_client_tracking_failed(error.what()); - close(client_fd); + platform::close_socket(client_fd); continue; } @@ -859,8 +894,11 @@ void Vajra::Server::start() catch (...) { active_connection_count_.fetch_sub(1, std::memory_order_acq_rel); - unregister_active_client_fd(client_fd, client_token); - close(client_fd); + const bool should_close = unregister_active_client_fd(client_fd, client_token); + if (should_close) + { + platform::close_socket(client_fd); + } throw; } } @@ -871,7 +909,7 @@ void Vajra::Server::start() const lifecycle::Snapshot snapshot = lifecycle_.snapshot(); if (snapshot.state == lifecycle::State::draining || snapshot.state == lifecycle::State::failed) { - interrupt_active_client_sockets(); + interrupt_active_client_sockets(true); } join_handler_threads(); lifecycle_.finish_stop(); @@ -895,10 +933,95 @@ void Vajra::Server::stop() shutdown_begin_callback_(); } lifecycle_.request_stop(lifecycle::StopReason::programmatic_stop); +#ifndef _WIN32 close_listener_fd(true); +#endif interrupt_active_client_sockets(); } +void Vajra::Server::start_dispatch_worker() +{ + if (!lifecycle_.begin_startup()) + { + return; + } + log_booting_event(process_role_, runtime_mode_, worker_processes_, request_execution_role_, debug_logging_); + if (!lifecycle_.mark_dispatch_ready(port_)) + { + lifecycle_.finish_stop(); + return; + } + start_handler_threads(); +} + +bool Vajra::Server::dispatch_client(platform::SocketHandle client_fd) +{ + if (!platform::socket_valid(client_fd)) + { + return false; + } + const lifecycle::Snapshot snapshot = lifecycle_.snapshot(); + if (snapshot.state == lifecycle::State::draining || snapshot.state == lifecycle::State::failed || + snapshot.state == lifecycle::State::stopped) + { + platform::close_socket(client_fd); + return false; + } + if (snapshot.state == lifecycle::State::listening) + { + lifecycle_.mark_serving(); + } + + sockaddr_in peer_address{}; + socklen_t peer_address_length = sizeof(peer_address); + if (!platform::peer_name(client_fd, reinterpret_cast(&peer_address), &peer_address_length)) + { + peer_address.sin_family = AF_INET; + peer_address.sin_addr.s_addr = htonl(INADDR_ANY); + peer_address.sin_port = 0; + } + + const std::size_t previous_active_connections = active_connection_count_.fetch_add(1, std::memory_order_acq_rel); + if (previous_active_connections >= max_connections_) + { + active_connection_count_.fetch_sub(1, std::memory_order_acq_rel); + log_connection_rejected(max_connections_); + platform::close_socket(client_fd); + return false; + } + Vajra::response::ResponseWriter::prepare_client_socket(client_fd); + + std::uint64_t client_token = 0; + try + { + client_token = register_active_client_fd(client_fd); + enqueue_pending_client(PendingClient{ + client_fd, + socket_context_for(client_fd, peer_address, port_), + client_token}); + Vajra::runtime::note_worker_dispatch_received(); + return true; + } + catch (const std::exception &error) + { + active_connection_count_.fetch_sub(1, std::memory_order_acq_rel); + if (client_token != 0) + { + (void)unregister_active_client_fd(client_fd, client_token); + } + log_active_client_tracking_failed(error.what()); + platform::close_socket(client_fd); + return false; + } +} + +void Vajra::Server::finish_dispatch_worker() +{ + stop(); + join_handler_threads(); + lifecycle_.finish_stop(); +} + Vajra::lifecycle::Snapshot Vajra::Server::lifecycle_snapshot() const { return lifecycle_.snapshot(); diff --git a/gems/vajra/ext/vajra/server.hpp b/gems/vajra/ext/vajra/server.hpp index 0e1762c..6b86369 100644 --- a/gems/vajra/ext/vajra/server.hpp +++ b/gems/vajra/ext/vajra/server.hpp @@ -23,6 +23,7 @@ #include "listener/listener_socket.hpp" #include "request/request_head_error.hpp" #include "request/request_processor.hpp" +#include "transport/tls_connection.hpp" namespace Vajra { @@ -39,26 +40,36 @@ namespace Vajra int worker_processes = 0, std::string request_execution_role = "single_process_bootstrap", bool debug_logging = false, - int inherited_listener_fd = -1, + platform::SocketHandle inherited_listener_fd = platform::kInvalidSocket, int request_head_timeout_seconds = 5, int first_data_timeout_seconds = 30, int request_body_timeout_seconds = request::kDefaultRequestBodyTimeoutSeconds, int persistent_timeout_seconds = 30, std::size_t max_connections = 256, std::function shutdown_begin_callback = {}, - std::size_t max_request_body_bytes = request::kDefaultMaxRequestBodyBytes); + std::size_t max_request_body_bytes = request::kDefaultMaxRequestBodyBytes, + std::size_t max_keepalive_requests = 0, + std::size_t http2_execution_threads = 4, + bool http2_enabled = false, + request::Http2Config http2_config = {}, + std::shared_ptr tls_context = nullptr, + std::function boot_ready_callback = {}); ~Server(); void start(); void stop(); + void start_dispatch_worker(); + bool dispatch_client(platform::SocketHandle client_fd); + void finish_dispatch_worker(); lifecycle::Snapshot lifecycle_snapshot() const; void set_lifecycle_observer(lifecycle::Controller::Observer observer); private: struct ActiveClientRegistration { - int original_fd; + platform::SocketHandle original_fd; bool open; + bool request_active; std::size_t descriptor_count; }; @@ -69,16 +80,19 @@ namespace Vajra struct PendingClient { - int fd; + platform::SocketHandle fd; request::SocketContext socket_context; std::uint64_t token; }; std::string host_; int port_; - std::atomic server_fd_; + std::atomic server_fd_; listener::Socket listener_socket_; request::RequestProcessor request_processor_; + std::shared_ptr request_executor_; + request::Http2Config http2_config_; + std::shared_ptr tls_context_; lifecycle::Controller lifecycle_; std::string process_role_; std::string runtime_mode_; @@ -90,6 +104,7 @@ namespace Vajra std::atomic active_connection_count_{0}; std::size_t active_tracked_client_descriptors_ = 0; std::function shutdown_begin_callback_; + std::function boot_ready_callback_; std::mutex handler_threads_mutex_; std::vector handler_threads_; std::mutex connection_queue_mutex_; @@ -107,9 +122,10 @@ namespace Vajra void enqueue_pending_client(PendingClient client); void run_handler_thread(); void handle_pending_client(PendingClient client); - std::uint64_t register_active_client_fd(int client_fd); - void unregister_active_client_fd(int client_fd, std::uint64_t client_token); - void interrupt_active_client_sockets() noexcept; + std::uint64_t register_active_client_fd(platform::SocketHandle client_fd); + bool unregister_active_client_fd(platform::SocketHandle client_fd, std::uint64_t client_token); + void set_active_client_request_state(std::uint64_t client_token, bool request_active); + void interrupt_active_client_sockets(bool include_active_requests = false) noexcept; }; } diff --git a/gems/vajra/ext/vajra/transport/connection.cpp b/gems/vajra/ext/vajra/transport/connection.cpp index 181fc2d..8f53b89 100644 --- a/gems/vajra/ext/vajra/transport/connection.cpp +++ b/gems/vajra/ext/vajra/transport/connection.cpp @@ -5,55 +5,29 @@ #include "connection.hpp" -#include -#include -#include -#include - -Vajra::transport::PlainConnection::PlainConnection(int client_fd) : client_fd_(client_fd) +Vajra::transport::PlainConnection::PlainConnection(platform::SocketHandle client_fd) : client_fd_(client_fd) { } -int Vajra::transport::PlainConnection::fd() const +Vajra::platform::SocketHandle Vajra::transport::PlainConnection::fd() const { return client_fd_; } bool Vajra::transport::PlainConnection::wait_readable(int timeout_seconds) { - pollfd descriptor{client_fd_, POLLIN | POLLHUP | POLLERR, 0}; const int timeout_milliseconds = timeout_seconds <= 0 ? 0 : timeout_seconds * 1000; - - for (;;) - { - const int poll_result = poll(&descriptor, 1, timeout_milliseconds); - if (poll_result > 0) - { - return (descriptor.revents & (POLLIN | POLLHUP | POLLERR)) != 0; - } - if (poll_result == 0) - { - return false; - } - if (errno != EINTR) - { - return false; - } - } + return platform::wait_socket(client_fd_, platform::WaitEvent::read, timeout_milliseconds); } -ssize_t Vajra::transport::PlainConnection::read(char *buffer, std::size_t length) +Vajra::platform::SignedSize Vajra::transport::PlainConnection::read(char *buffer, std::size_t length) { - return recv(client_fd_, buffer, length, 0); + return platform::receive_socket(client_fd_, buffer, length); } -ssize_t Vajra::transport::PlainConnection::write(const char *buffer, std::size_t length) +Vajra::platform::SignedSize Vajra::transport::PlainConnection::write(const char *buffer, std::size_t length) { -#ifdef MSG_NOSIGNAL - return send(client_fd_, buffer, length, MSG_NOSIGNAL); -#else - return send(client_fd_, buffer, length, 0); -#endif + return platform::send_socket(client_fd_, buffer, length); } std::string Vajra::transport::PlainConnection::protocol() const diff --git a/gems/vajra/ext/vajra/transport/connection.hpp b/gems/vajra/ext/vajra/transport/connection.hpp index a37b9b0..a04f396 100644 --- a/gems/vajra/ext/vajra/transport/connection.hpp +++ b/gems/vajra/ext/vajra/transport/connection.hpp @@ -6,6 +6,8 @@ #ifndef VAJRA_TRANSPORT_CONNECTION_HPP #define VAJRA_TRANSPORT_CONNECTION_HPP +#include "platform/socket.hpp" + #include #include @@ -18,10 +20,10 @@ namespace Vajra public: virtual ~Connection() = default; - virtual int fd() const = 0; + virtual platform::SocketHandle fd() const = 0; virtual bool wait_readable(int timeout_seconds) = 0; - virtual ssize_t read(char *buffer, std::size_t length) = 0; - virtual ssize_t write(const char *buffer, std::size_t length) = 0; + virtual platform::SignedSize read(char *buffer, std::size_t length) = 0; + virtual platform::SignedSize write(const char *buffer, std::size_t length) = 0; virtual std::string protocol() const = 0; virtual bool tls() const = 0; }; @@ -29,17 +31,17 @@ namespace Vajra class PlainConnection final : public Connection { public: - explicit PlainConnection(int client_fd); + explicit PlainConnection(platform::SocketHandle client_fd); - int fd() const override; + platform::SocketHandle fd() const override; bool wait_readable(int timeout_seconds) override; - ssize_t read(char *buffer, std::size_t length) override; - ssize_t write(const char *buffer, std::size_t length) override; + platform::SignedSize read(char *buffer, std::size_t length) override; + platform::SignedSize write(const char *buffer, std::size_t length) override; std::string protocol() const override; bool tls() const override; private: - int client_fd_; + platform::SocketHandle client_fd_; }; } } diff --git a/gems/vajra/ext/vajra/transport/tls_connection.cpp b/gems/vajra/ext/vajra/transport/tls_connection.cpp index 55679d6..dd890c1 100644 --- a/gems/vajra/ext/vajra/transport/tls_connection.cpp +++ b/gems/vajra/ext/vajra/transport/tls_connection.cpp @@ -9,12 +9,118 @@ #include #include #include -#include #include #include namespace { +#ifdef _WIN32 + struct SocketBioState + { + Vajra::platform::SocketHandle socket; + }; + + int socket_bio_create(BIO *bio) + { + BIO_set_init(bio, 0); + BIO_set_data(bio, nullptr); + BIO_set_shutdown(bio, 0); + return 1; + } + + int socket_bio_destroy(BIO *bio) + { + if (bio == nullptr) + { + return 0; + } + delete static_cast(BIO_get_data(bio)); + BIO_set_data(bio, nullptr); + BIO_set_init(bio, 0); + return 1; + } + + int socket_bio_read(BIO *bio, char *buffer, int length) + { + BIO_clear_retry_flags(bio); + if (buffer == nullptr || length <= 0) + { + return 0; + } + const auto *state = static_cast(BIO_get_data(bio)); + const auto result = Vajra::platform::receive_socket(state->socket, buffer, static_cast(length)); + if (result < 0) + { + const int error_number = errno; + if (Vajra::platform::socket_error_interrupted(error_number) || + Vajra::platform::socket_error_would_block(error_number)) + { + BIO_set_retry_read(bio); + } + } + return static_cast(result); + } + + int socket_bio_write(BIO *bio, const char *buffer, int length) + { + BIO_clear_retry_flags(bio); + if (buffer == nullptr || length <= 0) + { + return 0; + } + const auto *state = static_cast(BIO_get_data(bio)); + const auto result = Vajra::platform::send_socket(state->socket, buffer, static_cast(length)); + if (result < 0) + { + const int error_number = errno; + if (Vajra::platform::socket_error_interrupted(error_number) || + Vajra::platform::socket_error_would_block(error_number)) + { + BIO_set_retry_write(bio); + } + } + return static_cast(result); + } + + long socket_bio_ctrl(BIO *bio, int command, long value, void *) + { + switch (command) + { + case BIO_CTRL_FLUSH: + return 1; + case BIO_CTRL_GET_CLOSE: + return BIO_get_shutdown(bio); + case BIO_CTRL_SET_CLOSE: + BIO_set_shutdown(bio, static_cast(value)); + return 1; + case BIO_CTRL_PENDING: + case BIO_CTRL_WPENDING: + return 0; + default: + return 0; + } + } + + BIO_METHOD *socket_bio_method() + { + static BIO_METHOD *method = []() + { + BIO_METHOD *value = BIO_meth_new(BIO_get_new_index() | BIO_TYPE_SOURCE_SINK, "Vajra Windows socket"); + if (value == nullptr || BIO_meth_set_create(value, socket_bio_create) != 1 || + BIO_meth_set_destroy(value, socket_bio_destroy) != 1 || + BIO_meth_set_read(value, socket_bio_read) != 1 || + BIO_meth_set_write(value, socket_bio_write) != 1 || + BIO_meth_set_ctrl(value, socket_bio_ctrl) != 1) + { + BIO_meth_free(value); + throw std::runtime_error("unable to create Windows socket BIO method"); + } + return value; + }(); + return method; + } +#endif + std::string openssl_error_string() { const unsigned long error = ERR_get_error(); @@ -86,6 +192,39 @@ namespace } } +#ifdef _WIN32 +BIO *Vajra::transport::new_socket_bio(platform::SocketHandle socket) +{ + if (!platform::socket_valid(socket)) + { + throw std::invalid_argument("cannot attach an invalid socket to OpenSSL"); + } + BIO *bio = BIO_new(socket_bio_method()); + if (bio == nullptr) + { + throw std::runtime_error("unable to allocate Windows socket BIO: " + openssl_error_string()); + } + try + { + BIO_set_data(bio, new SocketBioState{socket}); + BIO_set_init(bio, 1); + BIO_set_shutdown(bio, 0); + return bio; + } + catch (...) + { + BIO_free(bio); + throw; + } +} + +Vajra::platform::SocketHandle Vajra::transport::socket_bio_handle(BIO *bio) +{ + const auto *state = bio == nullptr ? nullptr : static_cast(BIO_get_data(bio)); + return state == nullptr ? platform::kInvalidSocket : state->socket; +} +#endif + void Vajra::transport::SslContextDeleter::operator()(SSL_CTX *context) const { SSL_CTX_free(context); @@ -156,7 +295,7 @@ int Vajra::transport::TlsContext::write_timeout_seconds() const return write_timeout_seconds_; } -Vajra::transport::TlsConnection::TlsConnection(int client_fd, const TlsContext &context) +Vajra::transport::TlsConnection::TlsConnection(platform::SocketHandle client_fd, const TlsContext &context) : client_fd_(client_fd), ssl_(SSL_new(context.get())), handshake_timeout_seconds_(context.handshake_timeout_seconds()), @@ -167,10 +306,15 @@ Vajra::transport::TlsConnection::TlsConnection(int client_fd, const TlsContext & { throw std::runtime_error("unable to create TLS connection: " + openssl_error_string()); } - if (SSL_set_fd(ssl_.get(), client_fd_) != 1) +#ifdef _WIN32 + BIO *bio = new_socket_bio(client_fd_); + SSL_set_bio(ssl_.get(), bio, bio); +#else + if (SSL_set_fd(ssl_.get(), platform::openssl_socket_descriptor(client_fd_)) != 1) { throw std::runtime_error("unable to attach TLS connection to socket: " + openssl_error_string()); } +#endif } Vajra::transport::TlsConnection::~TlsConnection() @@ -216,7 +360,7 @@ void Vajra::transport::TlsConnection::handshake() } } -int Vajra::transport::TlsConnection::fd() const +Vajra::platform::SocketHandle Vajra::transport::TlsConnection::fd() const { return client_fd_; } @@ -227,10 +371,10 @@ bool Vajra::transport::TlsConnection::wait_readable(int timeout_seconds) { return true; } - return wait_for_events(POLLIN | POLLHUP | POLLERR, timeout_seconds); + return wait_for_event(platform::WaitEvent::read, timeout_seconds); } -ssize_t Vajra::transport::TlsConnection::read(char *buffer, std::size_t length) +Vajra::platform::SignedSize Vajra::transport::TlsConnection::read(char *buffer, std::size_t length) { if (ssl_ == nullptr) { @@ -259,7 +403,7 @@ ssize_t Vajra::transport::TlsConnection::read(char *buffer, std::size_t length) } } -ssize_t Vajra::transport::TlsConnection::write(const char *buffer, std::size_t length) +Vajra::platform::SignedSize Vajra::transport::TlsConnection::write(const char *buffer, std::size_t length) { if (ssl_ == nullptr) { @@ -310,31 +454,17 @@ int Vajra::transport::TlsConnection::write_timeout_seconds() const return write_timeout_seconds_; } -bool Vajra::transport::TlsConnection::wait_for_events(short events, int timeout_seconds) +bool Vajra::transport::TlsConnection::wait_for_event(platform::WaitEvent event, int timeout_seconds) { const int timeout_milliseconds = timeout_seconds <= 0 ? 0 : timeout_seconds * 1000; - return wait_for_events_milliseconds(events, timeout_milliseconds); + return wait_for_event_milliseconds(event, timeout_milliseconds); } -bool Vajra::transport::TlsConnection::wait_for_events_milliseconds(short events, int timeout_milliseconds) +bool Vajra::transport::TlsConnection::wait_for_event_milliseconds( + platform::WaitEvent event, + int timeout_milliseconds) { - pollfd descriptor{client_fd_, events, 0}; - for (;;) - { - const int poll_result = poll(&descriptor, 1, timeout_milliseconds); - if (poll_result > 0) - { - return (descriptor.revents & events) != 0; - } - if (poll_result == 0) - { - return false; - } - if (errno != EINTR) - { - return false; - } - } + return platform::wait_socket(client_fd_, event, timeout_milliseconds); } bool Vajra::transport::TlsConnection::wait_for_ssl_error(int ssl_error, int timeout_seconds) @@ -347,11 +477,11 @@ bool Vajra::transport::TlsConnection::wait_for_ssl_error_milliseconds(int ssl_er { if (ssl_error == SSL_ERROR_WANT_READ) { - return wait_for_events_milliseconds(POLLIN | POLLHUP | POLLERR, timeout_milliseconds); + return platform::wait_socket(client_fd_, platform::WaitEvent::read, timeout_milliseconds); } if (ssl_error == SSL_ERROR_WANT_WRITE) { - return wait_for_events_milliseconds(POLLOUT | POLLHUP | POLLERR, timeout_milliseconds); + return platform::wait_socket(client_fd_, platform::WaitEvent::write, timeout_milliseconds); } return false; } diff --git a/gems/vajra/ext/vajra/transport/tls_connection.hpp b/gems/vajra/ext/vajra/transport/tls_connection.hpp index d7e8598..e53e3aa 100644 --- a/gems/vajra/ext/vajra/transport/tls_connection.hpp +++ b/gems/vajra/ext/vajra/transport/tls_connection.hpp @@ -14,10 +14,19 @@ #include #include +#ifdef _WIN32 +#include +#endif + namespace Vajra { namespace transport { +#ifdef _WIN32 + BIO *new_socket_bio(platform::SocketHandle socket); + platform::SocketHandle socket_bio_handle(BIO *bio); +#endif + struct TlsConfig { std::string certificate; @@ -66,17 +75,17 @@ namespace Vajra class TlsConnection final : public Connection { public: - TlsConnection(int client_fd, const TlsContext &context); + TlsConnection(platform::SocketHandle client_fd, const TlsContext &context); ~TlsConnection() override; TlsConnection(const TlsConnection &) = delete; TlsConnection &operator=(const TlsConnection &) = delete; void handshake(); - int fd() const override; + platform::SocketHandle fd() const override; bool wait_readable(int timeout_seconds) override; - ssize_t read(char *buffer, std::size_t length) override; - ssize_t write(const char *buffer, std::size_t length) override; + platform::SignedSize read(char *buffer, std::size_t length) override; + platform::SignedSize write(const char *buffer, std::size_t length) override; std::string protocol() const override; bool tls() const override; std::unique_ptr release_ssl(); @@ -84,13 +93,13 @@ namespace Vajra int write_timeout_seconds() const; private: - bool wait_for_events(short events, int timeout_seconds); - bool wait_for_events_milliseconds(short events, int timeout_milliseconds); + bool wait_for_event(platform::WaitEvent event, int timeout_seconds); + bool wait_for_event_milliseconds(platform::WaitEvent event, int timeout_milliseconds); bool wait_for_ssl_error(int ssl_error, int timeout_seconds); bool wait_for_ssl_error_milliseconds(int ssl_error, int timeout_milliseconds); [[noreturn]] void raise_ssl_error(const char *operation, int ssl_result) const; - int client_fd_; + platform::SocketHandle client_fd_; std::unique_ptr ssl_; std::string negotiated_protocol_ = "http/1.1"; bool handshake_complete_ = false; diff --git a/gems/vajra/ext/vajra/vendor/nghttp2/UPSTREAM.md b/gems/vajra/ext/vajra/vendor/nghttp2/UPSTREAM.md index 5f12452..f8dc362 100644 --- a/gems/vajra/ext/vajra/vendor/nghttp2/UPSTREAM.md +++ b/gems/vajra/ext/vajra/vendor/nghttp2/UPSTREAM.md @@ -4,10 +4,7 @@ Vajra vendors nghttp2 release `v1.69.0` from under the upstream MIT license recorded in this directory. -The scheduled `nghttp2 version` workflow runs -`scripts/check-nghttp2-version` and fails when this source tree no longer -matches the latest upstream release. When updating the vendored source, update -the version and release link in this file in the same change, then run: +The scheduled `nghttp2 version` workflow runs `scripts/check-nghttp2-version` and fails when this source tree no longer matches the latest upstream release. When updating the vendored source, update the version and release link in this file in the same change, then run: ```bash new_version=v1.70.0 diff --git a/gems/vajra/lib/vajra.rb b/gems/vajra/lib/vajra.rb index 33b81fa..7f00ad4 100644 --- a/gems/vajra/lib/vajra.rb +++ b/gems/vajra/lib/vajra.rb @@ -10,6 +10,7 @@ require_relative 'vajra/internal/rack_execution' require_relative 'vajra/internal/tracing' require 'rbconfig' +require 'json' # Ruby entrypoint for booting the native Vajra HTTP listener. module Vajra @@ -23,14 +24,110 @@ class Error < StandardError; end module NativeExtension module_function + def package_platform + 'x64-mingw-ucrt' + end + + def ensure_supported_windows_abi! + return true unless Gem.win_platform? + return true if RUBY_PLATFORM.include?('mingw') + + raise LoadError, <<~MESSAGE + Vajra does not support MSVC-built Ruby on Windows. + Install a RubyInstaller UCRT Ruby with platform x64-mingw-ucrt. + MESSAGE + end + + def native_packages_root + File.expand_path('vajra/native', __dir__) + end + + def packaged_native_metadata_path + paths = Dir.glob(File.join(native_packages_root, '*', '*', 'native_abi.json')) + return nil if paths.empty? + + current_ruby_api = RbConfig::CONFIG.fetch('ruby_version') + paths_with_ruby_apis = paths.map { |path| [path, File.basename(File.dirname(path))] } + matching_paths = paths_with_ruby_apis.filter_map { |path, ruby_api| path if ruby_api == current_ruby_api } + if matching_paths.empty? + packaged_apis = paths_with_ruby_apis.map(&:last).uniq.sort + raise LoadError, <<~MESSAGE + Vajra native extension ABI mismatch. + Package Ruby APIs: #{packaged_apis.join(', ')}. + Runtime Ruby API: #{current_ruby_api}. + Install or build the Vajra gem for this Ruby API version. + MESSAGE + end + + if matching_paths.length > 1 + raise LoadError, + "Invalid Vajra native package: multiple ABI metadata files found for Ruby #{current_ruby_api}: #{matching_paths.join(', ')}" + end + + matching_paths.fetch(0) + end + + def packaged_native_root + metadata_path = packaged_native_metadata_path + return File.dirname(metadata_path) if metadata_path + + File.join(native_packages_root, package_platform, RbConfig::CONFIG.fetch('ruby_version')) + end + + def validate_abi!(metadata_path: packaged_native_metadata_path) + return true unless metadata_path + raise LoadError, "Invalid Vajra native package: ABI metadata is missing at #{metadata_path}" unless File.file?(metadata_path) + + metadata = JSON.parse(File.read(metadata_path)) + expected_platform = package_platform + actual_platform = metadata.fetch('platform') + actual_ruby_api = metadata.fetch('ruby_api_version') + current_ruby_api = RbConfig::CONFIG.fetch('ruby_version') + actual_architecture = metadata.fetch('architecture') + actual_compiler_family = metadata.fetch('compiler_family') + actual_runtime_abi = metadata.fetch('runtime_abi') + expected_compiler_family = 'mingw' + expected_runtime_abi = 'ucrt' + current_architecture = RbConfig::CONFIG.fetch('arch') + actual_abi = [actual_platform, actual_ruby_api, actual_architecture, actual_compiler_family, actual_runtime_abi] + expected_abi = [ + expected_platform, + current_ruby_api, + current_architecture, + expected_compiler_family, + expected_runtime_abi + ] + return true if Gem.win_platform? && actual_abi == expected_abi + + raise LoadError, <<~MESSAGE + Vajra native extension ABI mismatch. + Package ABI: #{actual_platform} / #{actual_architecture} / #{actual_compiler_family} / #{actual_runtime_abi} / Ruby #{actual_ruby_api}. + Runtime ABI: #{RUBY_PLATFORM} / #{current_architecture} / #{expected_compiler_family} / #{expected_runtime_abi} / Ruby #{current_ruby_api}. + Install the Vajra gem built for this exact Windows Ruby ABI. + MESSAGE + rescue JSON::ParserError, KeyError => e + raise LoadError, "Invalid Vajra native ABI metadata: #{e.message}" + end + def load!( loader: method(:require), - extension_path: File.expand_path("vajra/vajra.#{RbConfig::CONFIG.fetch('DLEXT')}", __dir__) + extension_path: nil ) + ensure_supported_windows_abi! + validate_abi! + extension_suffix = RbConfig::CONFIG.fetch('DLEXT') + packaged_extension = File.join(packaged_native_root, "vajra.#{extension_suffix}") + extension_path ||= if File.file?(packaged_extension) + packaged_extension + else + File.expand_path("vajra/vajra.#{extension_suffix}", __dir__) + end !!loader.call(extension_path) rescue LoadError => e raise LoadError, <<~MESSAGE, e.backtrace Unable to load the Vajra native extension. + Ruby ABI: #{RUBY_PLATFORM} (#{RbConfig::CONFIG.fetch('CC', 'unknown compiler')}). + On Windows, Vajra requires RubyInstaller UCRT Ruby (x64-mingw-ucrt). Run `bundle exec rake compile` from the `gems/vajra/` package directory and retry. Original error: #{e.message} MESSAGE diff --git a/gems/vajra/lib/vajra/internal/rack_execution.rb b/gems/vajra/lib/vajra/internal/rack_execution.rb index 6712707..64fa60a 100644 --- a/gems/vajra/lib/vajra/internal/rack_execution.rb +++ b/gems/vajra/lib/vajra/internal/rack_execution.rb @@ -40,10 +40,23 @@ def uninstall! APP_STATE.app = nil APP_STATE.max_threads = 1 end + # This file is loaded before the native extension so ABI/load failures + # can still produce a useful diagnostic. Its at-exit cleanup must also + # be safe in that partially initialized state. + return unless native_extension_loaded? + __native_set_app__(nil) __native_set_callback__(nil) end + def native_extension_loaded? + method(:__native_set_app__) + true + rescue NameError + false + end + private_class_method :native_extension_loaded? + def configure_threads!(max_threads) APP_MUTEX.synchronize { APP_STATE.max_threads = max_threads } __native_configure_threads__(max_threads) diff --git a/gems/vajra/lib/vajra/internal/tracing.rb b/gems/vajra/lib/vajra/internal/tracing.rb index 01db9ba..697bfb8 100644 --- a/gems/vajra/lib/vajra/internal/tracing.rb +++ b/gems/vajra/lib/vajra/internal/tracing.rb @@ -163,6 +163,12 @@ def after_fork! start_request_observability_drain_thread if enabled && output_available end + def before_worker_exit! + loop do + break if drain_request_observability_batch.zero? + end + end + def with_request_span(env, &) return yield unless request_span_observability_active? @@ -363,10 +369,18 @@ def native_tracing_configured?(config) return false if sampler_always_off?(config) return false unless traces_exporter_enabled?(config.traces_exporter, 'otlp') - config.endpoint.start_with?('http://') + native_https_endpoint?(config.endpoint) end private_class_method :native_tracing_configured? + def native_https_endpoint?(endpoint) + uri = URI.parse(endpoint) + uri.is_a?(URI::HTTPS) && !uri.hostname.to_s.empty? + rescue URI::InvalidURIError + false + end + private_class_method :native_https_endpoint? + def traces_exporter_enabled?(exporters, expected) exporters.to_s.split(',').map { |exporter| exporter.strip.downcase }.include?(expected) end @@ -922,12 +936,11 @@ def start_request_observability_drain_thread return unless respond_to?(:__native_drain_request_observability_events__) stop_request_observability_drain_thread - # rubocop:disable ThreadSafety/NewThread - thread = Thread.new { request_observability_drain_loop } - # rubocop:enable ThreadSafety/NewThread TRACE_MUTEX.synchronize do TRACE_STATE.request_observability_stop = false - TRACE_STATE.request_observability_thread = thread + # rubocop:disable ThreadSafety/NewThread + TRACE_STATE.request_observability_thread = Thread.new { request_observability_drain_loop } + # rubocop:enable ThreadSafety/NewThread end end private_class_method :start_request_observability_drain_thread diff --git a/gems/vajra/performance/Gemfile.lock b/gems/vajra/performance/Gemfile.lock index bc228c6..938b41e 100644 --- a/gems/vajra/performance/Gemfile.lock +++ b/gems/vajra/performance/Gemfile.lock @@ -121,7 +121,7 @@ GEM fiber-annotation fiber-local (~> 1.1) json - crass (1.0.6) + crass (1.0.7) csv (3.3.5) date (3.5.1) drb (2.2.3) @@ -189,6 +189,9 @@ GEM google-protobuf (4.35.1-arm64-darwin) bigdecimal rake (~> 13.3) + google-protobuf (4.35.1-x64-mingw-ucrt) + bigdecimal + rake (~> 13.3) google-protobuf (4.35.1-x86_64-darwin) bigdecimal rake (~> 13.3) @@ -254,7 +257,7 @@ GEM localhost (1.8.0) bake logger (1.7.0) - loofah (2.25.1) + loofah (2.25.2) crass (~> 1.0.2) nokogiri (>= 1.12.0) mail (2.9.0) @@ -293,6 +296,8 @@ GEM racc (~> 1.4) nokogiri (1.19.4-arm64-darwin) racc (~> 1.4) + nokogiri (1.19.4-x64-mingw-ucrt) + racc (~> 1.4) nokogiri (1.19.4-x86_64-darwin) racc (~> 1.4) nokogiri (1.19.4-x86_64-linux-gnu) @@ -378,8 +383,8 @@ GEM activesupport (>= 5.0.0) minitest nokogiri (>= 1.6) - rails-html-sanitizer (1.7.0) - loofah (~> 2.25) + rails-html-sanitizer (1.7.1) + loofah (~> 2.25, >= 2.25.2) nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0) railties (8.1.3) actionpack (= 8.1.3) @@ -432,6 +437,7 @@ PLATFORMS arm-linux-gnu arm-linux-musl arm64-darwin + x64-mingw-ucrt x86_64-darwin x86_64-linux-gnu x86_64-linux-musl diff --git a/gems/vajra/performance/README.md b/gems/vajra/performance/README.md index dbe8d6f..af9eb82 100644 --- a/gems/vajra/performance/README.md +++ b/gems/vajra/performance/README.md @@ -2,9 +2,7 @@ Small local benchmark project for comparing Vajra against peer Rack servers. -It generates Rack, Rails, Roda, Sinatra, and Hanami fixtures under `tmp//`, -starts each fixture through each selected server, runs k6 against the already-running -server, and reports the standard performance profile: +It generates Rack, Rails, Roda, Sinatra, and Hanami fixtures under `tmp//`, starts each fixture through each selected server, runs k6 against the already-running server, and reports the standard performance profile: - requests per second - requests per CPU core @@ -20,9 +18,7 @@ cd gems/vajra/performance bundle exec rake performance:run ``` -`performance:run` runs all profiles: the full framework/server comparison, -the Vajra-only observability comparison, and the Vajra-only protocol comparison. -To run one profile directly: +`performance:run` runs all profiles: the full framework/server comparison, the Vajra-only observability comparison, and the Vajra-only protocol comparison. To run one profile directly: ```bash bundle exec rake performance:main @@ -44,13 +40,9 @@ Local knobs are ENV variables: SERVERS=vajra,puma WORKER_COUNT=1 THREAD_COUNT=4 bundle exec rake performance:run ``` -Access logging is off by default. Set `ACCESS_LOG=1` to enable fair access -logging for every selected server. +Access logging is off by default. Set `ACCESS_LOG=1` to enable fair access logging for every selected server. -Vajra observability comparisons run after the standard profile and only benchmark -Vajra on the Rack fixture. The observability section uses a shorter fixed run so -it reports every mode without multiplying the full framework/server matrix. -The observability modes are: +Vajra observability comparisons run after the standard profile and only benchmark Vajra on the Rack fixture. The observability section uses a shorter fixed run so it reports every mode without multiplying the full framework/server matrix. The observability modes are: - `off` - `access_text` @@ -62,9 +54,7 @@ The observability modes are: - `otel_otlp`: Vajra-owned native OTLP span export to the local drain - `otel_all_otlp`: `otel_otlp` plus structured JSON access logging -The protocol profile also runs after the standard profile and only benchmarks -Vajra on the Rack fixture. It uses the standard run duration, including -`SOAK_SECONDS` when set. The default protocol modes are: +The protocol profile also runs after the standard profile and only benchmarks Vajra on the Rack fixture. It uses the standard run duration, including `SOAK_SECONDS` when set. The default protocol modes are: - `vajra_http1`: plaintext HTTP/1.1 - `vajra_tls_http1`: HTTP/1.1 over TLS @@ -88,8 +78,7 @@ Vajra on the Rack fixture. It uses the standard run duration, including - `vajra_h2c_tunnel_backpressure`: cleartext HTTP/2 tunnel backpressure - `vajra_tls_http2_tunnel_backpressure`: TLS HTTP/2 tunnel backpressure -Set `SOAK_SECONDS=300` to run the same matrix as a practical local soak. The -default run duration is 20 seconds per server/fixture pair. +Set `SOAK_SECONDS=300` to run the same matrix as a practical local soak. The default run duration is 20 seconds per server/fixture pair. Each generated fixture uses the same request mix: @@ -107,10 +96,4 @@ Each generated fixture uses the same request mix: - `POST /stream-read` - `POST /line-read` -Generated apps, k6 scripts, summaries, and logs are written under -`tmp///`. Each server/fixture pair writes -`.run-summary.json`, and the whole run writes `summary.json` at the -timestamp root. Observability mode artifacts are written under -`tmp///observability//`. Protocol mode artifacts are -written under `tmp///protocol//`. The comparison -tables print to stdout. +Generated apps, k6 scripts, summaries, and logs are written under `tmp///`. Each server/fixture pair writes `.run-summary.json`, and the whole run writes `summary.json` at the timestamp root. Observability mode artifacts are written under `tmp///observability//`. Protocol mode artifacts are written under `tmp///protocol//`. The comparison tables print to stdout. diff --git a/gems/vajra/performance/Rakefile b/gems/vajra/performance/Rakefile index ec06204..05e57f2 100644 --- a/gems/vajra/performance/Rakefile +++ b/gems/vajra/performance/Rakefile @@ -2,14 +2,25 @@ require 'fileutils' require 'etc' +require 'fiddle/import' if Gem.win_platform? require 'json' require 'net/http' require 'openssl' +require 'rbconfig' require 'socket' require 'time' require 'timeout' module Performance + if Gem.win_platform? + module WindowsConsoleControl + extend Fiddle::Importer + + dlload 'kernel32.dll' + extern 'int GenerateConsoleCtrlEvent(unsigned long, unsigned long)' + end + end + ROOT = File.expand_path(__dir__) TMP_ROOT = File.join(ROOT, 'tmp') DEFAULT_SERVERS = %w[vajra puma passenger falcon].freeze @@ -147,6 +158,8 @@ module Performance options.fetch(:servers).filter_map do |server| start_server(fixture, server, fixture_root, options) rescue StandardError => e + raise if server == 'vajra' + warn "[performance] #{fixture}/#{server}: skipped (#{e.message})" nil end @@ -158,7 +171,8 @@ module Performance raise 'missing server' unless available?(server, env, fixture_root) log_path = File.join(fixture_root, "#{server}.log") - pid = Process.spawn(env, *command_for(server, port, options), chdir: fixture_root, pgroup: true, out: log_path, err: log_path) + group_option = Gem.win_platform? ? { new_pgroup: true } : { pgroup: true } + pid = Process.spawn(env, *command_for(server, port, options), chdir: fixture_root, out: log_path, err: log_path, **group_option) scheme = observability_mode&.config&.fetch(:tls, false) ? 'https' : 'http' handle = Handle.new(fixture:, server:, pid:, url: "#{scheme}://127.0.0.1:#{port}", mode: observability_mode&.name) wait_until_ready(handle) @@ -1113,6 +1127,7 @@ module Performance requests_per_second:, requests: metric_value(metrics, 'http_reqs', 'count').to_i, error_rate: metric_value(metrics, 'http_req_failed', 'rate') * 100.0, + p50_ms: metric_value(metrics, 'http_req_duration', 'med'), p95_ms: metric_value(metrics, 'http_req_duration', 'p(95)'), p99_ms: metric_value(metrics, 'http_req_duration', 'p(99)'), route_metrics: route_metrics(metrics), @@ -1804,10 +1819,7 @@ module Performance sampler = { pgid: handle.pid, stop: false, samples: [] } sampler[:thread] = Thread.new do until sampler.fetch(:stop) - sampler.fetch(:samples) << { - at: Time.now.utc.iso8601, - bytes: process_group_rss_bytes(sampler.fetch(:pgid)) - } + sampler.fetch(:samples) << process_tree_sample(sampler.fetch(:pgid)) sleep RSS_SAMPLE_INTERVAL_SECONDS end end @@ -1819,10 +1831,7 @@ module Performance sampler[:stop] = true sampler.fetch(:thread).join - sampler.fetch(:samples) << { - at: Time.now.utc.iso8601, - bytes: process_group_rss_bytes(sampler.fetch(:pgid)) - } + sampler.fetch(:samples) << process_tree_sample(sampler.fetch(:pgid)) sampler rescue Errno::ESRCH sampler @@ -1838,21 +1847,65 @@ module Performance 0 end + def process_tree_sample(pid) + return windows_process_tree_sample(pid) if Gem.win_platform? + + { at: Time.now.utc.iso8601, bytes: process_group_rss_bytes(pid), private_bytes: 0, handle_count: 0 } + end + + def windows_process_tree_sample(pid) + script = <<~POWERSHELL.delete("\n") + $root=#{Integer(pid)}; + $all=@(Get-CimInstance Win32_Process); + $ids=@($root); + do {$before=$ids.Count; $ids+=@($all | Where-Object {$ids -contains $_.ParentProcessId} | ForEach-Object ProcessId); $ids=@($ids | Sort-Object -Unique)} while ($ids.Count -gt $before); + $processes=@(Get-Process -Id $ids -ErrorAction SilentlyContinue); + @{bytes=($processes | Measure-Object WorkingSet64 -Sum).Sum;private_bytes=($processes | Measure-Object PrivateMemorySize64 -Sum).Sum;handle_count=($processes | Measure-Object HandleCount -Sum).Sum} | ConvertTo-Json -Compress + POWERSHELL + payload = JSON.parse(IO.popen(['powershell.exe', '-NoProfile', '-NonInteractive', '-Command', script], &:read)) + { + at: Time.now.utc.iso8601, + bytes: payload.fetch('bytes', 0).to_i, + private_bytes: payload.fetch('private_bytes', 0).to_i, + handle_count: payload.fetch('handle_count', 0).to_i + } + rescue StandardError + { at: Time.now.utc.iso8601, bytes: 0, private_bytes: 0, handle_count: 0 } + end + def rss_summary(samples) values = samples.map { |sample| sample.fetch(:bytes).to_i } + private_values = samples.map { |sample| sample.fetch(:private_bytes, 0).to_i } + handle_values = samples.map { |sample| sample.fetch(:handle_count, 0).to_i } { - min: values.min || 0, - max: values.max || 0, - final: values.last || 0 + min: sample_min(values), + max: sample_max(values), + final: sample_final(values), + private_max: sample_max(private_values), + private_final: sample_final(private_values), + handles_max: sample_max(handle_values), + handles_final: sample_final(handle_values) } end + def sample_min(values) + values.min || 0 + end + + def sample_max(values) + values.max || 0 + end + + def sample_final(values) + values.last || 0 + end + def command_for(server, port, options) workers = options.fetch(:worker_count).to_s threads = options.fetch(:thread_count).to_s case server when 'vajra' - %w[bundle exec vajra] + Gem.win_platform? ? [RbConfig.ruby, '-rbundler/setup', Gem.bin_path('vajra', 'vajra')] : %w[bundle exec vajra] when 'puma' ['bundle', 'exec', 'puma', '-b', "tcp://127.0.0.1:#{port}", '-w', workers, '-t', "#{threads}:#{threads}", 'config.ru'] when 'passenger' @@ -2150,12 +2203,17 @@ module Performance def stop_process_group(pid) return unless pid - Process.kill('INT', -pid) + if Gem.win_platform? + result = WindowsConsoleControl.GenerateConsoleCtrlEvent(1, pid) + raise SystemCallError.new('GenerateConsoleCtrlEvent', Fiddle.last_error) if result.zero? + else + Process.kill('INT', -pid) + end Timeout.timeout(5) { Process.wait(pid) } rescue Errno::ECHILD, Errno::ESRCH nil rescue Timeout::Error - Process.kill('KILL', -pid) + Process.kill('KILL', Gem.win_platform? ? pid : -pid) Process.wait(pid) end end diff --git a/gems/vajra/sig/vajra.rbs b/gems/vajra/sig/vajra.rbs index dbc6df7..c9ba9a4 100644 --- a/gems/vajra/sig/vajra.rbs +++ b/gems/vajra/sig/vajra.rbs @@ -12,7 +12,13 @@ module Vajra def call: (String) -> bool end - def self.load!: (?loader: _Loader, ?extension_path: String) -> bool + def self.package_platform: () -> String + def self.ensure_supported_windows_abi!: () -> true + def self.native_packages_root: () -> String + def self.packaged_native_metadata_path: () -> String? + def self.packaged_native_root: () -> String + def self.validate_abi!: (?metadata_path: String?) -> true + def self.load!: (?loader: _Loader, ?extension_path: String?) -> bool end VERSION: String diff --git a/gems/vajra/sig/vajra/cli.rbs b/gems/vajra/sig/vajra/cli.rbs index 1ede07c..3d1ce80 100644 --- a/gems/vajra/sig/vajra/cli.rbs +++ b/gems/vajra/sig/vajra/cli.rbs @@ -9,15 +9,7 @@ module Vajra end interface _RackApplication - def call: ( - Hash[String, String | bool | IO | StringIO | Array[Integer]] - ) -> [ - Integer | String, - Hash[String | Symbol, Vajra::Internal::RackExecution::_RackHeaderValue] | - Array[[String | Symbol, Vajra::Internal::RackExecution::_RackHeaderValue]] | - Vajra::Internal::RackExecution::_RackHeaders, - Array[String] | Vajra::Internal::RackExecution::_RackBody - ] + def call: (Vajra::Internal::RackExecution::rack_env) -> Vajra::Internal::RackExecution::rack_response end def self.with_config_target: [T, R] (T target) { () -> R } -> R diff --git a/gems/vajra/sig/vajra/internal/rack_execution.rbs b/gems/vajra/sig/vajra/internal/rack_execution.rbs index be3fe65..870093b 100644 --- a/gems/vajra/sig/vajra/internal/rack_execution.rbs +++ b/gems/vajra/sig/vajra/internal/rack_execution.rbs @@ -17,7 +17,17 @@ module Vajra end class NativeHijack - def call: () -> IO + def call: () -> (IO | NativeTlsHijackIO) + end + + class NativeTlsHijackIO + def write: (String) -> Integer + def <<: (String) -> self + def read: (?Integer? length) -> String? + def readpartial: (Integer length) -> String + def flush: () -> self + def close: () -> nil + def closed?: () -> bool end module HTTP2 @@ -29,9 +39,9 @@ module Vajra def accept: (?Integer status, ?Hash[String | Symbol, _HeaderValue] headers) -> self def read: (?Integer? length, ?String? outbuf) -> String? def write: (String chunk) -> Integer - def flush: () -> nil + def flush: () -> self def close: () -> nil - def reset: (?Integer error_code) -> nil + def reset: (?(Integer | :cancel) error_code) -> nil def closed?: () -> bool def protocol: () -> String def stream_id: () -> Integer @@ -44,14 +54,23 @@ module Vajra def to_s: () -> String end + type rack_env_value = String | bool | IO | _RackInput | NativeHijack | HTTP2::Stream | Array[Integer] + type rack_env = Hash[String, rack_env_value] + type rack_response = [ + Integer | String, + Hash[String | Symbol, _RackHeaderValue] | Array[[String | Symbol, _RackHeaderValue]] | _RackHeaders, + Array[String] | _RackBody + ] + interface _RackApp - def call: (Hash[String, String | bool | IO | _RackInput | NativeHijack | HTTP2::Stream | Array[Integer]]) -> [Integer | String, Hash[String | Symbol, _RackHeaderValue] | Array[[String | Symbol, _RackHeaderValue]] | _RackHeaders, Array[String] | _RackBody] + def call: (rack_env) -> rack_response end interface _RackInput def read: (?Integer? length, ?String? outbuf) -> String? def gets: (?String? separator) -> String? def each: () { (String) -> void } -> self + | () -> Enumerator[String, self] def rewind: () -> Integer def close: () -> nil end @@ -78,6 +97,7 @@ module Vajra def self.configure_threads!: (Integer) -> Integer def self.installed?: () -> bool def self.call: (Array[[String, String]], String | _RackInput) -> [Integer, Array[[String, String]], Array[String]]? + def self.call_native: (_RackApp, rack_env) -> rack_response def self.__native_set_app__: (_RackApp? app) -> _RackApp? def self.__native_set_callback__: (Proc | nil) -> (Proc | nil) def self.__native_configure_threads__: (Integer max_threads) -> Integer diff --git a/gems/vajra/sig/vajra/internal/tracing.rbs b/gems/vajra/sig/vajra/internal/tracing.rbs index ad88291..668d686 100644 --- a/gems/vajra/sig/vajra/internal/tracing.rbs +++ b/gems/vajra/sig/vajra/internal/tracing.rbs @@ -16,13 +16,6 @@ module Vajra type start_options = Hash[Symbol, start_option_value] - type rack_env_value = - String | - bool | - IO | - StringIO | - Array[Integer] - type lifecycle_event_key = :event | :worker_index | @@ -162,7 +155,8 @@ module Vajra def self.install_from_start_options!: (start_options) -> bool def self.shutdown!: () -> void def self.after_fork!: () -> void - def self.with_request_span: [T] (Hash[String, rack_env_value]) { () -> T } -> T + def self.before_worker_exit!: () -> void + def self.with_request_span: [T] (Vajra::Internal::RackExecution::rack_env) { () -> T } -> T def self.current_trace_context: (?_TraceSpan? span) -> trace_context def self.emit_lifecycle_span: (lifecycle_event) -> nil def self.emit_native_request_span: (native_request_event) -> void diff --git a/gems/vajra/sig/vajra/rails.rbs b/gems/vajra/sig/vajra/rails.rbs index bd902e7..bcc903b 100644 --- a/gems/vajra/sig/vajra/rails.rbs +++ b/gems/vajra/sig/vajra/rails.rbs @@ -9,15 +9,7 @@ module Vajra end interface _RailsApplication - def call: ( - Hash[String, String | bool | IO | StringIO | Array[Integer]] - ) -> [ - Integer | String, - Hash[String | Symbol, Vajra::Internal::RackExecution::_RackHeaderValue] | - Array[[String | Symbol, Vajra::Internal::RackExecution::_RackHeaderValue]] | - Vajra::Internal::RackExecution::_RackHeaders, - Array[String] | Vajra::Internal::RackExecution::_RackBody - ] + def call: (Vajra::Internal::RackExecution::rack_env) -> Vajra::Internal::RackExecution::rack_response def initialized?: () -> bool def initialize!: () -> _RailsApplication end diff --git a/gems/vajra/spec/cpp/CMakeLists.txt b/gems/vajra/spec/cpp/CMakeLists.txt index 86240c0..7dd9e0d 100644 --- a/gems/vajra/spec/cpp/CMakeLists.txt +++ b/gems/vajra/spec/cpp/CMakeLists.txt @@ -1,11 +1,18 @@ cmake_minimum_required(VERSION 3.20) project(vajra_cpp_tests LANGUAGES C CXX) +if(MSVC) + message(FATAL_ERROR "Vajra supports Windows only with the UCRT/MinGW toolchain") +endif() + set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) set(CMAKE_CXX_EXTENSIONS OFF) find_package(Threads REQUIRED) +if(WIN32 AND MINGW AND NOT OPENSSL_ROOT_DIR AND DEFINED ENV{MINGW_PREFIX}) + file(TO_CMAKE_PATH "$ENV{MINGW_PREFIX}" OPENSSL_ROOT_DIR) +endif() find_package(OpenSSL REQUIRED) file(GLOB VAJRA_VENDOR_NGHTTP2_SOURCES CONFIGURE_DEPENDS @@ -16,6 +23,7 @@ add_executable(vajra_server_test test_support.cpp lifecycle_controller_test.cpp rack_env_test.cpp + platform_socket_test.cpp server_lifecycle_test.cpp request_head_test.cpp response_test.cpp @@ -24,6 +32,8 @@ add_executable(vajra_server_test server_test.cpp ../../ext/vajra/lifecycle/lifecycle_controller.cpp ../../ext/vajra/listener/listener_socket.cpp + ../../ext/vajra/platform/socket.cpp + ../../ext/vajra/platform/process.cpp ../../ext/vajra/rack/http2_stream.cpp ../../ext/vajra/rack/rack_execution_profiler.cpp ruby_execution_bridge_stub.cpp @@ -51,13 +61,17 @@ target_include_directories(vajra_server_test PRIVATE ../../ext/vajra/vendor/nghttp2/lib ) target_link_libraries(vajra_server_test PRIVATE Threads::Threads OpenSSL::SSL OpenSSL::Crypto) +if(WIN32) + target_link_libraries(vajra_server_test PRIVATE ws2_32 psapi) +endif() target_compile_definitions(vajra_server_test PRIVATE VAJRA_RUNTIME_TESTING NGHTTP2_STATICLIB BUILDING_NGHTTP2 - HAVE_ARPA_INET_H - HAVE_NETINET_IN_H ) +if(NOT WIN32) + target_compile_definitions(vajra_server_test PRIVATE HAVE_ARPA_INET_H HAVE_NETINET_IN_H) +endif() target_compile_options(vajra_server_test PRIVATE -Wall -Wextra -Wpedantic -Werror) enable_testing() diff --git a/gems/vajra/spec/cpp/platform_socket_test.cpp b/gems/vajra/spec/cpp/platform_socket_test.cpp new file mode 100644 index 0000000..bcd70bb --- /dev/null +++ b/gems/vajra/spec/cpp/platform_socket_test.cpp @@ -0,0 +1,58 @@ +// Copyright Codevedas Inc. 2025-present +// +// This source code is licensed under the MIT license found in the +// LICENSE file in the root directory of this source tree. + +#include "platform/socket.hpp" +#include "test_support.hpp" +#include "transport/tls_connection.hpp" + +#include +#include + +namespace VajraSpecCpp +{ + void run_platform_socket_tests() + { + try + { + (void)Vajra::platform::openssl_socket_descriptor(Vajra::platform::kInvalidSocket); + fail("OpenSSL descriptor conversion accepted an invalid socket"); + } + catch (const std::invalid_argument &) + { + } + + const auto sockets = connected_socket_pair(); + SocketGuard client(sockets[0]); + SocketGuard server(sockets[1]); + const int descriptor = Vajra::platform::openssl_socket_descriptor(client.get()); + if (descriptor < 0) + { + fail("OpenSSL descriptor conversion rejected a representable socket"); + } + +#ifdef _WIN32 + const auto oversized = static_cast( + static_cast(std::numeric_limits::max()) + 1ULL); + BIO *wide_bio = Vajra::transport::new_socket_bio(oversized); + expect_true( + Vajra::transport::socket_bio_handle(wide_bio) == oversized, + "Windows socket BIO truncated a pointer-sized socket handle"); + BIO_free(wide_bio); + + expect_true( + Vajra::platform::set_socket_nonblocking(client.get(), true), + "failed to make Windows socket non-blocking for BIO retry test"); + BIO *socket_bio = Vajra::transport::new_socket_bio(client.get()); + char byte = 0; + expect_true(BIO_read(socket_bio, &byte, 1) < 0, "empty non-blocking socket BIO unexpectedly read data"); + expect_true(BIO_should_retry(socket_bio) != 0, "socket BIO did not mark would-block as retryable"); + expect_true(BIO_should_read(socket_bio) != 0, "socket BIO did not request a read retry"); + BIO_free(socket_bio); + expect_true( + Vajra::platform::socket_open(client.get()), + "freeing the non-owning socket BIO closed its socket"); +#endif + } +} diff --git a/gems/vajra/spec/cpp/rack_env_test.cpp b/gems/vajra/spec/cpp/rack_env_test.cpp index 8347faf..736ad07 100644 --- a/gems/vajra/spec/cpp/rack_env_test.cpp +++ b/gems/vajra/spec/cpp/rack_env_test.cpp @@ -98,7 +98,7 @@ namespace VajraSpecCpp Vajra::request::ParsedHeader{"X-Trace-Id", "abc123"}, Vajra::request::ParsedHeader{"X-Trace-Id", "def456"}, }}, - Vajra::request::SocketContext{"127.0.0.2", 54'321, "127.0.0.1", 3000, "http"}, -1, "", nullptr, nullptr}; + Vajra::request::SocketContext{"127.0.0.2", 54'321, "127.0.0.1", 3000, "http"}, Vajra::platform::kInvalidSocket, "", nullptr, nullptr}; const std::vector env_entries = builder.build(request_context); @@ -126,7 +126,7 @@ namespace VajraSpecCpp Vajra::request::ParsedRequest{ Vajra::request::ParsedRequestLine{"GET", "/", "HTTP/1.1"}, {Vajra::request::ParsedHeader{"X/Trace", "abc123"}}}, - Vajra::request::SocketContext{"127.0.0.1", 10'000, "127.0.0.1", 3000, "http"}, -1, "", nullptr, nullptr}; + Vajra::request::SocketContext{"127.0.0.1", 10'000, "127.0.0.1", 3000, "http"}, Vajra::platform::kInvalidSocket, "", nullptr, nullptr}; try { @@ -152,7 +152,7 @@ namespace VajraSpecCpp Vajra::request::ParsedRequest{ Vajra::request::ParsedRequestLine{"GET", "/", "HTTP/1.1"}, {Vajra::request::ParsedHeader{"X_Foo", "ambiguous"}}}, - Vajra::request::SocketContext{"127.0.0.1", 10'000, "127.0.0.1", 3000, "http"}, -1, "", nullptr, nullptr}; + Vajra::request::SocketContext{"127.0.0.1", 10'000, "127.0.0.1", 3000, "http"}, Vajra::platform::kInvalidSocket, "", nullptr, nullptr}; try { @@ -178,7 +178,7 @@ namespace VajraSpecCpp Vajra::request::ParsedRequest{ Vajra::request::ParsedRequestLine{"GET", "/", "HTTP/1.1"}, {Vajra::request::ParsedHeader{"X.Foo", "ambiguous"}}}, - Vajra::request::SocketContext{"127.0.0.1", 10'000, "127.0.0.1", 3000, "http"}, -1, "", nullptr, nullptr}; + Vajra::request::SocketContext{"127.0.0.1", 10'000, "127.0.0.1", 3000, "http"}, Vajra::platform::kInvalidSocket, "", nullptr, nullptr}; try { @@ -204,7 +204,7 @@ namespace VajraSpecCpp Vajra::request::ParsedRequest{ Vajra::request::ParsedRequestLine{"GET", "/", "HTTP/1.1"}, {Vajra::request::ParsedHeader{"X-Trace-Id", std::string("bad\0value", 9)}}}, - Vajra::request::SocketContext{"127.0.0.1", 10'000, "127.0.0.1", 3000, "http"}, -1, "", nullptr, nullptr}; + Vajra::request::SocketContext{"127.0.0.1", 10'000, "127.0.0.1", 3000, "http"}, Vajra::platform::kInvalidSocket, "", nullptr, nullptr}; try { @@ -233,7 +233,7 @@ namespace VajraSpecCpp Vajra::request::ParsedHeader{"Content-Length", "0"}, Vajra::request::ParsedHeader{"Content-Length", "0"}, }}, - Vajra::request::SocketContext{"127.0.0.1", 10'000, "127.0.0.1", 3000, "http"}, -1, "", nullptr, nullptr}; + Vajra::request::SocketContext{"127.0.0.1", 10'000, "127.0.0.1", 3000, "http"}, Vajra::platform::kInvalidSocket, "", nullptr, nullptr}; try { @@ -262,7 +262,7 @@ namespace VajraSpecCpp Vajra::request::ParsedHeader{"Content-Type", "application/json"}, Vajra::request::ParsedHeader{"Content-Type", "text/plain"}, }}, - Vajra::request::SocketContext{"127.0.0.1", 10'000, "127.0.0.1", 3000, "http"}, -1, "", nullptr, nullptr}; + Vajra::request::SocketContext{"127.0.0.1", 10'000, "127.0.0.1", 3000, "http"}, Vajra::platform::kInvalidSocket, "", nullptr, nullptr}; try { @@ -291,7 +291,7 @@ namespace VajraSpecCpp Vajra::request::ParsedHeader{"Host", "example.test"}, Vajra::request::ParsedHeader{"Host", "evil.test"}, }}, - Vajra::request::SocketContext{"127.0.0.1", 10'000, "127.0.0.1", 3000, "http"}, -1, "", nullptr, nullptr}; + Vajra::request::SocketContext{"127.0.0.1", 10'000, "127.0.0.1", 3000, "http"}, Vajra::platform::kInvalidSocket, "", nullptr, nullptr}; try { diff --git a/gems/vajra/spec/cpp/request_head_test.cpp b/gems/vajra/spec/cpp/request_head_test.cpp index 860e52b..78eb9a7 100644 --- a/gems/vajra/spec/cpp/request_head_test.cpp +++ b/gems/vajra/spec/cpp/request_head_test.cpp @@ -10,7 +10,6 @@ #include #include -#include #include namespace VajraSpecCpp @@ -322,18 +321,8 @@ namespace VajraSpecCpp void test_head_reader_does_not_classify_local_descriptor_errors_as_peer_close() { - int pipe_fds[2]; - if (pipe(pipe_fds) < 0) - { - fail("pipe failed while setting up invalid descriptor reader test"); - } - - const int read_fd = pipe_fds[0]; - close(pipe_fds[0]); - close(pipe_fds[1]); - Vajra::request::HeadReader reader(Vajra::request::kDefaultMaxRequestHeadBytes, 0); - const Vajra::request::HeadReadResult result = reader.read(read_fd, "", 0); + const Vajra::request::HeadReadResult result = reader.read(Vajra::platform::kInvalidSocket, "", 0); if (result.complete) { diff --git a/gems/vajra/spec/cpp/response_test.cpp b/gems/vajra/spec/cpp/response_test.cpp index 11fc2f1..1358a20 100644 --- a/gems/vajra/spec/cpp/response_test.cpp +++ b/gems/vajra/spec/cpp/response_test.cpp @@ -9,6 +9,7 @@ #include "request/http2_session.hpp" #include "request/request_processor.hpp" #include "rack/http2_stream.hpp" +#include "platform/process.hpp" #include "response/response_serializer.hpp" #include "response/response_writer.hpp" #include "runtime/runtime_logging.hpp" @@ -21,14 +22,16 @@ #include #include #include +#include #include #include #include #include -#include #include #include +#ifndef _WIN32 #include +#endif #include namespace VajraSpecCpp @@ -507,9 +510,9 @@ namespace VajraSpecCpp { } - int fd() const override + Vajra::platform::SocketHandle fd() const override { - return -1; + return Vajra::platform::kInvalidSocket; } bool wait_readable(int) override @@ -878,18 +881,12 @@ namespace VajraSpecCpp std::thread start_request_processor_thread( const Vajra::request::RequestProcessor &processor, - FileDescriptorGuard &server_socket) + SocketGuard &server_socket) { - const int owned_fd = dup(server_socket.get()); - if (owned_fd < 0) - { - fail("dup failed while transferring request processor socket ownership"); - } - - server_socket.close_if_open(); + const Vajra::platform::SocketHandle owned_fd = server_socket.release(); return std::thread([&processor, owned_fd]() { - FileDescriptorGuard guard(owned_fd); + SocketGuard guard(owned_fd); Vajra::transport::PlainConnection connection(owned_fd); processor.handle(connection, Vajra::request::SocketContext{"127.0.0.1", 12'345, "127.0.0.1", 3000, "http"}); }); @@ -970,7 +967,15 @@ namespace VajraSpecCpp std::shared_ptr response_body_file_from(const std::string &body) { - FILE *file = std::tmpfile(); + FILE *file = nullptr; +#ifdef _WIN32 + if (tmpfile_s(&file) != 0) + { + file = nullptr; + } +#else + file = std::tmpfile(); +#endif if (file == nullptr) { fail("tmpfile failed while creating response body fixture"); @@ -1241,14 +1246,10 @@ namespace VajraSpecCpp void test_response_writer_send_returns_false_on_serialization_failure() { - int sockets[2]; - if (socketpair(AF_UNIX, SOCK_STREAM, 0, sockets) < 0) - { - fail("socketpair failed while setting up invalid response writer test"); - } + const auto sockets = connected_socket_pair(); - FileDescriptorGuard reader_socket(sockets[0]); - FileDescriptorGuard writer_socket(sockets[1]); + SocketGuard reader_socket(sockets[0]); + SocketGuard writer_socket(sockets[1]); Vajra::response::ResponseWriter writer; const bool sent = writer.send( @@ -1332,14 +1333,10 @@ namespace VajraSpecCpp void test_request_processor_keeps_connection_open_for_sequential_requests() { - int sockets[2]; - if (socketpair(AF_UNIX, SOCK_STREAM, 0, sockets) < 0) - { - fail("socketpair failed while setting up sequential request processor test"); - } + const auto sockets = connected_socket_pair(); - FileDescriptorGuard client_socket(sockets[0]); - FileDescriptorGuard server_socket(sockets[1]); + SocketGuard client_socket(sockets[0]); + SocketGuard server_socket(sockets[1]); suppress_sigpipe(client_socket.get()); const Vajra::request::RequestProcessor processor(Vajra::request::kDefaultMaxRequestHeadBytes); @@ -1430,14 +1427,10 @@ namespace VajraSpecCpp void test_request_processor_handles_pipelined_read_ahead_without_losing_the_next_request() { - int sockets[2]; - if (socketpair(AF_UNIX, SOCK_STREAM, 0, sockets) < 0) - { - fail("socketpair failed while setting up pipelined request processor test"); - } + const auto sockets = connected_socket_pair(); - FileDescriptorGuard client_socket(sockets[0]); - FileDescriptorGuard server_socket(sockets[1]); + SocketGuard client_socket(sockets[0]); + SocketGuard server_socket(sockets[1]); suppress_sigpipe(client_socket.get()); const Vajra::request::RequestProcessor processor(Vajra::request::kDefaultMaxRequestHeadBytes); @@ -1503,14 +1496,10 @@ namespace VajraSpecCpp void test_control_response_closes_instead_of_reinterpreting_request_body() { - int sockets[2]; - if (socketpair(AF_UNIX, SOCK_STREAM, 0, sockets) < 0) - { - fail("socketpair failed while setting up control response framing test"); - } + const auto sockets = connected_socket_pair(); - FileDescriptorGuard client_socket(sockets[0]); - FileDescriptorGuard server_socket(sockets[1]); + SocketGuard client_socket(sockets[0]); + SocketGuard server_socket(sockets[1]); suppress_sigpipe(client_socket.get()); const std::string hidden_request = "GET /poisoned HTTP/1.1\r\nHost: example.test\r\nConnection: close\r\n\r\n"; @@ -1561,14 +1550,10 @@ namespace VajraSpecCpp void test_request_processor_closes_after_parse_error_response() { - int sockets[2]; - if (socketpair(AF_UNIX, SOCK_STREAM, 0, sockets) < 0) - { - fail("socketpair failed while setting up parse error request processor test"); - } + const auto sockets = connected_socket_pair(); - FileDescriptorGuard client_socket(sockets[0]); - FileDescriptorGuard server_socket(sockets[1]); + SocketGuard client_socket(sockets[0]); + SocketGuard server_socket(sockets[1]); suppress_sigpipe(client_socket.get()); const Vajra::request::RequestProcessor processor(Vajra::request::kDefaultMaxRequestHeadBytes); @@ -1617,14 +1602,10 @@ namespace VajraSpecCpp void test_request_processor_keeps_connection_open_after_request_body_framing() { - int sockets[2]; - if (socketpair(AF_UNIX, SOCK_STREAM, 0, sockets) < 0) - { - fail("socketpair failed while setting up request body framing test"); - } + const auto sockets = connected_socket_pair(); - FileDescriptorGuard client_socket(sockets[0]); - FileDescriptorGuard server_socket(sockets[1]); + SocketGuard client_socket(sockets[0]); + SocketGuard server_socket(sockets[1]); suppress_sigpipe(client_socket.get()); const auto request_executor = std::make_shared(); @@ -1711,14 +1692,10 @@ namespace VajraSpecCpp void test_request_processor_closes_http_1_0_without_keep_alive() { - int sockets[2]; - if (socketpair(AF_UNIX, SOCK_STREAM, 0, sockets) < 0) - { - fail("socketpair failed while setting up HTTP/1.0 close test"); - } + const auto sockets = connected_socket_pair(); - FileDescriptorGuard client_socket(sockets[0]); - FileDescriptorGuard server_socket(sockets[1]); + SocketGuard client_socket(sockets[0]); + SocketGuard server_socket(sockets[1]); suppress_sigpipe(client_socket.get()); const Vajra::request::RequestProcessor processor(Vajra::request::kDefaultMaxRequestHeadBytes); @@ -1765,14 +1742,10 @@ namespace VajraSpecCpp void test_request_processor_keeps_http_1_0_alive_when_requested() { - int sockets[2]; - if (socketpair(AF_UNIX, SOCK_STREAM, 0, sockets) < 0) - { - fail("socketpair failed while setting up HTTP/1.0 keep-alive test"); - } + const auto sockets = connected_socket_pair(); - FileDescriptorGuard client_socket(sockets[0]); - FileDescriptorGuard server_socket(sockets[1]); + SocketGuard client_socket(sockets[0]); + SocketGuard server_socket(sockets[1]); suppress_sigpipe(client_socket.get()); const Vajra::request::RequestProcessor processor(Vajra::request::kDefaultMaxRequestHeadBytes); @@ -1835,7 +1808,7 @@ namespace VajraSpecCpp {Vajra::request::ParsedHeader{"Content-Length", "4"}}}; const Vajra::request::BodyReadResult result = reader.read( - -1, + Vajra::platform::kInvalidSocket, request, "bodyGET /next HTTP/1.1\r\nHost: example.test\r\n\r\n"); @@ -1858,7 +1831,7 @@ namespace VajraSpecCpp {Vajra::request::ParsedHeader{"Transfer-Encoding", "chunked"}}}; const Vajra::request::BodyReadResult result = reader.read( - -1, + Vajra::platform::kInvalidSocket, request, "3\r\nabc\r\n0\r\nX-Trailer: done\r\n\r\nGET /next HTTP/1.1\r\nHost: example.test\r\n\r\n"); @@ -1974,7 +1947,7 @@ namespace VajraSpecCpp try { - (void)reader.read(-1, request, "3 junk\r\nabc\r\n0\r\n\r\n"); + (void)reader.read(Vajra::platform::kInvalidSocket, request, "3 junk\r\nabc\r\n0\r\n\r\n"); } catch (const Vajra::request::HeadError &error) { @@ -1998,7 +1971,7 @@ namespace VajraSpecCpp try { - (void)reader.read(-1, request, ""); + (void)reader.read(Vajra::platform::kInvalidSocket, request, ""); } catch (const Vajra::request::HeadError &error) { @@ -2073,7 +2046,7 @@ namespace VajraSpecCpp try { - (void)reader.read(-1, request, "12345"); + (void)reader.read(Vajra::platform::kInvalidSocket, request, "12345"); } catch (const Vajra::request::HeadError &error) { @@ -2101,7 +2074,7 @@ namespace VajraSpecCpp try { - (void)reader.read(-1, request, "1234"); + (void)reader.read(Vajra::platform::kInvalidSocket, request, "1234"); } catch (const Vajra::request::BodyReadIncompleteError &) { @@ -2120,7 +2093,7 @@ namespace VajraSpecCpp try { - (void)reader.read(-1, request, ""); + (void)reader.read(Vajra::platform::kInvalidSocket, request, ""); } catch (const Vajra::request::BodyReadIncompleteError &) { @@ -2136,14 +2109,10 @@ namespace VajraSpecCpp void test_request_processor_reads_fragmented_fixed_length_request_body() { - int sockets[2]; - if (socketpair(AF_UNIX, SOCK_STREAM, 0, sockets) < 0) - { - fail("socketpair failed while setting up fragmented fixed-length body test"); - } + const auto sockets = connected_socket_pair(); - FileDescriptorGuard client_socket(sockets[0]); - FileDescriptorGuard server_socket(sockets[1]); + SocketGuard client_socket(sockets[0]); + SocketGuard server_socket(sockets[1]); suppress_sigpipe(client_socket.get()); const auto request_executor = std::make_shared(); @@ -2192,14 +2161,10 @@ namespace VajraSpecCpp void test_request_processor_decodes_chunked_request_body() { - int sockets[2]; - if (socketpair(AF_UNIX, SOCK_STREAM, 0, sockets) < 0) - { - fail("socketpair failed while setting up chunked body test"); - } + const auto sockets = connected_socket_pair(); - FileDescriptorGuard client_socket(sockets[0]); - FileDescriptorGuard server_socket(sockets[1]); + SocketGuard client_socket(sockets[0]); + SocketGuard server_socket(sockets[1]); suppress_sigpipe(client_socket.get()); const auto request_executor = std::make_shared(); @@ -2245,14 +2210,10 @@ namespace VajraSpecCpp void test_request_processor_decodes_chunked_request_body_with_extensions_and_trailers() { - int sockets[2]; - if (socketpair(AF_UNIX, SOCK_STREAM, 0, sockets) < 0) - { - fail("socketpair failed while setting up chunked extension test"); - } + const auto sockets = connected_socket_pair(); - FileDescriptorGuard client_socket(sockets[0]); - FileDescriptorGuard server_socket(sockets[1]); + SocketGuard client_socket(sockets[0]); + SocketGuard server_socket(sockets[1]); suppress_sigpipe(client_socket.get()); const auto request_executor = std::make_shared(); @@ -2345,14 +2306,10 @@ namespace VajraSpecCpp void test_request_processor_rejects_conflicting_request_body_framing() { - int sockets[2]; - if (socketpair(AF_UNIX, SOCK_STREAM, 0, sockets) < 0) - { - fail("socketpair failed while setting up conflicting request body framing test"); - } + const auto sockets = connected_socket_pair(); - FileDescriptorGuard client_socket(sockets[0]); - FileDescriptorGuard server_socket(sockets[1]); + SocketGuard client_socket(sockets[0]); + SocketGuard server_socket(sockets[1]); suppress_sigpipe(client_socket.get()); const Vajra::request::RequestProcessor processor(Vajra::request::kDefaultMaxRequestHeadBytes); @@ -2394,14 +2351,10 @@ namespace VajraSpecCpp void test_request_processor_rejects_malformed_chunked_request_body() { - int sockets[2]; - if (socketpair(AF_UNIX, SOCK_STREAM, 0, sockets) < 0) - { - fail("socketpair failed while setting up malformed chunked body test"); - } + const auto sockets = connected_socket_pair(); - FileDescriptorGuard client_socket(sockets[0]); - FileDescriptorGuard server_socket(sockets[1]); + SocketGuard client_socket(sockets[0]); + SocketGuard server_socket(sockets[1]); suppress_sigpipe(client_socket.get()); const Vajra::request::RequestProcessor processor(Vajra::request::kDefaultMaxRequestHeadBytes); @@ -2442,14 +2395,10 @@ namespace VajraSpecCpp void test_request_processor_rejects_oversized_request_body() { - int sockets[2]; - if (socketpair(AF_UNIX, SOCK_STREAM, 0, sockets) < 0) - { - fail("socketpair failed while setting up oversized request body test"); - } + const auto sockets = connected_socket_pair(); - FileDescriptorGuard client_socket(sockets[0]); - FileDescriptorGuard server_socket(sockets[1]); + SocketGuard client_socket(sockets[0]); + SocketGuard server_socket(sockets[1]); suppress_sigpipe(client_socket.get()); const Vajra::request::RequestProcessor processor(Vajra::request::kDefaultMaxRequestHeadBytes); @@ -2489,14 +2438,10 @@ namespace VajraSpecCpp void test_request_processor_closes_quietly_when_request_body_is_incomplete() { - int sockets[2]; - if (socketpair(AF_UNIX, SOCK_STREAM, 0, sockets) < 0) - { - fail("socketpair failed while setting up incomplete request body test"); - } + const auto sockets = connected_socket_pair(); - FileDescriptorGuard client_socket(sockets[0]); - FileDescriptorGuard server_socket(sockets[1]); + SocketGuard client_socket(sockets[0]); + SocketGuard server_socket(sockets[1]); suppress_sigpipe(client_socket.get()); const auto request_executor = std::make_shared(); @@ -2518,7 +2463,7 @@ namespace VajraSpecCpp fail("failed to send partial request body"); } - if (shutdown(client_socket.get(), SHUT_WR) < 0) + if (!Vajra::platform::shutdown_socket_write(client_socket.get())) { fail("failed to half-close partial request body socket"); } @@ -2550,14 +2495,10 @@ namespace VajraSpecCpp void test_request_processor_times_out_stalled_fixed_length_request_body() { - int sockets[2]; - if (socketpair(AF_UNIX, SOCK_STREAM, 0, sockets) < 0) - { - fail("socketpair failed while setting up fixed-length request body timeout test"); - } + const auto sockets = connected_socket_pair(); - FileDescriptorGuard client_socket(sockets[0]); - FileDescriptorGuard server_socket(sockets[1]); + SocketGuard client_socket(sockets[0]); + SocketGuard server_socket(sockets[1]); suppress_sigpipe(client_socket.get()); const auto request_executor = std::make_shared(); @@ -2607,14 +2548,10 @@ namespace VajraSpecCpp void test_request_processor_times_out_stalled_chunked_request_body() { - int sockets[2]; - if (socketpair(AF_UNIX, SOCK_STREAM, 0, sockets) < 0) - { - fail("socketpair failed while setting up chunked request body timeout test"); - } + const auto sockets = connected_socket_pair(); - FileDescriptorGuard client_socket(sockets[0]); - FileDescriptorGuard server_socket(sockets[1]); + SocketGuard client_socket(sockets[0]); + SocketGuard server_socket(sockets[1]); suppress_sigpipe(client_socket.get()); const auto request_executor = std::make_shared(); @@ -2664,14 +2601,10 @@ namespace VajraSpecCpp void test_request_processor_suppresses_head_response_body() { - int sockets[2]; - if (socketpair(AF_UNIX, SOCK_STREAM, 0, sockets) < 0) - { - fail("socketpair failed while setting up HEAD response body suppression test"); - } + const auto sockets = connected_socket_pair(); - FileDescriptorGuard client_socket(sockets[0]); - FileDescriptorGuard server_socket(sockets[1]); + SocketGuard client_socket(sockets[0]); + SocketGuard server_socket(sockets[1]); suppress_sigpipe(client_socket.get()); const auto request_executor = std::make_shared(); @@ -2723,14 +2656,10 @@ namespace VajraSpecCpp void test_request_processor_returns_internal_server_error_when_executor_raises() { - int sockets[2]; - if (socketpair(AF_UNIX, SOCK_STREAM, 0, sockets) < 0) - { - fail("socketpair failed while setting up executor failure test"); - } + const auto sockets = connected_socket_pair(); - FileDescriptorGuard client_socket(sockets[0]); - FileDescriptorGuard server_socket(sockets[1]); + SocketGuard client_socket(sockets[0]); + SocketGuard server_socket(sockets[1]); suppress_sigpipe(client_socket.get()); const auto request_executor = std::make_shared(); @@ -2778,14 +2707,10 @@ namespace VajraSpecCpp void test_request_processor_returns_bad_request_when_executor_raises_head_error() { - int sockets[2]; - if (socketpair(AF_UNIX, SOCK_STREAM, 0, sockets) < 0) - { - fail("socketpair failed while setting up head error executor test"); - } + const auto sockets = connected_socket_pair(); - FileDescriptorGuard client_socket(sockets[0]); - FileDescriptorGuard server_socket(sockets[1]); + SocketGuard client_socket(sockets[0]); + SocketGuard server_socket(sockets[1]); suppress_sigpipe(client_socket.get()); const auto request_executor = std::make_shared(); @@ -2828,14 +2753,10 @@ namespace VajraSpecCpp void test_request_processor_returns_internal_server_error_when_executor_response_is_invalid() { - int sockets[2]; - if (socketpair(AF_UNIX, SOCK_STREAM, 0, sockets) < 0) - { - fail("socketpair failed while setting up invalid response executor test"); - } + const auto sockets = connected_socket_pair(); - FileDescriptorGuard client_socket(sockets[0]); - FileDescriptorGuard server_socket(sockets[1]); + SocketGuard client_socket(sockets[0]); + SocketGuard server_socket(sockets[1]); suppress_sigpipe(client_socket.get()); const auto request_executor = std::make_shared(); @@ -2878,14 +2799,10 @@ namespace VajraSpecCpp void test_request_processor_strips_executor_framing_headers_before_sending() { - int sockets[2]; - if (socketpair(AF_UNIX, SOCK_STREAM, 0, sockets) < 0) - { - fail("socketpair failed while setting up framing header executor test"); - } + const auto sockets = connected_socket_pair(); - FileDescriptorGuard client_socket(sockets[0]); - FileDescriptorGuard server_socket(sockets[1]); + SocketGuard client_socket(sockets[0]); + SocketGuard server_socket(sockets[1]); suppress_sigpipe(client_socket.get()); const auto request_executor = std::make_shared(); @@ -2943,23 +2860,21 @@ namespace VajraSpecCpp void test_request_processor_does_not_mix_partial_internal_trace_context_with_traceparent() { - char log_path[] = "/tmp/vajra-access-log-XXXXXX"; - const int log_fd = mkstemp(log_path); - if (log_fd < 0) + const std::string log_path = ( + std::filesystem::temp_directory_path() / + ("vajra-access-log-" + std::to_string(Vajra::platform::current_process_id()) + ".jsonl")) + .string(); + std::ofstream log_file(log_path, std::ios::trunc); + if (!log_file) { - fail("mkstemp failed while setting up access log correlation test"); + fail("temporary file creation failed while setting up access log correlation test"); } - close(log_fd); + log_file.close(); - int sockets[2]; - if (socketpair(AF_UNIX, SOCK_STREAM, 0, sockets) < 0) - { - std::remove(log_path); - fail("socketpair failed while setting up access log correlation test"); - } + const auto sockets = connected_socket_pair(); - FileDescriptorGuard client_socket(sockets[0]); - FileDescriptorGuard server_socket(sockets[1]); + SocketGuard client_socket(sockets[0]); + SocketGuard server_socket(sockets[1]); suppress_sigpipe(client_socket.get()); Vajra::runtime::stop_runtime_logging_worker(); Vajra::runtime::configure_runtime_logging(false, log_path, "", "json"); @@ -2996,8 +2911,8 @@ namespace VajraSpecCpp client_socket.close_if_open(); processor_thread.join(); Vajra::runtime::stop_runtime_logging_worker(); - std::ifstream log_file(log_path); - const std::string access_log((std::istreambuf_iterator(log_file)), std::istreambuf_iterator()); + std::ifstream access_log_file(log_path); + const std::string access_log((std::istreambuf_iterator(access_log_file)), std::istreambuf_iterator()); if (access_log.find("\"trace_id\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\"") == std::string::npos) { fail("access log did not preserve app-provided trace id"); @@ -3008,7 +2923,7 @@ namespace VajraSpecCpp fail("access log mixed incoming traceparent span id with app-provided trace id"); } Vajra::runtime::configure_runtime_logging(false, "/dev/null", "", "text"); - std::remove(log_path); + (void)std::remove(log_path.c_str()); } catch (...) { @@ -3019,7 +2934,7 @@ namespace VajraSpecCpp } Vajra::runtime::stop_runtime_logging_worker(); Vajra::runtime::configure_runtime_logging(false, "/dev/null", "", "text"); - std::remove(log_path); + (void)std::remove(log_path.c_str()); throw; } } @@ -3153,7 +3068,7 @@ namespace VajraSpecCpp Vajra::request::ParsedRequestLine{"GET", "/upgrade", "HTTP/1.1"}, {Vajra::request::ParsedHeader{"Host", "localhost"}}}, Vajra::request::SocketContext{"127.0.0.1", 12'345, "127.0.0.1", 3000, "http"}, - -1, + Vajra::platform::kInvalidSocket, "", nullptr, nullptr}, @@ -3401,9 +3316,9 @@ namespace VajraSpecCpp { } - int fd() const override + Vajra::platform::SocketHandle fd() const override { - return -1; + return Vajra::platform::kInvalidSocket; } bool wait_readable(int) override diff --git a/gems/vajra/spec/cpp/ruby_rack_transport_stub.cpp b/gems/vajra/spec/cpp/ruby_rack_transport_stub.cpp index c52eba0..13d9377 100644 --- a/gems/vajra/spec/cpp/ruby_rack_transport_stub.cpp +++ b/gems/vajra/spec/cpp/ruby_rack_transport_stub.cpp @@ -32,7 +32,7 @@ namespace std::optional finish() override { - return transport_.execute(env_entries_, request_body_, -1, nullptr, nullptr); + return transport_.execute(env_entries_, request_body_, Vajra::platform::kInvalidSocket, nullptr, nullptr); } private: @@ -47,7 +47,7 @@ namespace std::optional execute( const std::vector &, const std::string &, - int, + Vajra::platform::SocketHandle, std::shared_ptr, std::shared_ptr) const override { @@ -58,7 +58,7 @@ namespace std::unique_ptr Vajra::rack::RackExecutionTransport::start( const std::vector &env_entries, - int, + platform::SocketHandle, std::shared_ptr) const { return std::make_unique(*this, env_entries); @@ -82,7 +82,7 @@ std::shared_ptr Vajra::rack::same_pro std::optional Vajra::rack::execute_current_thread_rack_request( const std::vector &, const std::string &, - int) + platform::SocketHandle) { throw std::logic_error("Ruby Rack transport is unavailable in native C++ tests"); } diff --git a/gems/vajra/spec/cpp/runtime_logging_test.cpp b/gems/vajra/spec/cpp/runtime_logging_test.cpp index 56966a9..ee2aad3 100644 --- a/gems/vajra/spec/cpp/runtime_logging_test.cpp +++ b/gems/vajra/spec/cpp/runtime_logging_test.cpp @@ -131,6 +131,22 @@ namespace } } + void test_native_otlp_rejects_cleartext_endpoint() + { + Vajra::runtime::configure_runtime_tracing( + true, + "http://127.0.0.1:4318/v1/traces", + "vajra-test", + false); + Vajra::runtime::set_runtime_tracing_available(false); + Vajra::runtime::start_runtime_tracing_worker(); + if (Vajra::runtime::runtime_tracing_available()) + { + VajraSpecCpp::fail("native OTLP should reject cleartext endpoints"); + } + Vajra::runtime::stop_runtime_tracing_worker(); + } + void test_runtime_trace_sampling_uses_ratio_and_parent_flags() { Vajra::runtime::set_runtime_request_observability_callback(reinterpret_cast(1)); @@ -412,6 +428,7 @@ void VajraSpecCpp::run_runtime_logging_tests() test_custom_access_log_needs_token_fields(); test_tracing_forces_trace_context_need(); test_active_context_required_follows_availability(); + test_native_otlp_rejects_cleartext_endpoint(); test_runtime_trace_sampling_uses_ratio_and_parent_flags(); test_traceparent_part_allows_future_version_fields(); test_async_logger_reuses_pooled_nodes(); diff --git a/gems/vajra/spec/cpp/server_lifecycle_test.cpp b/gems/vajra/spec/cpp/server_lifecycle_test.cpp index da1cede..2495278 100644 --- a/gems/vajra/spec/cpp/server_lifecycle_test.cpp +++ b/gems/vajra/spec/cpp/server_lifecycle_test.cpp @@ -270,8 +270,8 @@ namespace VajraSpecCpp { run_stop_interrupt_test( [&](int port, Vajra::Server &server) { - FileDescriptorGuard client_socket(connect_to_listener(port)); - if (client_socket.get() < 0) + SocketGuard client_socket(connect_to_listener(port)); + if (client_socket.get() == Vajra::platform::kInvalidSocket) { fail("failed to connect keep-alive test client"); } @@ -302,8 +302,8 @@ namespace VajraSpecCpp { run_stop_interrupt_test( [&](int port, Vajra::Server &server) { - FileDescriptorGuard client_socket(connect_to_listener(port)); - if (client_socket.get() < 0) + SocketGuard client_socket(connect_to_listener(port)); + if (client_socket.get() == Vajra::platform::kInvalidSocket) { fail("failed to connect partial-next-request test client"); } @@ -342,8 +342,8 @@ namespace VajraSpecCpp { run_stop_interrupt_test( [&](int port, Vajra::Server &server) { - FileDescriptorGuard client_socket(connect_to_listener(port)); - if (client_socket.get() < 0) + SocketGuard client_socket(connect_to_listener(port)); + if (client_socket.get() == Vajra::platform::kInvalidSocket) { fail("failed to connect disconnect-before-stop test client"); } diff --git a/gems/vajra/spec/cpp/server_test.cpp b/gems/vajra/spec/cpp/server_test.cpp index bf005dd..336575f 100644 --- a/gems/vajra/spec/cpp/server_test.cpp +++ b/gems/vajra/spec/cpp/server_test.cpp @@ -23,6 +23,7 @@ int main() { VajraSpecCpp::run_lifecycle_controller_tests(); VajraSpecCpp::run_rack_env_tests(); + VajraSpecCpp::run_platform_socket_tests(); VajraSpecCpp::run_server_lifecycle_tests(); VajraSpecCpp::run_request_head_tests(); VajraSpecCpp::run_response_tests(); diff --git a/gems/vajra/spec/cpp/test_suites.hpp b/gems/vajra/spec/cpp/test_suites.hpp index 7ee2e9b..034e501 100644 --- a/gems/vajra/spec/cpp/test_suites.hpp +++ b/gems/vajra/spec/cpp/test_suites.hpp @@ -10,6 +10,7 @@ namespace VajraSpecCpp { void run_lifecycle_controller_tests(); void run_rack_env_tests(); + void run_platform_socket_tests(); void run_server_lifecycle_tests(); void run_request_head_tests(); void run_response_tests(); diff --git a/gems/vajra/spec/cpp/test_support.cpp b/gems/vajra/spec/cpp/test_support.cpp index 5cf83c4..da8c1ca 100644 --- a/gems/vajra/spec/cpp/test_support.cpp +++ b/gems/vajra/spec/cpp/test_support.cpp @@ -9,26 +9,22 @@ #include "request/request_head_reader.hpp" #include "response/response_writer.hpp" -#include #include #include #include -#include -#include #include -#include #include +#ifndef _WIN32 +#include +#include +#include #include +#endif namespace { using namespace std::chrono_literals; -#ifdef MSG_NOSIGNAL - constexpr int kSendFlags = MSG_NOSIGNAL; -#else - constexpr int kSendFlags = 0; -#endif } [[noreturn]] void VajraSpecCpp::fail(const std::string &message) @@ -36,24 +32,31 @@ namespace throw std::runtime_error(message); } -VajraSpecCpp::FileDescriptorGuard::FileDescriptorGuard(int fd) : fd_(fd) {} +VajraSpecCpp::SocketGuard::SocketGuard(Vajra::platform::SocketHandle fd) : fd_(fd) {} -VajraSpecCpp::FileDescriptorGuard::~FileDescriptorGuard() +VajraSpecCpp::SocketGuard::~SocketGuard() { close_if_open(); } -int VajraSpecCpp::FileDescriptorGuard::get() const +Vajra::platform::SocketHandle VajraSpecCpp::SocketGuard::get() const { return fd_; } -void VajraSpecCpp::FileDescriptorGuard::close_if_open() +Vajra::platform::SocketHandle VajraSpecCpp::SocketGuard::release() +{ + const auto fd = fd_; + fd_ = Vajra::platform::kInvalidSocket; + return fd; +} + +void VajraSpecCpp::SocketGuard::close_if_open() { - if (fd_ >= 0) + if (Vajra::platform::socket_valid(fd_)) { - close(fd_); - fd_ = -1; + Vajra::platform::close_socket(fd_); + fd_ = Vajra::platform::kInvalidSocket; } } @@ -80,8 +83,9 @@ bool VajraSpecCpp::bind_conflict(const std::exception_ptr &error) int VajraSpecCpp::available_port() { - const int fd = socket(AF_INET, SOCK_STREAM, 0); - if (fd < 0) + Vajra::platform::ensure_socket_runtime(); + const auto fd = Vajra::platform::create_tcp_socket(AF_INET, SOCK_STREAM, 0); + if (!Vajra::platform::socket_valid(fd)) { fail("socket failed while allocating test port"); } @@ -91,47 +95,97 @@ int VajraSpecCpp::available_port() addr.sin_addr.s_addr = htonl(INADDR_LOOPBACK); addr.sin_port = htons(0); - if (bind(fd, reinterpret_cast(&addr), sizeof(addr)) < 0) + if (!Vajra::platform::bind_socket(fd, reinterpret_cast(&addr), sizeof(addr))) { - close(fd); - fail("bind failed while allocating test port"); + Vajra::platform::close_socket(fd); + fail("bind failed while allocating test port: " + Vajra::platform::socket_error_message(Vajra::platform::socket_last_error())); } socklen_t len = sizeof(addr); - if (getsockname(fd, reinterpret_cast(&addr), &len) < 0) + if (!Vajra::platform::socket_name(fd, reinterpret_cast(&addr), &len)) { - close(fd); + Vajra::platform::close_socket(fd); fail("getsockname failed while allocating test port"); } const int port = ntohs(addr.sin_port); - close(fd); + Vajra::platform::close_socket(fd); return port; } -int VajraSpecCpp::connect_to_listener(int port) +Vajra::platform::SocketHandle VajraSpecCpp::connect_to_listener(int port) { - const int fd = socket(AF_INET, SOCK_STREAM, 0); - if (fd < 0) + const auto fd = Vajra::platform::create_tcp_socket(AF_INET, SOCK_STREAM, 0); + if (!Vajra::platform::socket_valid(fd)) { fail("socket failed while connecting to test listener"); } sockaddr_in addr{}; addr.sin_family = AF_INET; - addr.sin_port = htons(port); + addr.sin_port = htons(static_cast(port)); addr.sin_addr.s_addr = htonl(INADDR_LOOPBACK); - if (connect(fd, reinterpret_cast(&addr), sizeof(addr)) < 0) + if (!Vajra::platform::connect_socket(fd, reinterpret_cast(&addr), sizeof(addr))) { - close(fd); - return -1; + Vajra::platform::close_socket(fd); + return Vajra::platform::kInvalidSocket; } return fd; } -void VajraSpecCpp::suppress_sigpipe(int fd) +std::array VajraSpecCpp::connected_socket_pair() +{ + Vajra::platform::ensure_socket_runtime(); +#ifndef _WIN32 + std::array sockets{ + Vajra::platform::kInvalidSocket, + Vajra::platform::kInvalidSocket}; + if (socketpair(AF_UNIX, SOCK_STREAM, 0, sockets.data()) != 0) + { + fail("socketpair failed while creating test socket pair"); + } + return sockets; +#else + SocketGuard listener(Vajra::platform::create_tcp_socket(AF_INET, SOCK_STREAM, 0)); + if (!Vajra::platform::socket_valid(listener.get())) + { + fail("socket failed while creating loopback socket pair"); + } + sockaddr_in address{}; + address.sin_family = AF_INET; + address.sin_addr.s_addr = htonl(INADDR_LOOPBACK); + address.sin_port = htons(0); + if (!Vajra::platform::bind_socket( + listener.get(), reinterpret_cast(&address), sizeof(address)) || + !Vajra::platform::listen_socket(listener.get(), 1)) + { + fail("listener setup failed while creating loopback socket pair"); + } + socklen_t address_length = sizeof(address); + if (!Vajra::platform::socket_name( + listener.get(), reinterpret_cast(&address), &address_length)) + { + fail("getsockname failed while creating loopback socket pair"); + } + SocketGuard client(Vajra::platform::create_tcp_socket(AF_INET, SOCK_STREAM, 0)); + if (!Vajra::platform::socket_valid(client.get()) || + !Vajra::platform::connect_socket( + client.get(), reinterpret_cast(&address), address_length)) + { + fail("connect failed while creating loopback socket pair"); + } + const auto server = Vajra::platform::accept_socket(listener.get(), nullptr, nullptr); + if (!Vajra::platform::socket_valid(server)) + { + fail("accept failed while creating loopback socket pair"); + } + return {client.release(), server}; +#endif +} + +void VajraSpecCpp::suppress_sigpipe(Vajra::platform::SocketHandle fd) { #ifdef SO_NOSIGPIPE int opt = 1; @@ -144,12 +198,12 @@ void VajraSpecCpp::suppress_sigpipe(int fd) #endif } -bool VajraSpecCpp::send_all(int fd, const std::string &payload) +bool VajraSpecCpp::send_all(Vajra::platform::SocketHandle fd, const std::string &payload) { std::size_t total_sent = 0; while (total_sent < payload.size()) { - const ssize_t bytes_sent = send(fd, payload.data() + total_sent, payload.size() - total_sent, kSendFlags); + const auto bytes_sent = Vajra::platform::send_socket(fd, payload.data() + total_sent, payload.size() - total_sent); if (bytes_sent < 0) { if (errno == EINTR) @@ -176,7 +230,7 @@ bool VajraSpecCpp::send_all(int fd, const std::string &payload) return true; } -bool VajraSpecCpp::complete_probe_request(int fd) +bool VajraSpecCpp::complete_probe_request(Vajra::platform::SocketHandle fd) { const std::string request = "GET / HTTP/1.1\r\n" @@ -190,18 +244,18 @@ bool VajraSpecCpp::complete_probe_request(int fd) } char buffer[4096]; - const ssize_t bytes_read = recv(fd, buffer, sizeof(buffer), 0); + const auto bytes_read = Vajra::platform::receive_socket(fd, buffer, sizeof(buffer)); return bytes_read > 0; } -std::string VajraSpecCpp::read_all(int fd) +std::string VajraSpecCpp::read_all(Vajra::platform::SocketHandle fd) { std::string response; char buffer[256]; for (;;) { - const ssize_t bytes_read = recv(fd, buffer, sizeof(buffer), 0); + const auto bytes_read = Vajra::platform::receive_socket(fd, buffer, sizeof(buffer)); if (bytes_read < 0) { if (errno == EINTR) @@ -259,14 +313,14 @@ std::size_t VajraSpecCpp::parse_content_length(const std::string &response) return content_length; } -std::string VajraSpecCpp::read_http_response(int fd) +std::string VajraSpecCpp::read_http_response(Vajra::platform::SocketHandle fd) { std::string response; char buffer[256]; while (response.find("\r\n\r\n") == std::string::npos) { - const ssize_t bytes_read = recv(fd, buffer, sizeof(buffer), 0); + const auto bytes_read = Vajra::platform::receive_socket(fd, buffer, sizeof(buffer)); if (bytes_read < 0) { if (errno == EINTR) @@ -290,7 +344,7 @@ std::string VajraSpecCpp::read_http_response(int fd) while (response.size() < total_size) { - const ssize_t bytes_read = recv(fd, buffer, sizeof(buffer), 0); + const auto bytes_read = Vajra::platform::receive_socket(fd, buffer, sizeof(buffer)); if (bytes_read < 0) { if (errno == EINTR) @@ -312,35 +366,17 @@ std::string VajraSpecCpp::read_http_response(int fd) return response.substr(0, total_size); } -bool VajraSpecCpp::peer_closed_within(int fd, int timeout_ms) +bool VajraSpecCpp::peer_closed_within(Vajra::platform::SocketHandle fd, int timeout_ms) { - pollfd descriptor{fd, POLLIN | POLLHUP | POLLERR, 0}; - while (true) { - const int poll_result = poll(&descriptor, 1, timeout_ms); - if (poll_result < 0) - { - if (errno == EINTR) - { - continue; - } - - fail("poll failed while checking socket closure"); - } - - if (poll_result == 0) + if (!Vajra::platform::wait_socket(fd, Vajra::platform::WaitEvent::read, timeout_ms)) { return false; } - if ((descriptor.revents & (POLLHUP | POLLERR)) != 0) - { - return true; - } - char byte = '\0'; - const ssize_t bytes_read = recv(fd, &byte, sizeof(byte), MSG_PEEK); + const auto bytes_read = Vajra::platform::peek_socket(fd, &byte, sizeof(byte)); if (bytes_read < 0) { if (errno == EINTR) @@ -353,6 +389,11 @@ bool VajraSpecCpp::peer_closed_within(int fd, int timeout_ms) return false; } + if (Vajra::platform::socket_error_disconnected(errno)) + { + return true; + } + fail("recv(MSG_PEEK) failed while checking socket closure"); } @@ -364,11 +405,11 @@ void VajraSpecCpp::wait_until_listening(int port) { for (int attempt = 0; attempt < 200; ++attempt) { - const int fd = connect_to_listener(port); - if (fd >= 0) + const auto fd = connect_to_listener(port); + if (Vajra::platform::socket_valid(fd)) { const bool completed_request = complete_probe_request(fd); - close(fd); + Vajra::platform::close_socket(fd); if (completed_request) { return; @@ -383,45 +424,41 @@ void VajraSpecCpp::wait_until_listening(int port) void VajraSpecCpp::assert_can_rebind(int port) { - const int fd = socket(AF_INET, SOCK_STREAM, 0); - if (fd < 0) + const auto fd = Vajra::platform::create_tcp_socket(AF_INET, SOCK_STREAM, 0); + if (!Vajra::platform::socket_valid(fd)) { fail("socket failed while checking port rebind"); } int opt = 1; - if (setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt)) < 0) + if (!Vajra::platform::set_socket_option(fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt))) { - close(fd); + Vajra::platform::close_socket(fd); fail("setsockopt failed while checking port rebind"); } sockaddr_in addr{}; addr.sin_family = AF_INET; addr.sin_addr.s_addr = htonl(INADDR_ANY); - addr.sin_port = htons(port); + addr.sin_port = htons(static_cast(port)); - if (bind(fd, reinterpret_cast(&addr), sizeof(addr)) < 0) + if (!Vajra::platform::bind_socket(fd, reinterpret_cast(&addr), sizeof(addr))) { - close(fd); + Vajra::platform::close_socket(fd); fail("listener port was not released after stop"); } - close(fd); + Vajra::platform::close_socket(fd); } VajraSpecCpp::ReaderOutcome VajraSpecCpp::read_request_head_from_chunks( const std::vector &chunks, std::size_t max_request_head_bytes) { - int sockets[2]; - if (socketpair(AF_UNIX, SOCK_STREAM, 0, sockets) < 0) - { - fail("socketpair failed while setting up reader test"); - } + const auto sockets = connected_socket_pair(); - FileDescriptorGuard reader_socket(sockets[0]); - FileDescriptorGuard writer_socket(sockets[1]); + SocketGuard reader_socket(sockets[0]); + SocketGuard writer_socket(sockets[1]); suppress_sigpipe(writer_socket.get()); Vajra::request::HeadReader reader(max_request_head_bytes); ReaderOutcome outcome{{false, false, "", ""}, nullptr}; @@ -558,19 +595,14 @@ void VajraSpecCpp::expect_reader_error( return; } - fail("reader raised the wrong exception type"); } std::string VajraSpecCpp::send_response_through_socket(const Vajra::response::Response &response) { - int sockets[2]; - if (socketpair(AF_UNIX, SOCK_STREAM, 0, sockets) < 0) - { - fail("socketpair failed while setting up response writer test"); - } + const auto sockets = connected_socket_pair(); - FileDescriptorGuard reader_socket(sockets[0]); - FileDescriptorGuard writer_socket(sockets[1]); + SocketGuard reader_socket(sockets[0]); + SocketGuard writer_socket(sockets[1]); { Vajra::response::ResponseWriter writer; diff --git a/gems/vajra/spec/cpp/test_support.hpp b/gems/vajra/spec/cpp/test_support.hpp index 170faf5..26907f4 100644 --- a/gems/vajra/spec/cpp/test_support.hpp +++ b/gems/vajra/spec/cpp/test_support.hpp @@ -11,6 +11,7 @@ #include "request/request_head_reader.hpp" #include "request/request_head_types.hpp" #include "response/response.hpp" +#include "platform/socket.hpp" #include #include @@ -23,19 +24,20 @@ namespace VajraSpecCpp { [[noreturn]] void fail(const std::string &message); - class FileDescriptorGuard + class SocketGuard { public: - explicit FileDescriptorGuard(int fd); - FileDescriptorGuard(const FileDescriptorGuard &) = delete; - FileDescriptorGuard &operator=(const FileDescriptorGuard &) = delete; - ~FileDescriptorGuard(); + explicit SocketGuard(Vajra::platform::SocketHandle fd); + SocketGuard(const SocketGuard &) = delete; + SocketGuard &operator=(const SocketGuard &) = delete; + ~SocketGuard(); - int get() const; + Vajra::platform::SocketHandle get() const; + Vajra::platform::SocketHandle release(); void close_if_open(); private: - int fd_; + Vajra::platform::SocketHandle fd_; }; struct ReaderOutcome @@ -46,14 +48,15 @@ namespace VajraSpecCpp bool bind_conflict(const std::exception_ptr &error); int available_port(); - int connect_to_listener(int port); - void suppress_sigpipe(int fd); - bool send_all(int fd, const std::string &payload); - bool complete_probe_request(int fd); - std::string read_all(int fd); + Vajra::platform::SocketHandle connect_to_listener(int port); + std::array connected_socket_pair(); + void suppress_sigpipe(Vajra::platform::SocketHandle fd); + bool send_all(Vajra::platform::SocketHandle fd, const std::string &payload); + bool complete_probe_request(Vajra::platform::SocketHandle fd); + std::string read_all(Vajra::platform::SocketHandle fd); std::size_t parse_content_length(const std::string &response); - std::string read_http_response(int fd); - bool peer_closed_within(int fd, int timeout_ms); + std::string read_http_response(Vajra::platform::SocketHandle fd); + bool peer_closed_within(Vajra::platform::SocketHandle fd, int timeout_ms); void wait_until_listening(int port); void assert_can_rebind(int port); ReaderOutcome read_request_head_from_chunks( diff --git a/gems/vajra/spec/e2e/spec_helper.rb b/gems/vajra/spec/e2e/spec_helper.rb index bf6bad2..b8158e8 100644 --- a/gems/vajra/spec/e2e/spec_helper.rb +++ b/gems/vajra/spec/e2e/spec_helper.rb @@ -6,13 +6,19 @@ # LICENSE file in the root directory of this source tree. require_relative '../spec_helper' +require 'fiddle/import' if Gem.win_platform? require 'open3' require 'rbconfig' require 'socket' require 'timeout' module VajraE2EHelpers - PACKAGE_ROOT = File.expand_path('../..', __dir__) + TEST_PACKAGE_ROOT = File.expand_path('../..', __dir__) + PACKAGE_ROOT = if ENV['VAJRA_INSTALLED_GEM'] == '1' + ENV.fetch('VAJRA_INSTALLED_GEM_ROOT') + else + TEST_PACKAGE_ROOT + end LISTENER_HOST = '127.0.0.1' LISTENER_BIND_HOST = '0.0.0.0' HTTP_RESPONSE_READ_TIMEOUT_SECONDS = 2 @@ -46,23 +52,51 @@ module VajraE2EHelpers IDLE_KEEP_ALIVE_CLOSE_TIMEOUT_SECONDS = REQUEST_HEAD_READ_TIMEOUT_SECONDS + 1 def vajra_command(*args) + return packaged_vajra_command + args if ENV['VAJRA_INSTALLED_GEM'] == '1' + return [RbConfig.ruby, '-rbundler/setup', '-Ilib', 'exe/vajra', *args] if Gem.win_platform? + ['bundle', 'exec', RbConfig.ruby, '-Ilib', 'exe/vajra', *args] end def packaged_vajra_command(*args) + if ENV['VAJRA_INSTALLED_GEM'] == '1' + return [ + RbConfig.ruby, + "-I#{File.join(PACKAGE_ROOT, 'lib')}", + File.join(PACKAGE_ROOT, 'exe', 'vajra'), + *args + ] + end + + if Gem.win_platform? + return [ + RbConfig.ruby, + '-rbundler/setup', + "-I#{File.join(PACKAGE_ROOT, 'lib')}", + File.join(PACKAGE_ROOT, 'exe', 'vajra'), + *args + ] + end + ['bundle', 'exec', RbConfig.ruby, "-I#{File.join(PACKAGE_ROOT, 'lib')}", File.join(PACKAGE_ROOT, 'exe', 'vajra'), *args] end def packaged_bundle_command(*args) + return [RbConfig.ruby, '-rbundler/setup', *args.drop(1)] if Gem.win_platform? && args.first == RbConfig.ruby + ['bundle', 'exec', *args] end def inline_ruby_command(script) + return [RbConfig.ruby, "-I#{File.join(PACKAGE_ROOT, 'lib')}", '-e', script] if ENV['VAJRA_INSTALLED_GEM'] == '1' + + return [RbConfig.ruby, '-rbundler/setup', '-Ilib', '-e', script] if Gem.win_platform? + ['bundle', 'exec', RbConfig.ruby, '-Ilib', '-e', script] end def app_root_bundle_env - { 'BUNDLE_GEMFILE' => File.join(PACKAGE_ROOT, 'Gemfile') } + { 'BUNDLE_GEMFILE' => ENV.fetch('VAJRA_PACKAGE_TEST_GEMFILE', File.join(TEST_PACKAGE_ROOT, 'Gemfile')) } end def vajra_env(host: nil, port: nil, max_request_head_bytes: nil) @@ -78,21 +112,37 @@ def listener_banner(port) end def managed_popen2e(*command, **options, &) - Open3.popen2e(*command, **options.merge(pgroup: true), &) + process_options = if Gem.win_platform? + options.merge(new_pgroup: true) + else + options.merge(pgroup: true) + end + Open3.popen2e(*command, **process_options) do |stdin, output, wait_thread| + windows_process_outputs[wait_thread.pid] = output if Gem.win_platform? + yield stdin, output, wait_thread + ensure + windows_process_outputs.delete(wait_thread.pid) if Gem.win_platform? + end + end + + def windows_process_alive?(pid) + output, status = Open3.capture2e('tasklist', '/FI', "PID eq #{pid}", '/NH', '/FO', 'CSV') + status.success? && output.match?(/\A"[^"]+","#{pid}"/) + end + + def windows_process_outputs + @windows_process_outputs ||= {} end def wait_for_banner(output, captured_lines: nil) - Timeout.timeout(15) do + Timeout.timeout(30) do pending_port = nil loop do - line = output.gets - raise 'vajra exited before startup banner' if line.nil? + line = startup_output_line(output, captured_lines) captured_lines << line if captured_lines - match = line.match(/\[Vajra\]\[lifecycle\] .* listening on port (\d+)/) || - line.match(/\[Vajra\]\[lifecycle\] .* event=boot_complete .* port=(\d+)/) || - line.match(/\[Vajra\]\[lifecycle\] .* event=worker_bootstrap_ready .* port=(\d+)/) + match = lifecycle_banner_match(line) return Integer(match[1]) if match raw_match = line.match(%r{\[\d+\] \* (?:Bind:|Listening on) http://[^:]+:(\d+)}) @@ -100,6 +150,24 @@ def wait_for_banner(output, captured_lines: nil) return pending_port if pending_port && line.include?('Worker ') && line.include?(' booted in ') end end + rescue Timeout::Error => e + captured = captured_lines&.join.to_s + raise Timeout::Error, "timed out waiting for Vajra startup banner; output=#{captured.inspect}", e.backtrace + end + + def lifecycle_banner_match(line) + [ + /\[Vajra\]\[lifecycle\] .* listening on port (\d+)/, + /\[Vajra\]\[lifecycle\] .* event=boot_complete .* port=(\d+)/, + /\[Vajra\]\[lifecycle\] .* event=worker_bootstrap_ready .* port=(\d+)/ + ].filter_map { |pattern| line.match(pattern) }.first + end + + def startup_output_line(output, captured_lines) + line = output.gets + return line unless line.nil? + + raise "vajra exited before startup banner: #{captured_lines&.join}" end end diff --git a/gems/vajra/spec/e2e/vajra/configuration_spec.rb b/gems/vajra/spec/e2e/vajra/configuration_spec.rb index 351c38d..173e586 100644 --- a/gems/vajra/spec/e2e/vajra/configuration_spec.rb +++ b/gems/vajra/spec/e2e/vajra/configuration_spec.rb @@ -656,8 +656,15 @@ def in_span(name, **options) span = FakeSpan.new yield span ensure + append_span_log(name:, attributes: options.fetch(:attributes), status: span&.status) + end + + private + + def append_span_log(entry) File.open(ENV.fetch("SPAN_LOG_PATH"), "a") do |file| - file.puts(JSON.generate(name:, attributes: options.fetch(:attributes), status: span&.status)) + file.flock(File::LOCK_EX) + file.puts(JSON.generate(entry)) end end end @@ -725,7 +732,6 @@ def otel_observability_result_from_paths(access_log_path, span_log_path, extra_e "GET /drain-native-observability HTTP/1.1\r\nHost: example.test\r\nConnection: close\r\n\r\n", 'otel_observability_result:drain' ) - wait_for_native_span_log(span_log_path) unless extra_env.fetch('UNSAMPLED_OTEL', '') == '1' stats_response = if include_stats otel_observability_request( selected_port, @@ -751,14 +757,6 @@ def otel_observability_result_from_paths(access_log_path, span_log_path, extra_e end end - def wait_for_native_span_log(span_log_path) - 100.times do - return if File.exist?(span_log_path) && File.read(span_log_path).include?('request_parse_error') - - sleep 0.01 - end - end - def keep_alive_post_sequence_result(script:, request_bodies:) managed_popen2e( vajra_env(port: disposable_listener_port), @@ -871,7 +869,7 @@ def staged_request_result(script:, env:, request_bodies:) it 'fails startup with actionable bind diagnostics and releases startup resources' do blocking_server = bind_port - blocked_port = blocking_server.addr[1] + blocked_port = blocking_server.local_address.ip_port begin failure = startup_failure(port: blocked_port) @@ -1070,16 +1068,19 @@ def staged_request_result(script:, env:, request_bodies:) Vajra.start RUBY + expected_boot_failure = if Gem.win_platform? + 'Unable to start Vajra: Ruby boot failed (worker_boot_failed/boot): worker activation exploded' + else + 'Unable to start Vajra: Ruby worker boot failed (worker_boot_failed/boot): worker activation exploded' + end expect(failure).to match( exitstatus: be_positive, - output: a_string_including( - 'Unable to start Vajra: Ruby worker boot failed (worker_boot_failed/boot): worker activation exploded' - ) + output: a_string_including(expected_boot_failure) ) expect(failure[:output]).not_to include('listening on port') end - it 'preloads once in the master and exposes inherited state to the worker' do + it 'preloads once in the master and bootstraps worker state' do Dir.mktmpdir('vajra-preload-proof') do |dir| trace_path = File.join(dir, 'boot_trace.txt') @@ -1097,7 +1098,13 @@ def staged_request_result(script:, env:, request_bodies:) when "ruby_master_preload" $vajra_preloaded_marker = "preloaded-once" when "ruby_worker_bootstrap" - raise "worker did not inherit preloaded marker" unless $vajra_preloaded_marker == "preloaded-once" + if Gem.win_platform? + raise "Windows worker unexpectedly inherited process-local state" unless $vajra_preloaded_marker.nil? + + $vajra_preloaded_marker = "worker-bootstrapped" + else + raise "worker did not inherit preloaded marker" unless $vajra_preloaded_marker == "preloaded-once" + end end { status: "ready", role: boot_request.fetch(:runtime_role) } @@ -1504,7 +1511,7 @@ def staged_request_result(script:, env:, request_bodies:) it 'prefers VAJRA_PORT over the Ruby port option even when the Ruby port would conflict' do blocking_server = nil blocking_server = bind_port - ruby_port = blocking_server.addr[1] + ruby_port = blocking_server.local_address.ip_port request = request_response_from_inline_start( env: { 'RUBY_PORT' => ruby_port.to_s, 'VAJRA_PORT' => disposable_listener_port.to_s } @@ -1693,7 +1700,7 @@ def staged_request_result(script:, env:, request_bodies:) native_error_span = spans.find do |span| span.fetch('attributes')['vajra.request.outcome'] == 'request_parse_error' end - expect(native_error_span).not_to be_nil + expect(native_error_span).not_to be_nil, "native spans: #{result[:span_lines].join(' | ')}" expect(native_error_span.fetch('attributes')).to include( 'http.response.status_code' => 400, 'vajra.response.sent' => true diff --git a/gems/vajra/spec/e2e/vajra/h2c_integration_spec.rb b/gems/vajra/spec/e2e/vajra/h2c_integration_spec.rb index e301978..9965f77 100644 --- a/gems/vajra/spec/e2e/vajra/h2c_integration_spec.rb +++ b/gems/vajra/spec/e2e/vajra/h2c_integration_spec.rb @@ -147,8 +147,9 @@ def h2_wait_for_response_headers(socket, stream_ids) end def h2_release_response_windows(socket, stream_ids, bytes: 400 * 1024) - h2_window_update(socket, 0, bytes) - stream_ids.each { |stream_id| h2_window_update(socket, stream_id, bytes) } + frames = [h2_frame(8, 0, 0, [bytes].pack('N'))] + frames.concat(stream_ids.map { |stream_id| h2_frame(8, 0, stream_id, [bytes].pack('N')) }) + socket.write(frames.join) end def h2_first_data_stream(socket) diff --git a/gems/vajra/spec/e2e/vajra/rack_hijack_integration_spec.rb b/gems/vajra/spec/e2e/vajra/rack_hijack_integration_spec.rb index b486c08..6dd3562 100644 --- a/gems/vajra/spec/e2e/vajra/rack_hijack_integration_spec.rb +++ b/gems/vajra/spec/e2e/vajra/rack_hijack_integration_spec.rb @@ -116,15 +116,19 @@ def h2_hijack_absence_body(socket) ) body = +''.b + received_frames = [] Timeout.timeout(5) do loop do frame = h2_read_frame(socket) + received_frames << frame.slice(:length, :type, :flags, :stream_id) socket.write(h2_frame(4, 0x1, 0, '')) if frame[:type] == 4 && frame[:flags].nobits?(0x1) body << frame[:payload] if frame[:type].zero? break if frame[:type].zero? && frame[:flags].anybits?(0x1) end end body + rescue Timeout::Error => e + raise Timeout::Error, "#{e.message}; received_frames=#{received_frames.inspect}" end it 'lets an HTTP/1.1 Rack app take over the raw connection' do @@ -241,7 +245,7 @@ def h2_hijack_absence_body(socket) request: "GET /once HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n" ) - expect(result[:response]).to include('IOError:rack.hijack was already called') + expect(result[:response]).to include('IOError:rack.hijack was already called'), result[:output] end it 'lets an HTTP/1.0 Rack app take over the raw connection' do @@ -507,6 +511,8 @@ def h2_hijack_absence_body(socket) expect(status.exitstatus).to eq(0), "#{startup_output.join}#{output.read}" expect(body).to eq('hijack-absent') + rescue Timeout::Error => e + raise Timeout::Error, "#{e.message}; #{process_diagnostics(wait_thread, output)}" ensure socket&.close unless socket&.closed? cleanup_process(wait_thread, output) diff --git a/gems/vajra/spec/e2e/vajra/support/http_helpers.rb b/gems/vajra/spec/e2e/vajra/support/http_helpers.rb index fa54db6..530eab8 100644 --- a/gems/vajra/spec/e2e/vajra/support/http_helpers.rb +++ b/gems/vajra/spec/e2e/vajra/support/http_helpers.rb @@ -45,7 +45,7 @@ def read_http_response( [parse_http_response(http_complete_response(headers, body, content_length)), http_trailing_bytes(body, content_length)] end - rescue EOFError, Errno::ECONNRESET => e + rescue EOFError, Errno::ECONNRESET, Errno::ECONNABORTED => e raise e.class, http_read_failure_message(e, request_label, wait_thread, output, buffered_bytes, response), e.backtrace end @@ -594,7 +594,7 @@ def wait_for_http_response(port, request, wait_thread:, output:, timeout:, reque ensure socket&.close unless socket&.closed? end - rescue Errno::ECONNREFUSED, Errno::EHOSTUNREACH, Errno::ECONNRESET, EOFError, Timeout::Error + rescue Errno::ECONNREFUSED, Errno::EHOSTUNREACH, Errno::ECONNRESET, Errno::ECONNABORTED, EOFError, Timeout::Error if Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline raise Timeout::Error, "#{request_label} timed out: #{process_diagnostics(wait_thread, output)}" end diff --git a/gems/vajra/spec/e2e/vajra/support/process_helpers.rb b/gems/vajra/spec/e2e/vajra/support/process_helpers.rb index 59e316b..96a8e85 100644 --- a/gems/vajra/spec/e2e/vajra/support/process_helpers.rb +++ b/gems/vajra/spec/e2e/vajra/support/process_helpers.rb @@ -6,6 +6,18 @@ # LICENSE file in the root directory of this source tree. module VajraE2EProcessHelpers + WINDOWS_SO_EXCLUSIVEADDRUSE = -5 + WINDOWS_CTRL_BREAK_EVENT = 1 + + if Gem.win_platform? + module WindowsConsoleControl + extend Fiddle::Importer + + dlload 'kernel32.dll' + extern 'int GenerateConsoleCtrlEvent(unsigned long, unsigned long)' + end + end + def read_available_output(output) captured = +'' @@ -41,12 +53,33 @@ def stop_process(wait_thread, signal: 'INT', timeout: 5) wait_for_exit(wait_thread, timeout: timeout) rescue Errno::ESRCH wait_thread.value + rescue Timeout::Error + output = windows_process_outputs[wait_thread.pid] + diagnostic = output.nil? ? "pid=#{wait_thread.pid}" : process_diagnostics(wait_thread, output) + raise Timeout::Error, "process did not exit after #{signal}: #{diagnostic}" end def signal_process_group(wait_thread, signal) - Process.kill(signal, -wait_thread.pid) + if Gem.win_platform? + return Process.kill('KILL', wait_thread.pid) if signal == 'KILL' + + if %w[INT TERM].include?(signal) + result = WindowsConsoleControl.GenerateConsoleCtrlEvent(WINDOWS_CTRL_BREAK_EVENT, wait_thread.pid) + raise SystemCallError.new('GenerateConsoleCtrlEvent', Fiddle.last_error) if result.zero? + + return 1 + end + + return Process.kill(signal, wait_thread.pid) + end + + signal_posix_process_group(wait_thread.pid, signal) + end + + def signal_posix_process_group(pid, signal) + Process.kill(signal, -pid) rescue Errno::ESRCH, Errno::EINVAL - Process.kill(signal, wait_thread.pid) + Process.kill(signal, pid) end def cleanup_process(wait_thread, output) @@ -75,6 +108,14 @@ def disposable_listener_port end def bind_port(port: disposable_listener_port) + if Gem.win_platform? + socket = Socket.new(Socket::AF_INET, Socket::SOCK_STREAM, 0) + socket.setsockopt(Socket::SOL_SOCKET, WINDOWS_SO_EXCLUSIVEADDRUSE, true) + socket.bind(Socket.sockaddr_in(port, VajraE2EHelpers::LISTENER_BIND_HOST)) + socket.listen(1) + return socket + end + TCPServer.new(VajraE2EHelpers::LISTENER_BIND_HOST, port) end @@ -118,7 +159,7 @@ def delayed_request_result( socket.close_write response = begin Timeout.timeout(timeout) { socket.read } - rescue Errno::ECONNRESET + rescue Errno::ECONNRESET, Errno::ECONNABORTED '' end ensure @@ -192,7 +233,7 @@ def wait_for_socket_close(socket, timeout: 1) end rescue Timeout::Error false - rescue Errno::ECONNRESET + rescue Errno::ECONNRESET, Errno::ECONNABORTED true end @@ -248,7 +289,11 @@ def request_chunks_result(chunks:, port: disposable_listener_port, env: {}, time Timeout.timeout(5) { stopper_thread.join } RUBY - managed_popen2e(vajra_env(port:).merge(env), *inline_ruby_command(script), chdir: VajraE2EHelpers::PACKAGE_ROOT) do |stdin, output, wait_thread| + managed_popen2e( + vajra_env(port:).merge(env), + *inline_ruby_command(script), + chdir: VajraE2EHelpers::PACKAGE_ROOT + ) do |stdin, output, wait_thread| startup_output = [] selected_port = wait_for_banner(output, captured_lines: startup_output) @@ -260,10 +305,10 @@ def request_chunks_result(chunks:, port: disposable_listener_port, env: {}, time end socket.close_write response = Timeout.timeout(timeout) { socket.read } - rescue Errno::EPIPE, Errno::ECONNRESET + rescue Errno::EPIPE, Errno::ECONNRESET, Errno::ECONNABORTED begin response = Timeout.timeout(timeout) { socket.read } - rescue Errno::ECONNRESET + rescue Errno::ECONNRESET, Errno::ECONNABORTED response = '' end ensure diff --git a/gems/vajra/spec/e2e/vajra/support/startup_helpers.rb b/gems/vajra/spec/e2e/vajra/support/startup_helpers.rb index 2c532a9..d4a32f5 100644 --- a/gems/vajra/spec/e2e/vajra/support/startup_helpers.rb +++ b/gems/vajra/spec/e2e/vajra/support/startup_helpers.rb @@ -122,7 +122,19 @@ def server_ready?(host, port) Vajra.start Timeout.timeout(5) { stopper_thread.join } - rebound_server = TCPServer.new(bind_host, port) + rebound_server = if Gem.win_platform? + Socket.new(Socket::AF_INET, Socket::SOCK_STREAM, 0).tap do |socket| + socket.setsockopt( + Socket::SOL_SOCKET, + #{VajraE2EProcessHelpers::WINDOWS_SO_EXCLUSIVEADDRUSE}, + true + ) + socket.bind(Socket.sockaddr_in(port, bind_host)) + socket.listen(1) + end + else + TCPServer.new(bind_host, port) + end rebound_server.close RUBY diff --git a/gems/vajra/spec/e2e/vajra/vajra_worker_resilience_spec.rb b/gems/vajra/spec/e2e/vajra/vajra_worker_resilience_spec.rb new file mode 100644 index 0000000..8ccfccd --- /dev/null +++ b/gems/vajra/spec/e2e/vajra/vajra_worker_resilience_spec.rb @@ -0,0 +1,236 @@ +# frozen_string_literal: true + +# Copyright Codevedas Inc. 2025-present +# +# This source code is licensed under the MIT license found in the +# LICENSE file in the root directory of this source tree. + +require_relative 'support' + +RSpec.describe Vajra, :e2e, :integration do + def windows_ipc_fault_result(fault) + script = <<~RUBY + require "vajra" + + Vajra::Internal::RackExecution.install!( + lambda do |_rack_env| + [200, { "Content-Type" => "text/plain" }, ["worker-ok"]] + end + ) + Vajra.start(workers: 1, threads: [1, 1], worker_timeout: 1, log_level: "debug") + RUBY + + env = vajra_env(port: disposable_listener_port).merge('VAJRA_WINDOWS_TEST_FAULT' => fault) + managed_popen2e(env, *inline_ruby_command(script), chdir: VajraE2EHelpers::PACKAGE_ROOT) do |_stdin, output, wait_thread| + startup_output = [] + selected_port = wait_for_banner(output, captured_lines: startup_output) + successful_before_fault = fault == 'duplicate_dispatch' ? resilience_request(selected_port, wait_thread, output) : nil + fault_closed_connection = resilience_fault_request(selected_port) + runtime_output = +'' + wait_for_runtime_output(output, runtime_output, 'event=worker_replacement_ready', timeout: 10) unless fault == 'partial_socket_transfer' + successful_after_fault = resilience_request(selected_port, wait_thread, output) + status = stop_process(wait_thread) + { + exitstatus: status.exitstatus, + fault_closed_connection:, + successful_before_fault:, + successful_after_fault:, + output: "#{startup_output.join}#{runtime_output}#{output.read}" + } + ensure + cleanup_process(wait_thread, output) + end + end + + def resilience_request(port, wait_thread, output) + single_rack_app_response( + selected_port: port, + request: "GET /healthy HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n", + wait_thread:, + output:, + request_label: 'worker_resilience' + ) + end + + def resilience_fault_request(port) + socket = TCPSocket.new(VajraE2EHelpers::LISTENER_HOST, port) + socket.write("GET /fault HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n") + Timeout.timeout(10) { socket.read.empty? } + rescue Errno::ECONNRESET, Errno::ECONNABORTED + true + ensure + socket&.close + end + + def windows_drain_timeout_result + script = <<~RUBY + require "vajra" + Vajra::Internal::RackExecution.install!(->(_env) { [200, { "Content-Type" => "text/plain" }, ["ok"]] }) + Vajra.start(workers: 1, threads: [1, 1], worker_timeout: 1, log_level: "debug") + RUBY + env = vajra_env(port: disposable_listener_port).merge('VAJRA_WINDOWS_TEST_FAULT' => 'drain_timeout') + managed_popen2e(env, *inline_ruby_command(script), chdir: VajraE2EHelpers::PACKAGE_ROOT) do |_stdin, output, wait_thread| + startup_output = [] + selected_port = wait_for_banner(output, captured_lines: startup_output) + response = resilience_request(selected_port, wait_thread, output) + started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC) + status = stop_process(wait_thread, timeout: 10) + elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - started_at + { exitstatus: status.exitstatus, elapsed:, response:, output: "#{startup_output.join}#{output.read}" } + ensure + cleanup_process(wait_thread, output) + end + end + + def windows_replacement_exhaustion_result + script = <<~RUBY + require "vajra" + Vajra::Internal::RackExecution.install!(->(_env) { [200, { "Content-Type" => "text/plain" }, ["ok"]] }) + Vajra.start(workers: 1, threads: [1, 1], worker_timeout: 1, log_level: "debug") + RUBY + env = vajra_env(port: disposable_listener_port).merge('VAJRA_WINDOWS_TEST_FAULT' => 'replacement_exhaustion') + managed_popen2e(env, *inline_ruby_command(script), chdir: VajraE2EHelpers::PACKAGE_ROOT) do |_stdin, output, wait_thread| + startup_output = [] + selected_port = wait_for_banner(output, captured_lines: startup_output) + connection_closed = resilience_fault_request(selected_port) + status = wait_for_exit(wait_thread, timeout: 15) + complete_output = "#{startup_output.join}#{output.read}" + worker_pids = complete_output.scan(/Worker \d+ \(PID: (\d+)\)/).flatten.map { |pid| Integer(pid) } + Timeout.timeout(10) do + sleep 0.05 while worker_pids.any? { |pid| windows_process_alive?(pid) } + end + rebound_listener = bind_port(port: selected_port) + rebound_listener.close + { + exitstatus: status.exitstatus, + connection_closed:, + worker_pids:, + port_reused: true, + output: complete_output + } + ensure + cleanup_process(wait_thread, output) + end + end + + def windows_parent_death_result + script = <<~RUBY + require "vajra" + Vajra::Internal::RackExecution.install!(->(_env) { [200, { "Content-Type" => "text/plain" }, ["ok"]] }) + Vajra.start(workers: 2, threads: [1, 1], log_level: "debug") + RUBY + managed_popen2e( + vajra_env(port: disposable_listener_port), + *inline_ruby_command(script), + chdir: VajraE2EHelpers::PACKAGE_ROOT + ) do |_stdin, output, wait_thread| + startup_output = [] + wait_for_banner(output, captured_lines: startup_output) + runtime_output = startup_output.join + wait_for_runtime_output(output, runtime_output, /Worker \d+ \(PID:/, count: 2, timeout: 10) + worker_pids = runtime_output.scan(/Worker \d+ \(PID: (\d+)\)/).flatten.map { |pid| Integer(pid) } + raise "worker PIDs missing from startup output: #{runtime_output}" unless worker_pids.length == 2 + + signal_process_group(wait_thread, 'KILL') + status = wait_for_exit(wait_thread, timeout: 10) + Timeout.timeout(10) do + sleep 0.05 while worker_pids.any? { |pid| windows_process_alive?(pid) } + end + { termsig: status.termsig, worker_pids: } + ensure + cleanup_process(wait_thread, output) + end + end + + if Gem.win_platform? && ENV['VAJRA_TEST_FAULT_INJECTION'] == '1' + %w[ + malformed_frame oversized_frame unknown_frame stale_generation duplicate_dispatch + partial_socket_transfer dispatch_timeout worker_crash + ].each do |fault| + it "recovers safely from #{fault.tr('_', ' ')}" do + result = windows_ipc_fault_result(fault) + + expect(result[:exitstatus]).to eq(0), result[:output] + expect(result[:fault_closed_connection]).to be(true) + expect(result[:successful_before_fault]).to include(status_line: 'HTTP/1.1 200 OK', body: 'worker-ok') if fault == 'duplicate_dispatch' + expect(result[:successful_after_fault]).to include(status_line: 'HTTP/1.1 200 OK', body: 'worker-ok') + end + end + + %w[bootstrap_failure readiness_timeout].each do |fault| + it "fails startup safely on #{fault.tr('_', ' ')}" do + result = startup_failure_with_inline_start( + 'RUBY_WORKERS' => '1', + 'RUBY_WORKER_TIMEOUT' => '1', + 'VAJRA_WINDOWS_TEST_FAULT' => fault + ) + + expect(result[:exitstatus]).to be_positive + expect(result[:output]).to include('Unable to start Vajra:', 'Windows worker failed to become ready') + end + end + + it 'clears startup state when console handler installation fails' do + script = <<~RUBY + require "vajra" + + first_error = begin + Vajra.start(workers: 1) + "missing first startup error" + rescue => error + error.message + end + second_error = Thread.new do + begin + Vajra.start(workers: 1) + "missing second startup error" + rescue => error + error.message + end + end.value + puts first_error + puts second_error + RUBY + result = startup_failure_with_inline_script( + script, + env: { 'VAJRA_WINDOWS_TEST_FAULT' => 'console_handler_failure' } + ) + + expect(result[:exitstatus]).to eq(0), result[:output] + expect(result[:output]).to include( + 'injected Windows console control handler failure', + 'worker-only Vajra.start must be invoked from the Ruby main thread' + ) + expect(result[:output]).not_to include('Vajra already running') + end + + it 'forces a worker that exceeds its drain deadline to exit' do + result = windows_drain_timeout_result + + expect(result[:exitstatus]).to eq(0), result[:output] + expect(result[:elapsed]).to be_between(1, 10) + expect(result[:response]).to include(status_line: 'HTTP/1.1 200 OK', body: 'ok') + expect(result[:output]).to include('- Gracefully shutting down workers...', 'Goodbye!') + end + + it 'bounds replacement attempts and reports terminal failure' do + result = windows_replacement_exhaustion_result + + expect(result[:connection_closed]).to be(true) + expect(result[:exitstatus]).to be_positive + expect(result[:worker_pids]).to contain_exactly(be_positive) + expect(result[:port_reused]).to be(true) + expect(result[:output]).to include( + 'event=worker_replacement_terminal_failure', + 'Windows worker replacement attempts exhausted' + ) + end + + it 'terminates every worker when its parent is killed' do + result = windows_parent_death_result + + expect(result[:termsig]).to be_nil + expect(result[:worker_pids]).to contain_exactly(be_positive, be_positive) + end + end +end diff --git a/gems/vajra/spec/spec_helper.rb b/gems/vajra/spec/spec_helper.rb index 30ac56e..78a3241 100644 --- a/gems/vajra/spec/spec_helper.rb +++ b/gems/vajra/spec/spec_helper.rb @@ -10,13 +10,21 @@ SimpleCov.start do enable_coverage :branch track_files 'lib/**/*.rb' - add_filter '/spec/' + project_lib = "#{File.expand_path('../lib', __dir__).tr('\\', '/')}/" + add_filter do |source_file| + !source_file.filename.tr('\\', '/').start_with?(project_lib) + end minimum_coverage line: 100, branch: 100 end end -require_relative '../lib/vajra/version' -require_relative '../lib/vajra' +if ENV['VAJRA_INSTALLED_GEM'] == '1' + require 'vajra/version' + require 'vajra' +else + require_relative '../lib/vajra/version' + require_relative '../lib/vajra' +end RSpec.configure do |config| # Enable flags like --only-failures and --next-failure diff --git a/gems/vajra/spec/support/documented_server_options.rb b/gems/vajra/spec/support/documented_server_options.rb index 597b802..976b1d8 100644 --- a/gems/vajra/spec/support/documented_server_options.rb +++ b/gems/vajra/spec/support/documented_server_options.rb @@ -36,7 +36,7 @@ def native_config_file_contents config.stats_path "/__vajra/stats" config.metrics_endpoint "/metrics" config.trace_enabled - config.trace_endpoint "http://127.0.0.1:4318/v1/traces" + config.trace_endpoint "https://collector.example.test/v1/traces" config.trace_service_name "vajra-test" config.trace_otel_owner config.max_request_head_bytes 2048 @@ -73,7 +73,7 @@ def native_start_options stats_path: '/__vajra/stats', metrics_endpoint: '/metrics', trace_enabled: true, - trace_endpoint: 'http://127.0.0.1:4318/v1/traces', + trace_endpoint: 'https://collector.example.test/v1/traces', trace_otel_owner: true, trace_service_name: 'vajra-test', max_request_head_bytes: 2048, diff --git a/gems/vajra/spec/vajra/internal/rack_execution_spec.rb b/gems/vajra/spec/vajra/internal/rack_execution_spec.rb index 2b4bc92..62653d3 100644 --- a/gems/vajra/spec/vajra/internal/rack_execution_spec.rb +++ b/gems/vajra/spec/vajra/internal/rack_execution_spec.rb @@ -106,6 +106,16 @@ def closed? expect(described_class.call([%w[REQUEST_METHOD GET]], ''.b)).to be_nil end + it 'cleans up safely when native extension loading failed' do + allow(described_class).to receive(:method).and_call_original + allow(described_class).to receive(:method) + .with(:__native_set_app__) + .and_raise(NameError) + + expect { described_class.uninstall! }.not_to raise_error + expect(described_class.installed?).to be(false) + end + it 'wraps native-built Rack env execution in request tracing' do env = { 'REQUEST_METHOD' => 'GET', 'rack.input' => Vajra::NativeInput.from_string('') } app = lambda { |rack_env| diff --git a/gems/vajra/spec/vajra/internal/tracing_spec.rb b/gems/vajra/spec/vajra/internal/tracing_spec.rb index c51d3ed..1343fd9 100644 --- a/gems/vajra/spec/vajra/internal/tracing_spec.rb +++ b/gems/vajra/spec/vajra/internal/tracing_spec.rb @@ -178,7 +178,7 @@ def stub_trace_with_span(ok_status: nil, error_status: nil) end it 'warns and stays boot-safe when OpenTelemetry gems are unavailable' do - allow(Kernel).to receive(:require).with('opentelemetry/sdk').and_raise(LoadError) + allow(described_class).to receive(:require).with('opentelemetry/sdk').and_raise(LoadError) expect( described_class.install_from_start_options!( @@ -217,14 +217,14 @@ def stub_trace_with_span(ok_status: nil, error_status: nil) expect( described_class.install_from_start_options!( trace_enabled: true, - trace_endpoint: 'http://127.0.0.1:4318/v1/traces', + trace_endpoint: 'https://collector.example.test/v1/traces', trace_service_name: 'vajra-test', trace_otel_owner: true ) ).to be(true) expect(described_class).to have_received(:__native_set_tracing_status__) - .with(true, true, 'http://127.0.0.1:4318/v1/traces', 'vajra-test', false, 1.0, '', 'tracecontext,baggage') + .with(true, true, 'https://collector.example.test/v1/traces', 'vajra-test', false, 1.0, '', 'tracecontext,baggage') end it 'installs native callbacks for metrics-only telemetry without reporting tracing as available' do @@ -248,14 +248,14 @@ def stub_trace_with_span(ok_status: nil, error_status: nil) expect( described_class.install_from_start_options!( trace_enabled: true, - trace_endpoint: 'http://127.0.0.1:4318/v1/traces', + trace_endpoint: 'https://collector.example.test/v1/traces', trace_service_name: 'vajra-test', trace_otel_owner: true ) ).to be(true) expect(described_class).to have_received(:__native_set_tracing_status__) - .with(true, true, 'http://127.0.0.1:4318/v1/traces', 'vajra-test', false, 1.0, '', 'tracecontext,baggage') + .with(true, true, 'https://collector.example.test/v1/traces', 'vajra-test', false, 1.0, '', 'tracecontext,baggage') expect(described_class).to have_received(:__native_set_lifecycle_callback__).with(nil) expect(described_class).to have_received(:__native_set_request_observability_callback__).with(nil) end @@ -866,7 +866,7 @@ def tracer(_name) config = described_class.send( :resolve_config, trace_enabled: true, - trace_endpoint: 'http://127.0.0.1:4318/v1/traces', + trace_endpoint: 'https://collector.example.test/v1/traces', trace_service_name: 'vajra-test', trace_otel_owner: true ) @@ -877,10 +877,10 @@ def tracer(_name) expect(described_class).not_to have_received(:require).with('opentelemetry/sdk') end - it 'does not fall back to Ruby SDK export for unsupported native OTLP endpoints' do + it 'does not use native OTLP export for cleartext endpoints' do config = described_class::TraceConfig.new( enabled: true, - endpoint: 'https://collector.example.test/v1/traces', + endpoint: 'http://collector.example.test/v1/traces', service_name: 'vajra-test', otel_owner: true, traces_exporter: 'otlp', @@ -891,9 +891,26 @@ def tracer(_name) expect(described_class.send(:native_tracing_configured?, config)).to be(false) end + it 'requires HTTPS for native OTLP export' do + config = described_class::TraceConfig.new( + traces_exporter: 'otlp', + sampler: '' + ) + + ['https://collector.example.test:4318', 'https://127.0.0.1:4318'].each do |endpoint| + config.endpoint = endpoint + expect(described_class.send(:native_tracing_configured?, config)).to be(true) + end + + ['http://collector.example.test:4318', 'https:/missing-host', 'not a URL'].each do |endpoint| + config.endpoint = endpoint + expect(described_class.send(:native_tracing_configured?, config)).to be(false) + end + end + it 'uses native tracing only when OTEL traces exporters include OTLP' do otlp_exporter = described_class::TraceConfig.new( - endpoint: 'http://127.0.0.1:4318/v1/traces', + endpoint: 'https://collector.example.test/v1/traces', traces_exporter: ' console, OTLP ', sampler: '' ) @@ -1966,6 +1983,14 @@ def shutdown expect(described_class).to have_received(:start_request_observability_drain_thread) end + it 'drains queued request observability before a native worker exits' do + allow(described_class).to receive(:drain_request_observability_batch).and_return(1, 0) + + described_class.before_worker_exit! + + expect(described_class).to have_received(:drain_request_observability_batch).twice + end + it 'shuts down cleanly when a provider has no lifecycle APIs' do provider = Object.new described_class.send(:write_trace_state, enabled: true, available: true, tracer: tracer, meter: nil, provider:) diff --git a/gems/vajra/spec/vajra/native_extension_spec.rb b/gems/vajra/spec/vajra/native_extension_spec.rb index ce7ab9a..d766d22 100644 --- a/gems/vajra/spec/vajra/native_extension_spec.rb +++ b/gems/vajra/spec/vajra/native_extension_spec.rb @@ -5,16 +5,20 @@ # This source code is licensed under the MIT license found in the # LICENSE file in the root directory of this source tree. +require 'fileutils' +require 'tmpdir' + RSpec.describe Vajra::NativeExtension do let(:failing_loader) do proc { |_path| raise LoadError, 'cannot load such file -- vajra/vajra' } end it 'raises an actionable error when the native extension cannot be loaded' do - expect { described_class.load!(loader: failing_loader) }.to raise_error( - LoadError, - /bundle exec rake compile/ - ) + expect { described_class.load!(loader: failing_loader) }.to raise_error(LoadError) do |error| + expect(error.message).to include('bundle exec rake compile') + expect(error.message).to include("Ruby ABI: #{RUBY_PLATFORM}") + expect(error.message).to include('requires RubyInstaller UCRT Ruby') + end end it 'preserves the original load error backtrace' do @@ -31,12 +35,33 @@ it 'loads only the native extension for the current platform' do loader = instance_double(Method, call: true) + packaged_extension = File.join( + described_class.packaged_native_root, + "vajra.#{RbConfig::CONFIG.fetch('DLEXT')}" + ) + expected_extension = if File.file?(packaged_extension) + packaged_extension + else + implementation_root = File.dirname(described_class.method(:load!).source_location.fetch(0)) + File.expand_path("vajra/vajra.#{RbConfig::CONFIG.fetch('DLEXT')}", implementation_root) + end described_class.load!(loader: loader) - expect(loader).to have_received(:call).with( - File.expand_path("../../lib/vajra/vajra.#{RbConfig::CONFIG.fetch('DLEXT')}", __dir__) - ) + expect(loader).to have_received(:call).with(expected_extension) + end + + it 'loads the ABI-scoped extension from a precompiled package' do + loader = instance_double(Method, call: true) + + Dir.mktmpdir('vajra-native-package') do |dir| + extension = File.join(dir, "vajra.#{RbConfig::CONFIG.fetch('DLEXT')}") + File.write(extension, '') + allow(described_class).to receive(:packaged_native_root).and_return(dir) + + expect(described_class.load!(loader:)).to be(true) + expect(loader).to have_received(:call).with(extension) + end end it 'normalizes a callable loader result to a boolean' do @@ -44,4 +69,199 @@ expect(described_class.load!(loader: loader, extension_path: '/tmp/vajra.bundle')).to be(true) end + + it 'rejects packaged extensions for another Windows ABI' do + Dir.mktmpdir('vajra-native-abi') do |dir| + metadata_path = File.join(dir, 'native_abi.json') + File.write( + metadata_path, + JSON.generate( + platform: 'x64-mswin64', + ruby_api_version: '0.0.0', + architecture: 'x64-mswin64', + compiler_family: 'msvc', + runtime_abi: 'msvc' + ) + ) + + expect { described_class.validate_abi!(metadata_path:) }.to raise_error( + LoadError, + /Vajra native extension ABI mismatch/ + ) + end + end + + it 'discovers and rejects foreign ABI metadata before selecting an extension' do + Dir.mktmpdir('vajra-native-packages') do |dir| + api_version = RbConfig::CONFIG.fetch('ruby_version') + native_root = File.join(dir, 'x64-mswin64', api_version) + FileUtils.mkdir_p(native_root) + File.write( + File.join(native_root, 'native_abi.json'), + JSON.generate( + platform: 'x64-mswin64', + ruby_api_version: api_version, + architecture: 'x64-mswin64', + compiler_family: 'msvc', + runtime_abi: 'msvc' + ) + ) + allow(described_class).to receive(:native_packages_root).and_return(dir) + + expect { described_class.load!(loader: instance_double(Method)) }.to raise_error( + LoadError, + /Package ABI: x64-mswin64.*Runtime ABI:/m + ) + end + end + + it 'rejects packages containing multiple ABI metadata files for one Ruby API' do + Dir.mktmpdir('vajra-native-packages') do |dir| + api_version = RbConfig::CONFIG.fetch('ruby_version') + %w[x64-mingw-ucrt x64-mswin64].each do |platform| + native_root = File.join(dir, platform, api_version) + FileUtils.mkdir_p(native_root) + File.write(File.join(native_root, 'native_abi.json'), '{}') + end + allow(described_class).to receive(:native_packages_root).and_return(dir) + + expect { described_class.packaged_native_metadata_path }.to raise_error( + LoadError, + /multiple ABI metadata files/ + ) + end + end + + it 'rejects a native package containing only a foreign Ruby API' do + Dir.mktmpdir('vajra-native-packages') do |dir| + foreign_api = '0.0.0' + native_root = File.join(dir, 'x64-mingw-ucrt', foreign_api) + FileUtils.mkdir_p(native_root) + File.write(File.join(native_root, 'native_abi.json'), '{}') + allow(described_class).to receive(:native_packages_root).and_return(dir) + + expect { described_class.packaged_native_metadata_path }.to raise_error( + LoadError, + /Package Ruby APIs: #{Regexp.escape(foreign_api)}.*Runtime Ruby API: /m + ) + end + end + + it 'reports every packaged API when none matches the running Ruby' do + Dir.mktmpdir('vajra-native-packages') do |dir| + %w[1.0.0 2.0.0].each do |api_version| + native_root = File.join(dir, 'x64-mingw-ucrt', api_version) + FileUtils.mkdir_p(native_root) + File.write(File.join(native_root, 'native_abi.json'), '{}') + end + allow(described_class).to receive(:native_packages_root).and_return(dir) + + expect { described_class.packaged_native_metadata_path }.to raise_error( + LoadError, + /Package Ruby APIs: 1\.0\.0, 2\.0\.0/ + ) + end + end + + it 'selects the current API from a valid merged native package' do + Dir.mktmpdir('vajra-native-packages') do |dir| + current_api = RbConfig::CONFIG.fetch('ruby_version') + %W[0.0.0 #{current_api}].each do |api_version| + native_root = File.join(dir, 'x64-mingw-ucrt', api_version) + FileUtils.mkdir_p(native_root) + File.write(File.join(native_root, 'native_abi.json'), '{}') + end + allow(described_class).to receive(:native_packages_root).and_return(dir) + + expect(described_class.packaged_native_metadata_path).to eq( + File.join(dir, 'x64-mingw-ucrt', current_api, 'native_abi.json') + ) + end + end + + it 'allows source gems without native ABI metadata' do + Dir.mktmpdir('vajra-native-packages') do |dir| + allow(described_class).to receive(:native_packages_root).and_return(dir) + + expect(described_class.validate_abi!).to be(true) + end + end + + it 'selects the packaged native root from discovered metadata' do + Dir.mktmpdir('vajra-native-packages') do |dir| + native_root = File.join(dir, 'x64-mingw-ucrt', RbConfig::CONFIG.fetch('ruby_version')) + FileUtils.mkdir_p(native_root) + File.write(File.join(native_root, 'native_abi.json'), '{}') + allow(described_class).to receive(:native_packages_root).and_return(dir) + + expect(described_class.packaged_native_root).to eq(native_root) + end + end + + it 'rejects an explicitly selected package whose ABI metadata is missing' do + Dir.mktmpdir('vajra-native-packages') do |dir| + missing_metadata = File.join(dir, 'native_abi.json') + + expect { described_class.validate_abi!(metadata_path: missing_metadata) }.to raise_error( + LoadError, + /ABI metadata is missing/ + ) + end + end + + it 'accepts metadata matching the current Windows Ruby ABI contract' do + stub_const('RUBY_PLATFORM', 'x64-mingw-ucrt') + allow(Gem).to receive(:win_platform?).and_return(true) + + Dir.mktmpdir('vajra-native-abi') do |dir| + metadata_path = File.join(dir, 'native_abi.json') + File.write( + metadata_path, + JSON.generate( + platform: 'x64-mingw-ucrt', + ruby_api_version: RbConfig::CONFIG.fetch('ruby_version'), + architecture: RbConfig::CONFIG.fetch('arch'), + compiler_family: 'mingw', + runtime_abi: 'ucrt' + ) + ) + + expect(described_class.validate_abi!(metadata_path:)).to be(true) + end + end + + it 'rejects MSVC-built Ruby with an actionable installation message' do + stub_const('RUBY_PLATFORM', 'x64-mswin64') + allow(Gem).to receive(:win_platform?).and_return(true) + + expect { described_class.ensure_supported_windows_abi! }.to raise_error( + LoadError, + /does not support MSVC-built Ruby.*x64-mingw-ucrt/m + ) + end + + it 'accepts RubyInstaller UCRT Ruby on Windows' do + stub_const('RUBY_PLATFORM', 'x64-mingw-ucrt') + allow(Gem).to receive(:win_platform?).and_return(true) + + expect(described_class.ensure_supported_windows_abi!).to be(true) + end + + it 'does not apply the Windows ABI restriction on other platforms' do + allow(Gem).to receive(:win_platform?).and_return(false) + + expect(described_class.ensure_supported_windows_abi!).to be(true) + end + + it 'rejects malformed native ABI metadata' do + Dir.mktmpdir('vajra-native-abi') do |dir| + metadata_path = File.join(dir, 'native_abi.json') + File.write(metadata_path, '{') + + expect { described_class.validate_abi!(metadata_path:) }.to raise_error( + LoadError, + /Invalid Vajra native ABI metadata/ + ) + end + end end diff --git a/gems/vajra/vajra.gemspec b/gems/vajra/vajra.gemspec index 233248f..c2c5f99 100644 --- a/gems/vajra/vajra.gemspec +++ b/gems/vajra/vajra.gemspec @@ -45,7 +45,10 @@ Gem::Specification.new do |spec| '.ruby-version' ], base: File.expand_path(__dir__) - ) + ).reject do |path| + path.match?(%r{\Aext/vajra/(?:Makefile|mkmf\.log)\z}) || + path.match?(/\.(?:o|obj|lib|exp)\z/i) + end spec.require_paths = ['lib'] spec.extensions = ['ext/vajra/extconf.rb'] end