Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Sendability warnings #417

Merged
merged 13 commits into from
Nov 1, 2023
Merged

Sendability warnings #417

merged 13 commits into from
Nov 1, 2023

Conversation

rnro
Copy link
Contributor

@rnro rnro commented Sep 4, 2023

Motivation:

To address warnings revealed with
.enableExperimentalFeature("StrictConcurrency=complete"), in Swift 5.8

Modifications:

  • Numerous protections of state in tests
  • Make the multiplexer instances explicitly loop bound
  • Update channel initializers to be marked as Sendable
  • Mark error backing storage types as unchecked sendable

Result:

Better thread safety

@rnro rnro requested a review from glbrntt September 4, 2023 09:14
@@ -227,20 +239,31 @@ extension NIOHTTP2Handler {
/// `OutboundStreamOutput`. This type may be `HTTP2Frame` or changed to any other type.
@available(macOS 10.15, iOS 13.0, watchOS 6.0, tvOS 13.0, *)
@_spi(AsyncChannel)
public struct AsyncStreamMultiplexer<InboundStreamOutput> {
private let inlineStreamMultiplexer: InlineStreamMultiplexer
public struct AsyncStreamMultiplexer<InboundStreamOutput>: @unchecked Sendable {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Aren't all the properties of this type Sendable?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yep, marked as Sendable

@@ -1078,8 +1082,9 @@ extension NIOHTTP2Handler {
return try self.syncMultiplexer()
}
} else {
let loopBoundSelf = NIOLoopBound(self, eventLoop: self.eventLoop!)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We can't do this: NIOLoopBound preconditions that it's on the right EL on init and we know we're not on the EL here as we just checked.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, this is the canonical use of UnsafeTransfer.

Comment on lines 182 to 184
let loopBoundMultiplexer = NIOLoopBound(multiplexer, eventLoop: self.eventLoop)
let loopBoundHandlers = NIOLoopBound(handlers, eventLoop: self.eventLoop)
let loopBoundPosition = NIOLoopBound(position, eventLoop: self.eventLoop)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We know we're not on the EL here so these will fail preconditions on init.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Rather than moving the handlers onto the EL we can just create them on the EL. The pipeline position is super awkward though because it isn't Sendable. I'm not sure what the solution there is (this is a problem NIO needs to solve).

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've moved the handler creation but left the warning for this and other use of position rather than trying to hide it because it truly is not sendable.

Comment on lines 230 to 231
let loopBoundStreamDelegate = NIOLoopBound(streamDelegate, eventLoop: self.eventLoop)
let loopBoundPosition = NIOLoopBound(position, eventLoop: self.eventLoop)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We know we're not on the EL here: these will precondition failure on init.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've made it a requirement that the streamDelegate be Sendable and left the position warning there.

@@ -460,7 +483,7 @@ extension Channel {
}
} else {
return self.eventLoop.submit {
return try self.pipeline.syncOperations.configureAsyncHTTP2Pipeline(
return try self.pipeline.syncOperations .configureAsyncHTTP2Pipeline(
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
return try self.pipeline.syncOperations .configureAsyncHTTP2Pipeline(
return try self.pipeline.syncOperations.configureAsyncHTTP2Pipeline(

@@ -167,11 +167,15 @@ extension NIOHTTP2Handler {
public struct StreamMultiplexer: @unchecked Sendable {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

At this point do we need @unchecked?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We do not.

@@ -167,11 +167,15 @@ extension NIOHTTP2Handler {
public struct StreamMultiplexer: @unchecked Sendable {
// '@unchecked Sendable' because this state is not intrinsically `Sendable`
// but it is only accessed in `createStreamChannel` which executes the work on the right event loop
private let inlineStreamMultiplexer: InlineStreamMultiplexer

private let inlineStreamMultiplexer: NIOLoopBound<InlineStreamMultiplexer>
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think NIOLoopBound is the right approach here. There isn't a particular requirement to force the event loop dance out this far, I don't think. In practice we're calling an API that is thread-safe inside InlineStreamMultiplexer, so I wonder if using a lens type might be better.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have made InlineStreamMultiplexerSendableView for this purpose

@@ -103,7 +103,7 @@ public final class NIOHTTP2Handler: ChannelDuplexHandler {

/// The delegate for (de)multiplexing inbound streams.
private var inboundStreamMultiplexerState: InboundStreamMultiplexerState
internal var inboundStreamMultiplexer: InboundStreamMultiplexer? {
internal var inboundStreamMultiplexer: NIOLoopBound<InboundStreamMultiplexer>? {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why does this need to be a NIOLoopBound?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It doesn't, removed.

@@ -1078,8 +1082,9 @@ extension NIOHTTP2Handler {
return try self.syncMultiplexer()
}
} else {
let loopBoundSelf = NIOLoopBound(self, eventLoop: self.eventLoop!)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, this is the canonical use of UnsafeTransfer.

@@ -467,7 +467,9 @@ public enum NIOHTTP2Errors {
}
}

private final class Storage: Equatable {
private final class Storage: Equatable, @unchecked Sendable {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I mean, this type is very trivially not Sendable. Can it be made to be? Same for the other changes here.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd put the annotation in the wrong place, I've moved it to the broader error type.

@@ -124,3 +123,5 @@ public enum NIOHPACKErrors {
public init() {}
}
}

public protocol Foo: Collection, Sendable {}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oops!

@@ -546,3 +561,35 @@ extension AsyncThrowingStream {
}
}
#endif

struct HTTP2CommonInboundStreamMultiplexerSendableView: @unchecked Sendable {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We usually nest views inside the type they are viewing, e.g. HTTP2CommonInboundStreamMultiplexer.SendableView

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think that actually I didn't end up using this type, so I'll remove it. I did make a different view to which I'll apply this tip though.

@@ -1572,7 +1574,9 @@ public enum NIOHTTP2Errors {
/// meaningfully be `Equatable`, so they aren't. There's also no additional location information: that's
/// provided by the base error.
public struct StreamError: Error {
private final class Storage {
private final class Storage: @unchecked Sendable {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The storage isn't sendable, but StreamError is @unchecked Sendable because it does the CoW dance when modifying storage.

@@ -1690,7 +1694,7 @@ private func _location(file: String, line: UInt) -> String {
return "\(file):\(line)"
}

private final class StringAndLocationStorage: Equatable {
private final class StringAndLocationStorage: Equatable, @unchecked Sendable {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This shouldn't be Sendable, it's a class with mutable properties. It can be used in a Sendable type if the caller does the appropriate CoW dance though.

Comment on lines 195 to 197
} catch {
return self.eventLoop.makeFailedFuture(error)
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Aim to keep do-catch blocks as small as possible. Here we should scope it to the if self.eventLoop.inEventLoop block.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah, I missed that self.eventLoop.submit handles the thrown error so I thought both branches could throw and wanted to avoid duplication.

Comment on lines 473 to 480
var handlers = [ChannelHandler]()
handlers.reserveCapacity(2) // Update this if we need to add more handlers, to avoid unnecessary reallocation.

handlers.append(NIOHTTP2Handler(mode: mode, initialSettings: initialLocalSettings))
let multiplexer = HTTP2StreamMultiplexer(mode: mode, channel: channel, targetWindowSize: targetWindowSize, inboundStreamInitializer: inboundStreamInitializer)
handlers.append(multiplexer)

try self.addHandlers(handlers, position: position)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Given we're doing synchronous operations here we can actually save an allocation by avoiding the handlers array and just calling addHandler multiple times.

Motivation:

To address warnings revealed with
`.enableExperimentalFeature("StrictConcurrency=complete"),` in Swift 5.8

Modifications:

* Numerous protections of state in tests
* Make the multiplexer instances explicitly loop bound
* Update channel initializers to be marked as Sendable
* Mark error backing storage types as unchecked sendable

Result:

Better thread safety
@rnro
Copy link
Contributor Author

rnro commented Sep 11, 2023

I squashed all of the reviewed changes into one commit and merged the changes from main into this branch to try and highlight any new changes.

@rnro rnro requested a review from glbrntt September 11, 2023 15:51

return self.pipeline.addHandlers(handlers, position: position).map { multiplexer }
if self.eventLoop.inEventLoop {
do {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

FYI eventLoop has a makeCompletedFuture function which takes a closure which returns a Result type. It's effectively the Result-based counterpart to makeSucceededFuture and makeFailedFuture and allows you to avoid doing the do-catch block here.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Neat

configurator: @escaping NIOChannelInitializer
) -> EventLoopFuture<Void> {
let loopBoundStreamDelegate = NIOLoopBound(streamDelegate, eventLoop: self.eventLoop)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this correct? This pipeline helpers isn't synchronous so we can't make the guarantee that we'll be in the EL at this point. Also I think stream delegate is Sendable now anyway isn't it?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It is indeed sendable, good spot. I don't know if this came up in the rebase or if it was just an outstanding issue.

@rnro rnro requested a review from glbrntt September 14, 2023 08:56
@Lukasa Lukasa added the semver/minor Adds new public API. label Sep 18, 2023
}

internal func createStreamChannel(promise: EventLoopPromise<Channel>?, _ streamStateInitializer: @escaping NIOHTTP2Handler.StreamInitializer) {
self.inlineStreamMultiplexer.createStreamChannel(promise: promise, streamStateInitializer)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm a bit confused: is this method thread-safe, or is the usage point protecting it?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good spot. It's intended that the method is thread-safe. I've moved the event loop dance inside this method to make that the case.

if self.eventLoop.inEventLoop {
self.inlineStreamMultiplexer.createStreamChannel(promise: promise, streamStateInitializer)
} else {
self.eventLoop.execute {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

See my comment on the SendableView.

extension UnsafeTransfer: @unchecked Sendable {}

extension UnsafeTransfer: Equatable where Wrapped: Equatable {}
extension UnsafeTransfer: Hashable where Wrapped: Hashable {}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we put this in its own file rather than smuggling it into here?

inboundStreamStateInitializer: .includesStreamID(nil)
)
self.streams[streamID] = channel
loopBounds.value.0.streams[streamID] = channel
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Repeatedly unpacking the loop bounds here kinda sucks: can we unwrap them at the top of the execute { } block and then use their names?

@rnro rnro requested a review from Lukasa September 21, 2023 09:57
public struct AsyncStreamMultiplexer<InboundStreamOutput> {
private let inlineStreamMultiplexer: InlineStreamMultiplexer
public struct AsyncStreamMultiplexer<InboundStreamOutput: Sendable>: Sendable {
private let inlineStreamMultiplexer: NIOLoopBound<InlineStreamMultiplexer>
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there a reason we don't use the sendable view here?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think so, an oversight. I have used it here and fleshed out its API a bit more to mirror the underlying multiplexer.

@rnro rnro requested a review from Lukasa September 21, 2023 15:45
* HTTP2StreamMultiplexer stream creation previously called down to
  underlying code which assumed it was always on the correct event loop
  however that wasn't the case via this public API. I have introduced
  the event loop dance.
* Remove the use of NIOLoopBound in cases where we know we are on the
  correct event loop and unsafe transfer is more applicable.
Calls to `createStreamChannel` now always go via a `SendableView` lens
type which serializes access on an event loop with an unconditional hop.
This combines the serialization with the unconditional hop which was
previously performed at the `HTTP2CommonInboundStreamMultiplexer` level,
in some cases saving a hop.

The `SendableView` is performed at the higher multiplexer levels rather
than at the 'common' level because the higher levels pass in a `self` to
lower calls which would otherwise need to be transferred in to the event
loop hop.
Copy link
Member

@FranzBusch FranzBusch left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In general this looks good to me. I would leave the final review to @Lukasa

@@ -146,7 +146,9 @@ private enum HTTP2StreamData {
}

@usableFromInline
final class HTTP2StreamChannel: Channel, ChannelCore {
final class HTTP2StreamChannel: Channel, ChannelCore, @unchecked Sendable {
// @unchecked Sendable because the only mutable state is `_pipeline` which is only modified in init
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's not fully correct there are a bunch of methods on the ChannelCore that must be called on the EL such as write0. However, nobody should really call those methods.

@rnro
Copy link
Contributor Author

rnro commented Nov 1, 2023

I believe that the discovered API breakages are okay, just to expand on that.
Add sendable requirement to generic parameters:

  💔 API breakage: struct NIOHTTP2Handler.AsyncStreamMultiplexer has generic signature change from <InboundStreamOutput> to <InboundStreamOutput where InboundStreamOutput : Swift.Sendable>
  💔 API breakage: accessor NIOHTTP2Handler.AsyncStreamMultiplexer.inbound.Get() has generic signature change from <InboundStreamOutput> to <InboundStreamOutput where InboundStreamOutput : Swift.Sendable>
  💔 API breakage: func NIOHTTP2Handler.AsyncStreamMultiplexer.openStream(_:) has generic signature change from <InboundStreamOutput, Output where Output : Swift.Sendable> to <InboundStreamOutput, Output where InboundStreamOutput : Swift.Sendable, Output : Swift.Sendable>
  💔 API breakage: struct NIOHTTP2AsyncSequence has generic signature change from <Output> to <Output where Output : Swift.Sendable>
  💔 API breakage: struct NIOHTTP2AsyncSequence.AsyncIterator has generic signature change from <Output> to <Output where Output : Swift.Sendable>
  💔 API breakage: typealias NIOHTTP2AsyncSequence.AsyncIterator.Element has generic signature change from <Output> to <Output where Output : Swift.Sendable>
  💔 API breakage: func NIOHTTP2AsyncSequence.AsyncIterator.next() has generic signature change from <Output> to <Output where Output : Swift.Sendable>
  💔 API breakage: typealias NIOHTTP2AsyncSequence.Element has generic signature change from <Output> to <Output where Output : Swift.Sendable>
  💔 API breakage: func NIOHTTP2AsyncSequence.makeAsyncIterator() has generic signature change from <Output> to <Output where Output : Swift.Sendable>
  💔 API breakage: protocol NIOHTTP2StreamDelegate has generic signature change from  to <Self : Swift.Sendable>

Add sendable requirement:

  💔 API breakage: protocol NIOHTTP2StreamDelegate has added inherited protocol Sendable

Change from explicit closure type to equivalent type alias:

  💔 API breakage: constructor HTTP2StreamMultiplexer.init(mode:channel:targetWindowSize:inboundStreamStateInitializer:) has parameter 3 type change from ((NIOCore.Channel, NIOHTTP2.HTTP2StreamID) -> NIOCore.EventLoopFuture<Swift.Void>)? to NIOHTTP2.NIOChannelInitializerWithStreamID?
  💔 API breakage: constructor HTTP2StreamMultiplexer.init(mode:channel:targetWindowSize:outboundBufferSizeHighWatermark:outboundBufferSizeLowWatermark:inboundStreamStateInitializer:) has parameter 5 type change from ((NIOCore.Channel, NIOHTTP2.HTTP2StreamID) -> NIOCore.EventLoopFuture<Swift.Void>)? to NIOHTTP2.NIOChannelInitializerWithStreamID?
  💔 API breakage: func Channel.configureHTTP2Pipeline(mode:initialLocalSettings:position:inboundStreamStateInitializer:) has parameter 3 type change from ((NIOCore.Channel, NIOHTTP2.HTTP2StreamID) -> NIOCore.EventLoopFuture<Swift.Void>)? to NIOHTTP2.NIOChannelInitializerWithStreamID?
  💔 API breakage: func Channel.configureHTTP2Pipeline(mode:initialLocalSettings:position:targetWindowSize:inboundStreamStateInitializer:) has parameter 4 type change from ((NIOCore.Channel, NIOHTTP2.HTTP2StreamID) -> NIOCore.EventLoopFuture<Swift.Void>)? to NIOHTTP2.NIOChannelInitializerWithStreamID?

Change from explicit closure type to equivalent type alias:

  💔 API breakage: constructor HTTP2StreamMultiplexer.init(mode:channel:targetWindowSize:outboundBufferSizeHighWatermark:outboundBufferSizeLowWatermark:inboundStreamInitializer:) has parameter 5 type change from ((NIOCore.Channel) -> NIOCore.EventLoopFuture<Swift.Void>)? to NIOHTTP2.NIOChannelInitializer?
  💔 API breakage: func Channel.configureHTTP2Pipeline(mode:initialLocalSettings:position:targetWindowSize:inboundStreamInitializer:) has parameter 4 type change from ((NIOCore.Channel) -> NIOCore.EventLoopFuture<Swift.Void>)? to NIOHTTP2.NIOChannelInitializer?
  💔 API breakage: func Channel.configureCommonHTTPServerPipeline(h2ConnectionChannelConfigurator:_:) has parameter 0 type change from ((NIOCore.Channel) -> NIOCore.EventLoopFuture<Swift.Void>)? to NIOHTTP2.NIOChannelInitializer?
  💔 API breakage: func Channel.configureCommonHTTPServerPipeline(h2ConnectionChannelConfigurator:targetWindowSize:_:) has parameter 0 type change from ((NIOCore.Channel) -> NIOCore.EventLoopFuture<Swift.Void>)? to NIOHTTP2.NIOChannelInitializer?
  💔 API breakage: func Channel.configureCommonHTTPServerPipeline(connectionConfiguration:streamConfiguration:streamDelegate:h2ConnectionChannelConfigurator:configurator:) has parameter 3 type change from ((NIOCore.Channel) -> NIOCore.EventLoopFuture<Swift.Void>)? to NIOHTTP2.NIOChannelInitializer?

@glbrntt glbrntt enabled auto-merge (squash) November 1, 2023 14:30
@glbrntt glbrntt disabled auto-merge November 1, 2023 14:36
@glbrntt glbrntt merged commit 285e444 into apple:main Nov 1, 2023
7 of 8 checks passed
@rnro rnro deleted the sendability_warnings branch November 1, 2023 14:38
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
semver/minor Adds new public API.
Projects
None yet
Development

Successfully merging this pull request may close these issues.

4 participants