Skip to content

isOutboundHalfClosureEnabled closes channel before all writes are flushed #3139

Open
@adam-fowler

Description

@adam-fowler

Expected behavior

If I create a NIOAsyncChannel with isOutboundHalfClosureEnabled set to true. When I call outbound.finish() I expect that all writes will have been flushed before the channel is closed.

This was meant to be a workaround for the fact that NIOAsyncChannel.executeThenClose didn't flush writes, but it has the same issue.

Steps to reproduce

  1. Write small HTTP server that outputs a large response
  2. Use curl to get response.
  3. curl will report with something like curl: (18) transfer closed with 672832 bytes remaining to read

If possible, minimal yet complete reproducer code (or URL to code)

Here is a small HTTP server that returns 1000000 bytes in its response

import NIOCore
import NIOHTTP1
import NIOPosix

/// Sendable server response that doesn't use ``IOData``
public typealias SendableHTTPServerResponsePart = HTTPPart<HTTPResponseHead, ByteBuffer>

/// Channel to convert HTTPServerResponsePart to the Sendable type HBHTTPServerResponsePart
final class HTTPSendableResponseChannelHandler: ChannelOutboundHandler, RemovableChannelHandler {
    typealias OutboundIn = SendableHTTPServerResponsePart
    typealias OutboundOut = HTTPServerResponsePart

    func write(context: ChannelHandlerContext, data: NIOAny, promise: EventLoopPromise<Void>?) {
        let part = unwrapOutboundIn(data)
        switch part {
        case .head(let head):
            context.write(self.wrapOutboundOut(.head(head)), promise: promise)
        case .body(let buffer):
            context.write(self.wrapOutboundOut(.body(.byteBuffer(buffer))), promise: promise)
        case .end:
            context.writeAndFlush(self.wrapOutboundOut(.end(nil)), promise: promise)
        }
    }
}

@available(macOS 14, *)
func server() async throws {
    let asyncChannel = try await ServerBootstrap(group: MultiThreadedEventLoopGroup.singleton)
        // Specify backlog and enable SO_REUSEADDR for the server itself
        .serverChannelOption(ChannelOptions.socketOption(.so_reuseaddr), value: 1)
        .childChannelOption(ChannelOptions.allowRemoteHalfClosure, value: true)
        .bind(host: "127.0.0.1", port: 8888, serverBackPressureStrategy: nil) { channel in
            return channel.eventLoop.makeCompletedFuture {
                try channel.pipeline.syncOperations.configureHTTPServerPipeline()
                try channel.pipeline.syncOperations.addHandler(HTTPSendableResponseChannelHandler())
                return try NIOAsyncChannel<HTTPServerRequestPart, SendableHTTPServerResponsePart>(
                    wrappingChannelSynchronously: channel,
                    configuration: .init(isOutboundHalfClosureEnabled: true)
                )
            }
        }
    print("Listening on 127.0.0.1:8888")
    await withDiscardingTaskGroup { group in
        do {
            try await asyncChannel.executeThenClose { inbound in
                for try await childChannel in inbound {
                    group.addTask {
                        try? await childChannel.executeThenClose { inbound, outbound in
                            for try await part in inbound {
                                if case .end = part {
                                    let buffer = ByteBuffer(repeating: 0, count: 1_000_000)
                                    try await outbound.write(
                                        .head(
                                            .init(
                                                version: .http1_1,
                                                status: .ok,
                                                headers: ["Content-Length": "\(buffer.readableBytes)"]
                                            )
                                        )
                                    )
                                    try await outbound.write(.body(buffer))
                                    try await outbound.write(.end(nil))
                                    outbound.finish()
                                    try await childChannel.channel.closeFuture.get()
                                    break
                                }
                            }
                        }
                        print("Closed")
                    }
                }
            }
        } catch {
            print("ERROR: Waiting on child channel: \(error)")
        }
    }

}

if #available(macOS 14, *) {
    try await server()
}

SwiftNIO version/commit hash

be266a9 (latest of this morning)

System & version information

swift-driver version: 1.115.1 Apple Swift version 6.0.3 (swiftlang-6.0.3.1.10 clang-1600.0.30.1)
Target: arm64-apple-macosx15.0
Darwin Adams-MBP-M1-Max.local 24.3.0 Darwin Kernel Version 24.3.0: Thu Jan 2 20:24:16 PST 2025; root:xnu-11215.81.4~3/RELEASE_ARM64_T6000 arm64

Metadata

Metadata

Assignees

No one assigned

    Labels

    kind/bugFeature doesn't work as expected.size/MMedium task. (A couple of days of work.)

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions