Skip to content
Open
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
#!/usr/bin/env bash
#
# collect_benchmarks.sh
#
# Description:
# Reads “Request handled in XX ms” lines from a SwiftNIO HTTPResponsivenessServer
# log file (produced when started with --collect-benchmarks) and computes
# p0, p25, p50, p75, p90, p99 and p100 statistics.
#
# Called from run_benchmarks.sh
#
#

set -euo pipefail

if [ $# -ne 3 ]; then
echo "Usage: $0 <LOGFILE> <ENDPOINT> <SAMPLES>" >&2
exit 1
fi

LOGFILE="$1"
ENDPOINT="$2"
SAMPLES="$3"

# extract exactly the first N timings
mapfile -t times < <(
grep -a "Request handled in" "$LOGFILE" \
| head -n "$SAMPLES" \
| sed -E 's/.*Request handled in ([0-9]+(\.[0-9]+)?) ms/\1/'
)

if [ "${#times[@]}" -lt "$SAMPLES" ]; then
echo "ERROR: only found ${#times[@]} samples in $LOGFILE" >&2
exit 1
fi

# compute percentiles in Python
python3 - "$ENDPOINT" "${times[@]}" << 'PYCODE'
import sys, numpy as np

# parse float timings from command-line args (skip the first entry which is '-')
data = [float(x) for x in sys.argv[2:]]
arr = np.array(data)

# compute percentiles
pcts = [ np.percentile(arr, p) for p in (0, 25, 50, 75, 90, 99, 100) ]
samples = arr.size
metric = sys.argv[1]

# nice Unicode table
print("╒═══════════════════════╤══════════╤══════════╤══════════╤══════════╤══════════╤══════════╤══════════╤══════════╕")
print("│ Metric │ p0 │ p25 │ p50 │ p75 │ p90 │ p99 │ p100 │ Samples │")
print("╞═══════════════════════╪══════════╪══════════╪══════════╪══════════╪══════════╪══════════╪══════════╪══════════╡")
print(f"│ {metric:20s} │ " +
" │ ".join(f"{v:7.6f}" for v in pcts) +
f" │ {samples:7d} │")
print("╘═══════════════════════╧══════════╧══════════╧══════════╧══════════╧══════════╧══════════╧══════════╧══════════╛")
PYCODE
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
#!/usr/bin/env bash
#
# run_benchmarks.sh
#
# Description:
# Sends HTTP GETs to a running HTTPResponsivenessServer (started with --collect-benchmarks)
# then invokes collect_benchmarks.sh on its log.
#
# Prerequisites:
# 1. Build your server:
# swift build -c release
#
# 2. Start it in the background, redirecting stdout+stderr to a log:
#
# stdbuf -oL -eL .build/release/HTTPResponsivenessServer \
# --host 127.0.0.1 \
# --insecure-port 8080 \
# --collect-benchmarks \
# &> server.log
#
# Usage:
# In the root directory of this App
# Examples/ping-benchmarks/run_benchmarks.sh \
# --host 127.0.0.1 \
# --port 8080 \
# --endpoint /ping \
# --samples 100 \
# --logfile server.log
#

set -euo pipefail

# --- parse args ---
while [[ $# -gt 0 ]]; do
case $1 in
--host) HOST="$2"; shift 2 ;;
--port) PORT="$2"; shift 2 ;;
--endpoint) ENDPOINT="$2"; shift 2 ;;
--samples) SAMPLES="$2"; shift 2 ;;
--logfile) LOGFILE="$2"; shift 2 ;;
*)
echo "Unknown argument: $1" >&2
exit 1
;;
esac
done

# --- sanity check logfile ---
if [[ ! -f "$LOGFILE" ]]; then
cat <<EOF >&2
ERROR: logfile '$LOGFILE' not found.
Make sure you started the server with:

stdbuf -oL -eL .build/release/HTTPResponsivenessServer --host $HOST --insecure-port $PORT --collect-benchmarks &> $LOGFILE

EOF
exit 1
fi

# --- clear out any old measurements ---
: > "$LOGFILE"

echo "→ Sending $SAMPLES requests to http://$HOST:$PORT$ENDPOINT"
for i in $(seq 1 "$SAMPLES"); do
curl -s "http://$HOST:$PORT$ENDPOINT" > /dev/null
done

# give the server a moment to log
sleep 0.1

# --- wait for exactly SAMPLES log entries ---
echo "→ Waiting up to 5s for $SAMPLES entries in '$LOGFILE' …"
deadline=$((SECONDS + 5))
while (( SECONDS < deadline )); do
count=$(grep -c "Request handled in" "$LOGFILE" || true)
if (( count >= SAMPLES )); then
break
fi
sleep 0.1
done

# final check
count=$(grep -c "Request handled in" "$LOGFILE" || true)
if (( count < SAMPLES )); then
echo "ERROR: only found $count samples in '$LOGFILE' (wanted $SAMPLES)" >&2
exit 1
fi

echo "→ Collected $count samples, now computing percentiles…"
Examples/ping-benchmarks/collect_benchmarks.sh "$LOGFILE" "$ENDPOINT" "$SAMPLES"
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
//===----------------------------------------------------------------------===//
import ArgumentParser
import ExtrasJSON
import Foundation
import NIOCore
import NIOHTTP1
import NIOHTTP2
Expand All @@ -28,6 +29,81 @@ enum ChannelInitializeError: Error {
case unrecognizedPort(Int?)
}

