Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 38 additions & 1 deletion .gitea/workflows/bazel.yml
Original file line number Diff line number Diff line change
Expand Up @@ -56,8 +56,45 @@ jobs:
build --@boost.asio//:ssl=boringssl

- name: Bazel Test
id: test
shell: bash
run: |
bazel test framework/...
set -o pipefail
bazel test //framework/... --test_output=errors --test_verbose_timeout_warnings 2>&1 | tee ci-test.log
- name: HTTP concurrency benchmark smoke test
id: concurrency_benchmark
shell: bash
run: |
set -o pipefail
bazel run //framework/tests:concurrency_benchmark -- \
--server-threads 2 --concurrency 8 --requests 80 --warmup 8 \
2>&1 | tee ci-concurrency-benchmark.log
- name: Publish test and benchmark summary
if: always()
shell: bash
env:
TEST_RESULT: ${{ steps.test.outcome }}
BENCHMARK_RESULT: ${{ steps.concurrency_benchmark.outcome }}
run: |
summary_file="${GITHUB_STEP_SUMMARY:-}"
if [ -n "$summary_file" ]; then
{
echo "## khttpd framework tests (${{ matrix.os }})"
echo
echo "| Stage | Result |"
echo "| --- | --- |"
echo "| Test | ${TEST_RESULT} |"
echo "| Benchmark | ${BENCHMARK_RESULT} |"
echo
echo '```text'
sed -n '/khttpd HTTP 并发基准测试/,$p' ci-concurrency-benchmark.log 2>/dev/null || true
echo '```'
} >> "$summary_file"
else
echo "khttpd test: ${TEST_RESULT}"
echo "khttpd concurrency benchmark: ${BENCHMARK_RESULT}"
sed -n '/khttpd HTTP 并发基准测试/,$p' ci-concurrency-benchmark.log 2>/dev/null || true
fi
example:
needs: build
strategy:
Expand Down
23 changes: 23 additions & 0 deletions .github/workflows/bazel.yml
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,14 @@ jobs:
run: |
set -o pipefail
bazel test //framework/... --test_output=errors --test_verbose_timeout_warnings --verbose_failures 2>&1 | tee ci-test.log
- name: HTTP concurrency benchmark smoke test
id: concurrency_benchmark
shell: bash
run: |
set -o pipefail
bazel run //framework/tests:concurrency_benchmark -- \
--server-threads 2 --concurrency 8 --requests 80 --warmup 8 \
2>&1 | tee ci-concurrency-benchmark.log
- name: Upload failed test logs
if: failure()
uses: actions/upload-artifact@v4
Expand All @@ -73,6 +81,7 @@ jobs:
path: |
bazel-testlogs/**
ci-test.log
ci-concurrency-benchmark.log
if-no-files-found: ignore
retention-days: 3
- name: Publish test summary
Expand All @@ -93,6 +102,20 @@ jobs:
echo "- Ref: \`${GITHUB_REF}\`"
echo "- Commit: \`${GITHUB_SHA}\`"
} >> "${GITHUB_STEP_SUMMARY}"
{
echo
echo "### HTTP concurrency benchmark smoke test"
echo
echo "| Stage | Result |"
echo "| --- | --- |"
echo "| Benchmark | ${{ steps.concurrency_benchmark.outcome }} |"
if [ -f ci-concurrency-benchmark.log ]; then
echo
echo '```text'
sed -n '/khttpd HTTP/,$p' ci-concurrency-benchmark.log
echo '```'
fi
} >> "${GITHUB_STEP_SUMMARY}"
FAILED_LOG_ARTIFACT=khttpd-${{ matrix.os }}-test-diagnostics \
.github/scripts/write_failure_summary.sh
example:
Expand Down
2 changes: 1 addition & 1 deletion MODULE.bazel
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
module(
name = "khttpd",
version = "0.4.6",
version = "0.5.0",
)

bazel_dep(name = "platforms", version = "1.1.0")
Expand Down
11 changes: 11 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -629,6 +629,17 @@ router.map_exception<ValidationError>([](const ValidationError& e) {
`HttpException` is available when an exception should carry an HTTP status, JSON body, and validated headers directly.
Unmapped exceptions return a generic JSON 500 response; exception details are logged server-side but are not sent to clients.

### HTTP Concurrency Benchmark

The framework includes a local benchmark that starts a real `Server`, sends concurrent HTTP requests, and reports success/failure counts, throughput, average latency, and p50/p95/p99 latency:

```bash
bazel run //framework/tests:concurrency_benchmark -- \
--server-threads 4 --concurrency 32 --requests 2000 --warmup 100
```

The defaults are intentionally bounded for development and CI. Adjust `--concurrency`, `--requests`, and `--server-threads` for a capacity test on the target machine. The result is a measurement of this workload, not a fixed framework limit; CPU, memory, file-descriptor limits, kernel backlog, payload size, and connection reuse all affect the supported concurrency.

## License

MIT License — see [LICENSE](LICENSE) for details.
2 changes: 1 addition & 1 deletion example/MODULE.bazel
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ bazel_dep(name = "boost", version = "1.90.0.bcr.1")
bazel_dep(name = "boost.asio", version = "1.90.0.bcr.1")
bazel_dep(name = "boost.mysql", version = "1.90.0.bcr.1")
bazel_dep(name = "spdlog", version = "1.17.0")
bazel_dep(name = "khttpd", version = "0.4.6")
bazel_dep(name = "khttpd", version = "0.5.0")
local_path_override(
module_name = "khttpd",
path = "..",
Expand Down
47 changes: 32 additions & 15 deletions framework/router/http_router.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,20 @@ namespace khttpd::framework

HttpRouter::HttpRouter() = default;

void HttpRouter::set_base_path(std::string path)
{
if (!path.empty() && path.front() != '/') throw std::invalid_argument("router base path must start with '/'");
while (path.size() > 1 && path.back() == '/') path.pop_back();
base_path_ = path == "/" ? std::string{} : std::move(path);
}

std::string HttpRouter::apply_base_path(const std::string& path) const
{
if (base_path_.empty()) return path;
if (path.empty() || path.front() != '/') throw std::invalid_argument("route path must start with '/'");
return base_path_ + (path == "/" ? std::string{} : path);
}

std::tuple<std::regex, std::vector<std::string>, int, int> HttpRouter::parse_path_pattern(
const std::string& path_pattern)
{
Expand Down Expand Up @@ -157,30 +171,31 @@ namespace khttpd::framework
RouteDocumentation documentation,
std::vector<RouteParameterDocumentation> parameters)
{
const auto registered_path = apply_base_path(path_pattern);
if (documented)
record_route_descriptor(path_pattern, method, std::move(request_schema), std::move(response_schema),
record_route_descriptor(registered_path, method, std::move(request_schema), std::move(response_schema),
std::move(documentation), std::move(parameters));
else
route_descriptors_.erase(std::remove_if(route_descriptors_.begin(), route_descriptors_.end(),
[&](const RouteDescriptor& descriptor)
{
return descriptor.path == path_pattern && descriptor.method == method;
return descriptor.path == registered_path && descriptor.method == method;
}), route_descriptors_.end());

for (auto& entry : routes_)
{
if (entry.original_path == path_pattern)
if (entry.original_path == registered_path)
{
entry.handlers[method] = std::move(handler);
spdlog::debug("Updated handler for route: {} {}", std::string(boost::beast::http::to_string(method)),
path_pattern);
registered_path);
return;
}
}

RouteEntry new_entry;
new_entry.original_path = path_pattern;
auto [regex, params, literal_count, dynamic_count] = parse_path_pattern(path_pattern);
new_entry.original_path = registered_path;
auto [regex, params, literal_count, dynamic_count] = parse_path_pattern(registered_path);
new_entry.path_regex = std::move(regex);
new_entry.param_names = std::move(params);
new_entry.literal_segments_count = literal_count;
Expand All @@ -190,7 +205,7 @@ namespace khttpd::framework
routes_.push_back(std::move(new_entry));
std::sort(routes_.begin(), routes_.end(), RouteEntry::compare_specificity);
spdlog::debug("Registered dynamic route: {} {} (literal:{}, dynamic:{})",
std::string(boost::beast::http::to_string(method)), path_pattern, literal_count, dynamic_count);
std::string(boost::beast::http::to_string(method)), registered_path, literal_count, dynamic_count);
}

void HttpRouter::add_typed_route(const std::string& path_pattern,
Expand Down Expand Up @@ -327,18 +342,19 @@ namespace khttpd::framework
void HttpRouter::stream(const std::string& path_pattern, const boost::beast::http::verb method,
HttpStreamHandler handler)
{
record_route_descriptor(path_pattern, method);
const auto registered_path = apply_base_path(path_pattern);
record_route_descriptor(registered_path, method);
for (auto& entry : routes_)
{
if (entry.original_path == path_pattern)
if (entry.original_path == registered_path)
{
entry.stream_handlers[method] = std::move(handler);
return;
}
}
RouteEntry entry;
entry.original_path = path_pattern;
auto [regex, params, literal_count, dynamic_count] = parse_path_pattern(path_pattern);
entry.original_path = registered_path;
auto [regex, params, literal_count, dynamic_count] = parse_path_pattern(registered_path);
entry.path_regex = std::move(regex);
entry.param_names = std::move(params);
entry.literal_segments_count = literal_count;
Expand Down Expand Up @@ -437,18 +453,19 @@ namespace khttpd::framework
void HttpRouter::async_route(const std::string& path, boost::beast::http::verb method,
HttpAsyncHandler handler)
{
record_route_descriptor(path, method);
auto [path_regex, param_names, literal_count, dynamic_count] = parse_path_pattern(path);
const auto registered_path = apply_base_path(path);
record_route_descriptor(registered_path, method);
auto [path_regex, param_names, literal_count, dynamic_count] = parse_path_pattern(registered_path);
for (auto& entry : routes_)
{
if (entry.original_path == path)
if (entry.original_path == registered_path)
{
entry.async_handlers[method] = std::move(handler);
return;
}
}
RouteEntry entry;
entry.original_path = path;
entry.original_path = registered_path;
entry.path_regex = std::move(path_regex);
entry.param_names = std::move(param_names);
entry.literal_segments_count = literal_count;
Expand Down
5 changes: 5 additions & 0 deletions framework/router/http_router.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,9 @@ namespace khttpd::framework
public:
HttpRouter();

void set_base_path(std::string path);
const std::string& base_path() const { return base_path_; }

void get(const std::string& path, HttpHandler handler);
void post(const std::string& path, HttpHandler handler);
void put(const std::string& path, HttpHandler handler);
Expand Down Expand Up @@ -542,6 +545,7 @@ namespace khttpd::framework

std::vector<RouteEntry> routes_;
std::vector<RouteDescriptor> route_descriptors_;
std::string base_path_;
std::vector<std::shared_ptr<Interceptor>> interceptors_;

std::vector<std::shared_ptr<ExceptionHandlerBase>> exception_handlers_;
Expand All @@ -555,6 +559,7 @@ namespace khttpd::framework
bool documented = true,
RouteDocumentation documentation = {},
std::vector<RouteParameterDocumentation> parameters = {});
std::string apply_base_path(const std::string& path) const;
void add_typed_route(const std::string& path_pattern, boost::beast::http::verb method,
detail::TypedRouteHandler handler, RouteDocumentation documentation = {});
void record_route_descriptor(const std::string& path, boost::beast::http::verb method,
Expand Down
17 changes: 13 additions & 4 deletions framework/router/websocket_router.cpp
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
#include "websocket_router.hpp"

#include <algorithm>
#include <stdexcept>
#include <spdlog/spdlog.h>
#include "websocket/websocket_session.hpp"

Expand All @@ -18,18 +19,26 @@ namespace khttpd::framework

WebsocketRouter::WebsocketRouter() = default;

void WebsocketRouter::set_base_path(std::string path)
{
if (!path.empty() && path.front() != '/') throw std::invalid_argument("router base path must start with '/'");
while (path.size() > 1 && path.back() == '/') path.pop_back();
base_path_ = path == "/" ? std::string{} : std::move(path);
}

void WebsocketRouter::add_handler(const std::string& path, WebsocketOpenHandler on_open,
WebsocketMessageHandler on_message, WebsocketCloseHandler on_close,
WebsocketErrorHandler on_error)
{
const auto registered_path = base_path_.empty() ? path : base_path_ + path;
WebsocketRouteEntry entry{std::move(on_open), std::move(on_message), std::move(on_close), std::move(on_error)};
std::unique_lock lock(handlers_mutex_);
for (auto& route : handlers_)
if (route.original_path == path) { route.handlers = std::move(entry); return; }
auto [regex, params, literal_count, dynamic_count] = parse_path_pattern(path);
handlers_.push_back({path, std::move(regex), std::move(params), literal_count, dynamic_count, std::move(entry)});
if (route.original_path == registered_path) { route.handlers = std::move(entry); return; }
auto [regex, params, literal_count, dynamic_count] = parse_path_pattern(registered_path);
handlers_.push_back({registered_path, std::move(regex), std::move(params), literal_count, dynamic_count, std::move(entry)});
std::sort(handlers_.begin(), handlers_.end(), WebsocketRoute::compare_specificity);
spdlog::debug("Registered WebSocket handlers for path: {}", path);
spdlog::debug("Registered WebSocket handlers for path: {}", registered_path);
}

void WebsocketRouter::dispatch_open(const std::string& path, WebsocketContext& ctx)
Expand Down
4 changes: 4 additions & 0 deletions framework/router/websocket_router.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,9 @@ namespace khttpd::framework
public:
WebsocketRouter();

void set_base_path(std::string path);
const std::string& base_path() const { return base_path_; }

void add_handler(const std::string& path,
WebsocketOpenHandler on_open = {nullptr},
WebsocketMessageHandler on_message = {nullptr},
Expand All @@ -70,6 +73,7 @@ namespace khttpd::framework
void dispatch(const std::string& path, WebsocketContext& ctx,
const std::function<void(const WebsocketRouteEntry&)>& invoke);
std::vector<WebsocketRoute> handlers_;
std::string base_path_;
mutable std::shared_mutex handlers_mutex_;
};
}
Expand Down
41 changes: 40 additions & 1 deletion framework/tests/BUILD.bazel
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
load("@rules_cc//cc:defs.bzl", "cc_test")
load("@rules_cc//cc:defs.bzl", "cc_binary", "cc_test")

cc_test(
name = "sse_test",
Expand All @@ -11,6 +11,28 @@ cc_test(
],
)

cc_binary(
name = "concurrency_benchmark",
srcs = [
"concurrency_benchmark.cpp",
"concurrency_benchmark.hpp",
],
copts = select({
"@platforms//os:windows": [
"/std:c++17",
"/wd4865",
"/wd5026",
"/wd5039",
],
"//conditions:default": [
"-std=c++17",
"-Wall",
"-pedantic",
],
}),
deps = ["//framework"],
)

cc_test(
name = "context_test",
srcs = ["context_test.cpp"],
Expand Down Expand Up @@ -266,6 +288,23 @@ cc_test(
],
)

cc_test(
name = "concurrency_benchmark_test",
srcs = [
"concurrency_benchmark_test.cpp",
"concurrency_benchmark.hpp",
],
copts = [
"-std=c++17",
"-Wall",
"-pedantic",
],
deps = [
"@googletest//:gtest",
"@googletest//:gtest_main",
],
)

cc_test(
name = "typed_route_test",
srcs = ["typed_route_test.cpp"],
Expand Down
Loading
Loading