Skip to content
Closed
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
37 changes: 37 additions & 0 deletions NOTICE.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@

The SwiftNIO Project
====================

Please visit the SwiftNIO web site for more information:

* https://github.com/apple/swift-nio

Copyright 2017, 2018 The SwiftNIO Project

The SwiftNIO Project licenses this file to you under the Apache License,
version 2.0 (the "License"); you may not use this file except in compliance
with the License. You may obtain a copy of the License at:

https://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
License for the specific language governing permissions and limitations
under the License.

Also, please refer to each LICENSE.<component>.txt file, which is located in
the 'license' directory of the distribution file, for the license terms of the
components that this product depends on.

-------------------------------------------------------------------------------


This product contains a derivation of the following files from gRPC Swift NIO
Transport: "Timer.swift", "ServerConnectionManagementHandler.swift", and
"ServerConnectionManagementHandler+StateMachine.swift".

* LICENSE (Apache License 2.0):
* https://www.apache.org/licenses/LICENSE-2.0
* HOMEPAGE:
* https://github.com/grpc/grpc-swift-nio-transport
4 changes: 2 additions & 2 deletions http-responsiveness-server/Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -47,9 +47,8 @@ let package = Package(
.package(url: "https://github.com/apple/swift-nio-ssl.git", from: "2.27.0"),
.package(
url: "https://github.com/apple/swift-nio-extras.git",
revision: "4804de1953c14ce71cfca47a03fb4581a6b3301c"
revision: "c6714eeaa49272442d2b2d21ee1e2e645ae6f607"
),
.package(url: "https://github.com/apple/swift-http-types.git", from: "1.1.0"),
.package(url: "https://github.com/apple/swift-argument-parser.git", from: "1.4.0"),
.package(url: "https://github.com/swift-extras/swift-extras-json.git", from: "0.6.0"),
.package(url: "https://github.com/apple/swift-nio-transport-services.git", from: "1.23.0"),
Expand All @@ -66,6 +65,7 @@ let package = Package(
.product(name: "ArgumentParser", package: "swift-argument-parser"),
.product(name: "NIOHTTPTypesHTTP2", package: "swift-nio-extras"),
.product(name: "NIOHTTPTypesHTTP1", package: "swift-nio-extras"),
.product(name: "NIOExtras", package: "swift-nio-extras"),
.product(name: "NIOHTTPResponsiveness", package: "swift-nio-extras"),
.product(name: "ExtrasJSON", package: "swift-extras-json"),
.product(name: "NIOTransportServices", package: "swift-nio-transport-services"),
Expand Down
46 changes: 46 additions & 0 deletions http-responsiveness-server/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
# HTTP Responsiveness Server

This HTTP server offers a simple API that allows load testing, based on [`SimpleResponsivenessRequestMux`](https://github.com/apple/swift-nio-extras/blob/main/Sources/NIOHTTPResponsiveness/SimpleResponsivenessRequestMux.swift). It implements the following endpoints:

- GET `/responsiveness`: Returns an overview of the responsiveness enpoints.
- GET `/responsiveness/download/{size}`: Download `size` bytes of data.
- POST `/responsiveness/upload`: Upload data.
- GET `/drip`: Provides a stream of zeroes.
- POST `/admin/shutdown`: Gracefully shutdown the server.

```
USAGE: http-responsiveness-server --host <host> --port <port> [--threads <threads>] [--max-idle-time <max-idle-time>] [--max-age <max-age>] [--max-grace-time <max-grace-time>]

OPTIONS:
--host <host> Which host to bind to.
--port <port> Which port to bind to.
--threads <threads> Override how many threads to use.
--max-idle-time <max-idle-time>
Time a connection may be idle for before being closed, in seconds.
--max-age <max-age> Time a connection may exist before being gracefully closed, in seconds.
--max-grace-time <max-grace-time>
Grace period for connections to close after shutdown, in seconds.
-h, --help Show help information.
```

## Example execution

Run the server in one terminal:

```bash
swift run HTTPResponsivenessServer --host 127.0.0.1 --port 2345
```

Make requests from a separate terminal, e.g., a download process:

```bash
curl --http2-prior-knowledge -o /dev/null http://127.0.0.1:2345/responsiveness/download/8000000000
```

Gracefully shutdown the server:

```bash
curl -X POST --http2-prior-knowledge -o data.bin http://127.0.0.1:2345/admin/shutdown
```

Don't forget to use `swift run -c release` for any measurements!
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
//===----------------------------------------------------------------------===//
//
// This source file is part of the SwiftNIO open source project
//
// Copyright (c) 2025 Apple Inc. and the SwiftNIO project authors
// Licensed under Apache License v2.0
//
// See LICENSE.txt for license information
// See CONTRIBUTORS.txt for the list of SwiftNIO project authors
//
// SPDX-License-Identifier: Apache-2.0
//
//===----------------------------------------------------------------------===//

import HTTPTypes
import NIOCore
import NIOHTTPTypes

/// A `ChannelHandler` that provides administrative HTTP endpoints for server management.
/// Currently, it supports a single enpoint that aims to shutdown the server gracefully:
///
/// - `GET /admin/shutdown` - Initiates shutdown via a given callback
///
/// It will only respond to the supported administrative endpoints and return 404 for all other requests.
/// Since it does not forward requests, it should be at the end of a pipeline.
public final class HTTPAdminHandler: ChannelInboundHandler {
public typealias InboundIn = HTTPRequestPart
public typealias OutboundOut = HTTPResponsePart

private static let notFoundBody = ByteBuffer(string: "Not Found")

private static let okBody = ByteBuffer(string: "Initiating shutdown.")

private var isShuttingDown = false

private var shutdownCallback: () -> Void

/// Creates a new HTTP admin handler with the specified shutdown callback.
///
/// - Parameter shutdownCallback: A closure that will be called once when a shutdown request
/// is received via the `/admin/shutdown` endpoint.
public init(shutdownCallback: @escaping () -> Void) {
self.shutdownCallback = shutdownCallback
}

public func channelRead(context: ChannelHandlerContext, data: NIOAny) {
if case let .head(request) = self.unwrapInboundIn(data) {
guard let path = request.path else {
self.writeSimpleResponse(
context: context,
status: .notFound,
body: HTTPAdminHandler.notFoundBody
)
return
}

// Parse the path, removing query parameters if present
var pathComponents = path.utf8.lazy.split(separator: UInt8(ascii: "?"), maxSplits: 1).makeIterator()
let firstPathComponent = pathComponents.next()!

// Split the path into components for routing
var uriComponentIterator:
LazyMapSequence<LazySequence<[Substring.UTF8View.SubSequence]>.Elements, Substring>.Iterator =
firstPathComponent.split(
separator: UInt8(ascii: "/"),
maxSplits: 3,
omittingEmptySubsequences: false
).lazy.map(Substring.init).makeIterator()

// Route the request based on HTTP method and path components. The only request we handle here is
// "/admin/shutdown". Other requests will be answered with a 404.
switch (
request.method, uriComponentIterator.next(), uriComponentIterator.next(),
uriComponentIterator.next(), uriComponentIterator.next().flatMap { Int($0) }
) {
case (.post, .some(""), .some("admin"), .some("shutdown"), .none):
self.writeSimpleResponse(
context: context,
status: .ok,
body: HTTPAdminHandler.okBody
)

// Initiate shutdown only once to prevent multiple shutdown calls
if !self.isShuttingDown {
self.shutdownCallback()
self.isShuttingDown = true
}

default:
self.writeSimpleResponse(
context: context,
status: .notFound,
body: HTTPAdminHandler.notFoundBody
)
}
}
}

private func writeSimpleResponse(
context: ChannelHandlerContext,
status: HTTPResponse.Status,
body: ByteBuffer
) {
let bodyLen = body.readableBytes
let responseHead = HTTPResponse(
status: status,
headerFields: HTTPFields(dictionaryLiteral: (.contentLength, "\(bodyLen)"))
)
context.write(self.wrapOutboundOut(.head(responseHead)), promise: nil)
context.write(self.wrapOutboundOut(.body(body)), promise: nil)
context.writeAndFlush(self.wrapOutboundOut(.end(nil)), promise: nil)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
//===----------------------------------------------------------------------===//
//
// This source file is part of the SwiftNIO open source project
//
// Copyright (c) 2025 Apple Inc. and the SwiftNIO project authors
// Licensed under Apache License v2.0
//
// See LICENSE.txt for license information
// See CONTRIBUTORS.txt for the list of SwiftNIO project authors
//
// SPDX-License-Identifier: Apache-2.0
//
//===----------------------------------------------------------------------===//

import ArgumentParser
import ExtrasJSON
import NIOCore
import NIOExtras
import NIOHTTP1
import NIOHTTP2
import NIOHTTPResponsiveness
import NIOHTTPTypesHTTP1
import NIOHTTPTypesHTTP2
import NIOPosix
import NIOSSL
import NIOTLS
import NIOTransportServices

func responsivenessConfigBuffer(scheme: String, host: String, port: Int) throws -> ByteBuffer {
let cfg = ResponsivenessConfig(
version: 1,
urls: ResponsivenessConfigURLs(scheme: scheme, authority: "\(host):\(port)")
)
let encoded = try XJSONEncoder().encode(cfg)
return ByteBuffer(bytes: encoded)
}

@main
private struct HTTPResponsivenessServer: ParsableCommand {
@Option(help: "Host to bind to.")
var host: String

@Option(help: "Port to bind to.")
var port: Int

@Option(help: "Number of threads to use.")
var threads: Int? = nil

@Option(
name: .customLong("max-idle-time"),
help: "Time a connection may be idle for before being closed, in seconds."
)
var maxIdleTimeSeconds: Int64?

@Option(
name: .customLong("max-age"),
help: "Time a connection may exist before being gracefully closed, in seconds."
)
var maxAgeSeconds: Int64?

@Option(
name: .customLong("max-grace-time"),
help: "Grace period for connections to close after shutdown, in seconds."
)
var maxGraceTimeSeconds: Int64?

func run() throws {
if let threads = self.threads {
NIOSingletons.groupLoopCountSuggestion = threads
}

let group = MultiThreadedEventLoopGroup.singleton

// This helper can initate the shutdown process
let quiesce = ServerQuiescingHelper(group: group)
let fullyShutdownPromise: EventLoopPromise<Void> = group.next().makePromise()

// This builds the response for requests to /responsiveness
let config = try responsivenessConfigBuffer(scheme: "http", host: host, port: port)

let bootstrap = ServerBootstrap(group: group)
// Configure the server
.serverChannelOption(.backlog, value: 256)
.serverChannelOption(.socketOption(.so_reuseaddr), value: 1)

// Inject the channel handler for the shutdown process
.serverChannelInitializer { channel in
channel.eventLoop.makeCompletedFuture {
try channel.pipeline.syncOperations.addHandler(quiesce.makeServerChannelHandler(channel: channel))
}
}

// Set the handlers that are applied to the accepted Channels
.childChannelInitializer { channel in
channel.pipeline.eventLoop.makeCompletedFuture {
let shutdownHandler = ServerConnectionManagementHandler(
eventLoop: channel.eventLoop,
maxIdleTime: maxIdleTimeSeconds == nil ? nil : .seconds(self.maxIdleTimeSeconds!),
maxAge: self.maxAgeSeconds == nil ? nil : .seconds(self.maxAgeSeconds!),
maxGraceTime: self.maxGraceTimeSeconds == nil ? nil : .seconds(self.maxGraceTimeSeconds!)
)

_ = try channel.pipeline.syncOperations.configureHTTP2Pipeline(
mode: .server,
connectionConfiguration: .init(),
streamConfiguration: .init(),
streamDelegate: shutdownHandler.http2StreamDelegate
) { channel in
channel.pipeline.eventLoop.makeCompletedFuture {
let sync = channel.pipeline.syncOperations
try sync.addHandler(HTTP2FramePayloadToHTTP1ServerCodec())
try sync.addHandlers(HTTP1ToHTTPServerCodec(secure: false))
try sync.addHandler(
SimpleResponsivenessRequestMux(
responsivenessConfigBuffer: config,
forwardOtherRequests: true
)
)
try sync.addHandlers(
HTTPAdminHandler {
quiesce.initiateShutdown(promise: fullyShutdownPromise)
}
)
}
}
try channel.pipeline.syncOperations.addHandlers(shutdownHandler)
try channel.pipeline.syncOperations.addHandlers(NIOCloseOnErrorHandler())
}
}
// Configure the accepted channels
.childChannelOption(.socketOption(.so_reuseaddr), value: 1)
.childChannelOption(ChannelOptions.tcpOption(.tcp_nodelay), value: 1)
.childChannelOption(.maxMessagesPerRead, value: 16)
.childChannelOption(.recvAllocator, value: AdaptiveRecvByteBufferAllocator())

_ = try! bootstrap.bind(host: host, port: port).wait()

// Wait for the server to shutdown.
try fullyShutdownPromise.futureResult.wait()
}
}
Loading