// MARK: Performance Measurement Handler

/// Logs the time between request head and request end.
final class PerformanceMeasurementHandler: ChannelInboundHandler {
typealias InboundIn = HTTPServerRequestPart
private var startTime: DispatchTime?

func channelRead(context: ChannelHandlerContext, data: NIOAny) {
let part = unwrapInboundIn(data)
switch part {
case .head:
startTime = DispatchTime.now()
context.fireChannelRead(data)
case .body:
context.fireChannelRead(data)
case .end:
if let start = startTime {
let elapsedMs = Double(DispatchTime.now().uptimeNanoseconds - start.uptimeNanoseconds) / 1_000_000
print("Request handled in \(String(format: "%.2f", elapsedMs)) ms")
}
context.fireChannelRead(data)
}
}
}

/// Simple `/ping` endpoint that immediately responds “pong” — and swallows both head and end.
final class PingHandler: ChannelInboundHandler {
// HTTP *requests* coming in…
typealias InboundIn = HTTPServerRequestPart
// HTTP *responses* going out.
typealias OutboundOut = HTTPServerResponsePart

// Track whether we’re currently in the middle of a ping request:
private var handlingPing = false

func channelRead(context: ChannelHandlerContext, data: NIOAny) {
let part = unwrapInboundIn(data)

switch part {
case .head(let head) where head.uri == "/ping":
// Mark that we’ve started handling ping:
handlingPing = true

// Build and send a 200 OK / "pong"
var resHead = HTTPResponseHead(version: head.version, status: .ok)
resHead.headers.add(name: "Content-Length", value: "4")
resHead.headers.add(name: "Content-Type", value: "text/plain")
context.write(wrapOutboundOut(.head(resHead)), promise: nil)

var buf = context.channel.allocator.buffer(capacity: 4)
buf.writeString("pong")
context.write(wrapOutboundOut(.body(.byteBuffer(buf))), promise: nil)

// And flush the end of response:
context.writeAndFlush(wrapOutboundOut(.end(nil)), promise: nil)

// **Do not forward** the request head downstream.
return

case .body where handlingPing:
// ignore any body for ping
return

case .end where handlingPing:
// ping is done
handlingPing = false
return

default:
// everything else: pass through normally
context.fireChannelRead(data)
}
}
}

func configureCommonHTTPTypesServerPipeline(
_ channel: Channel,
_ configurator: @Sendable @escaping (Channel) -> EventLoopFuture<Void>
Expand Down Expand Up @@ -64,7 +140,8 @@ func channelInitializer(
channel: Channel,
tls: ([Int], NIOSSLContext, ByteBuffer)?,
insecure: ([Int], ByteBuffer)?,
isNIOTS: Bool = false
isNIOTS: Bool = false,
collectBenchmarks: Bool = false
) -> EventLoopFuture<Void> {
// Handle TLS case
var port = channel.localAddress?.port
Expand All @@ -83,6 +160,9 @@ func channelInitializer(
}
return configureCommonHTTPTypesServerPipeline(channel) { channel in
channel.eventLoop.makeCompletedFuture {
if collectBenchmarks {
try channel.pipeline.syncOperations.addHandler(PerformanceMeasurementHandler())
}
try channel.pipeline.syncOperations.addHandler(
SimpleResponsivenessRequestMux(responsivenessConfigBuffer: config)
)
Expand All @@ -93,6 +173,11 @@ func channelInitializer(
// Handle insecure case
if let (ports, config) = insecure, let port, ports.contains(port) {
return channel.pipeline.configureHTTPServerPipeline().flatMapThrowing {
// If benchmarking is enabled, install timer and ping handlers first:
if collectBenchmarks {
try channel.pipeline.syncOperations.addHandler(PerformanceMeasurementHandler())
try channel.pipeline.syncOperations.addHandler(PingHandler())
}
let mux = SimpleResponsivenessRequestMux(responsivenessConfigBuffer: config)
return try channel.pipeline.syncOperations.addHandlers([
HTTP1ToHTTPServerCodec(secure: false),
Expand Down Expand Up @@ -146,6 +231,9 @@ private struct HTTPResponsivenessServer: ParsableCommand {
@Option(help: "override how many threads to use")
var threads: Int? = nil

@Flag(help: "Enable per-request performance measurements")
var collectBenchmarks: Bool = false

func run() throws {
if port == nil && insecurePort == nil {
throw RunError.inputError("must provide either port or insecurePort")
Expand Down Expand Up @@ -199,7 +287,8 @@ private struct HTTPResponsivenessServer: ParsableCommand {
channel: channel,
tls: tls,
insecure: insecure,
isNIOTS: useNetwork
isNIOTS: useNetwork,
collectBenchmarks: collectBenchmarks
)
})

Expand Down Expand Up @@ -233,7 +322,8 @@ private struct HTTPResponsivenessServer: ParsableCommand {
channelInitializer(
channel: channel,
tls: tls,
insecure: insecure
insecure: insecure,
collectBenchmarks: collectBenchmarks
)
})

Expand Down Expand Up @@ -261,6 +351,7 @@ private struct HTTPResponsivenessServer: ParsableCommand {
print("Listening on https://\(host):\(port!)")
return out
}

let insecureChannel = try insecureChannelBootstrap.map {
let out = try $0.wait()
print("Listening on http://\(host):\(insecurePort!)")
Expand Down