Skip to content

Conversation

@felixschlegel
Copy link
Contributor

@felixschlegel felixschlegel commented May 15, 2023

Motivation

If NIOSSLHandler receives close(mode: .output) or close(mode: .input), it ignores them, optionally failing their promises. This is weird behaviour. It should do better.

Modifications

  • flush outstanding messages on NIOSSLHandler.close(mode: .output)
  • close SSL Connection on NIOSSLHandler.close(mode: .output)
  • support closing the input of NIOSSLHandler when used as a server with ChannelOptions.Types.AllowRemoteHalfClosureOption enabled
  • trigger TCP close when NIOSSLHandler.state == .inputClose
  • write integration tests to verify the behaviour

Warning: This PR relies on EmbeddedChannel supporting ChannelOptions.Types.AllowRemoteHalfClosureOption, which is currently not possible and proposed as a Patch to swift-nio

Note: I have removed a lot of whitespace, so consider selecting Hide Whitespace in the GitHub Files Changed tab

@felixschlegel felixschlegel force-pushed the fs-new-close-mode-behaviour branch 3 times, most recently from 267422f to 59cf0df Compare May 15, 2023 15:45
@felixschlegel felixschlegel marked this pull request as ready for review May 15, 2023 16:09
@felixschlegel felixschlegel marked this pull request as draft May 16, 2023 09:28
@felixschlegel felixschlegel force-pushed the fs-new-close-mode-behaviour branch 2 times, most recently from 4616900 to 416465c Compare May 17, 2023 10:17
@felixschlegel felixschlegel marked this pull request as ready for review May 18, 2023 13:55
@Lukasa Lukasa added 🔨 semver/patch No public API change. 🆕 semver/minor Adds new public API. and removed 🔨 semver/patch No public API change. labels May 19, 2023
}

public func channelActive(context: ChannelHandlerContext) {
context.channel.getOption(ChannelOptions.allowRemoteHalfClosure).whenSuccess { halfClosureAllowed in
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 caching this value is semantically quite right: users can change this value during the lifetime of the Channel. We should look it up when we need it.

Similarly, we should try to use the sync options where we can.

}


// TODO: how do I test this?
Copy link
Contributor

Choose a reason for hiding this comment

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

With EmbeddedChannel, you can fire the input closed event into the pipeline manually.

userInboundInputClosedTriggered(context: context)
default:
context.fireUserInboundEventTriggered(event)
// Trigger closure of TCP input as this event has not occured although we are in state .inputClosed already.
Copy link
Contributor

Choose a reason for hiding this comment

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

This code seems to be in the wrong place.

let channelError: NIOSSLError
switch self.state {
case .closed, .idle:
return
Copy link
Contributor

Choose a reason for hiding this comment

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

In both of these cases we should probably still send the user event onwards.

// We flush all outstanding writes once the handshake step is complete and set our state to .outputClosed aftwerwards.
// This prevents any further writes to this channel.
if let promise = promise, let closeOutputPromise = self.closeOutputPromise {
closeOutputPromise.futureResult.cascade(to: promise)
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 possibly have a closeOutputPromise if we're not in state closingOutput?

} else if let promise = promise {
self.closeOutputPromise = promise
self.state = .closingOutput(self.scheduleTimedOutShutdown(context: context))
self.doShutdownStep(context: context)
Copy link
Contributor

Choose a reason for hiding this comment

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

It seems wrong to only do the shutdown if there's a promise.


self.flush(context: context)

self.closeOutputPromise?.futureResult.whenSuccess {
Copy link
Contributor

Choose a reason for hiding this comment

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

This is incorrect: if we don't have a promise, this never fires.

This makes me thing that maybe outputClosing isn't necessary at all. It's a sort of immediate transition: we send our CLOSE_NOTIFY and the process is conceptually "done". So maybe this is an atomic transition.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Ok, I have added two new commits, one removing the .outputClosing state and one removing the closeOutputPromise — let me know what you think!

@felixschlegel felixschlegel force-pushed the fs-new-close-mode-behaviour branch 2 times, most recently from 5ed780a to 41ebe30 Compare May 23, 2023 10:26
@felixschlegel felixschlegel requested a review from Lukasa May 24, 2023 09:48
self.state = .closing(self.scheduleTimedOutShutdown(context: context))
case .unwrapping, .closing:
case .active, .outputClosed:
if self.getAllowRemoteHalfClosureFromChannel(context: context) == false {
Copy link
Contributor

Choose a reason for hiding this comment

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

Ideally we'd cache this across the call to doFlushReadData.

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 point!

var halfClosureAllowed = false
if let syncOptions = context.channel.syncOptions {
let result = try? syncOptions.getOption(ChannelOptions.allowRemoteHalfClosure)
result.map { halfClosureAllowed = $0 }
Copy link
Contributor

Choose a reason for hiding this comment

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

Using map for the side-effect is a bit weird here, it's probably better to use if let.


/// Completes the given promise when all outstanding writes have been flushed.
private func bufferedWritesCascadeFlushPromise(
to promise: EventLoopPromise<Void>?,
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 this use of the promise is correct. It should be attached to the close(mode: .output) operation. We should fail it if we don't actually issue that call, but we shouldn't succeed it ourselves.

@felixschlegel felixschlegel requested a review from Lukasa May 30, 2023 13:30
flushCompletePromise.futureResult.whenComplete { _ in
self.doShutdownStep(context: context)
}
bufferedWritesCascadeFlushPromise(to: flushCompletePromise, context: context)
Copy link
Contributor

Choose a reason for hiding this comment

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

So this promise dance is probably unnecessary. We can send closeOutput once we've called flush, even if the write promises haven't completed. Handlers are required to queue close(mode: .output) behind writes.

completeHandshake(context: context)
}
case .unwrapping, .closing, .unwrapped, .closed:
case .unwrapping, .closing, .unwrapped, .closed, .inputClosed, .outputClosed:
Copy link
Contributor

Choose a reason for hiding this comment

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

I think this is wrong, .inputClosed and .outputClosed should match active here I think.

@felixschlegel felixschlegel requested a review from Lukasa June 1, 2023 09:42
@felixschlegel
Copy link
Contributor Author

Hey @Lukasa ,

I have implemented your requested changes and added the buffering of closeOutput when we are still in state = .handshaking / .additionalVerification.

I still have a couple of open questions for this PR:

  • should we also support scheduledShutdown for close(mode: .output) (see the normal close mode for reference)
  • what if the downstream ChannelHandler does not support close(mode: .output)? At the moment we just invoke context.close(mode: .output) hoping for the next ChannelHandler not to fail

Best regards,
-Felix

@Lukasa
Copy link
Contributor

Lukasa commented Jun 8, 2023

@swift-server-bot add to allowlist

@Lukasa
Copy link
Contributor

Lukasa commented Jun 8, 2023

should we also support scheduledShutdown for close(mode: .output) (see the normal close mode for reference)

No, scheduled shutdown is waiting for us to get a CLOSE_NOTIFY from the remote peer. In this mode, we don't expect one.

what if the downstream ChannelHandler does not support close(mode: .output)?

Then the partial close fails and the user should upgrade to full close.

let bufferedWrite = self.bufferedWrites.removeFirst()
bufferedWrite.promise?.fail(reason)
private func discardBufferedActions(reason: Error) {
while self.bufferedActions.count > 0 {
Copy link
Contributor

Choose a reason for hiding this comment

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

This can be more concisely expressed as while let bufferedAction = self.bufferedActions.popFirst().

}
return writeSuccessful
case .closeOutput:
invokeCloseOutput = true
Copy link
Contributor

Choose a reason for hiding this comment

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

This looks like it incorrect reorders a close(mode: .output) with writes that follow it. Once we get close(mode: .output) we should stop iterating the buffered actions and then, after we send the close, discard anything left in the buffer.

} catch {
// We encountered an error, it's cleanup time. Close ourselves down.
channelClose(context: context, reason: error)
if case .outputClosed = self.state {} else {
Copy link
Contributor

Choose a reason for hiding this comment

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

This is a really weird construction, prefer using a switch.

felixschlegel and others added 4 commits June 23, 2023 13:13
Modifications:

* flush outstanding messages on NIOSSLHandler.close(mode: .output)
* change state once NIOSSLHandler.close(mode: .output) is invoked, so
  that subsequent writes to the channel fail
* write integration tests to verify the behaviour
Modifications:

* send user event "input closed" onwards when in state .closed or .idle
* trigger closure of TCP input once we enter the .inputClosed state
Modifications:

* in `closeOuput` trigger the shutdown step regardless of the existence
  of a promise
* remove redundant state transition to `.outputClosed`
Modifications:

* remove `.closingOutput` state
Modifications:

* pass on user inbound event "input closed" to the next `ChannelHandler`
  event in the event of an error
* cache value of `ChannelOptions.allowRemoteHalfClosure` across
  `doFlushReadData` to avoid races in case the option's value
  changes while `doFlushReadData` is being executed
* change behaviour: pass `closeOutputPromise` on in `context.close(mode:
  .output, promise: closeOutputPromise`
Modifications:

* `NIOSSLHandler.userInboundInputClosedTriggered`: execute
  `fireErrorCaught` before `userInboundEventTriggered(.inputClosed)`
* `func completedAdditionalPeerCertificateVerification` fails with
  invalid state when in states `{.input,.ouput}closed`
Modifications:

* create `enum BufferedAction` that allows `NIOSSLHandler` to buffer
  actions instead of just writes
* rename `bufferedWrites` -> `bufferdActions`
* add `BufferedAction.closeOutput`, which allows us to buffer
  invocations of `close(mode: .output)` when we are still in non-active
  modes such as `.handshaking` or `.additionalVerification` -> this
  means we'll invoke the closing of the output after our handshaking
  step is completed and all our buffered writes have been flushed
Modifications:

* Refactoring
* fixed wrong comment
* remove `MarkedCircularBuffer.forEachElementUntilMark()`
* discard all buffered actions that occured after the `.closeOutput`
  buffered actions in `NIOSSLHandler.doUnbufferActions()`
Motivation:

Previously we did not check the current state when setting the
`NIOSSLHandler`'s state to `.inputClosed`. This resulted in an issue
where when we were in state `.closing`, then set the state to
`.inputClosed`, we would end up with a fatalError in our shutdown /
scheduledShutdown as state `.inputClosed` is not a shutting-down state.

Modifications:

* check current state before setting `state = .inputClosed`,
  specifically not setting the state to `.inputClosed` when we are
  already in the process of doing a full closure
@felixschlegel felixschlegel force-pushed the fs-new-close-mode-behaviour branch from 494fb31 to 93458ef Compare June 23, 2023 12:14
@felixschlegel
Copy link
Contributor Author

@swift-server-bot test this please

@felixschlegel felixschlegel requested a review from Lukasa June 23, 2023 12:22
@yim-lee
Copy link
Member

yim-lee commented Jun 23, 2023

@swift-server-bot test this please

Modifications:

* `NIOSSLHandler`: escalate to full closure when both input and output
  are closed
* `NIOSSLIntegrationTest.testCloseModeOutputServerAndClient`: assert
  that having input and output closed in `NIOSSLHandler` results in full
  closure
* `NIOSSLIntegrationTest.testCloseModeOutputServerAndClient`: assert
  that if one side closes its output, the other side of the connection
  closes its input
@felixschlegel felixschlegel force-pushed the fs-new-close-mode-behaviour branch from 93458ef to 937b25b Compare June 26, 2023 09:52
switch targetCompleteState {
case .outputClosed:
// No full channel close here. We would expect users to invoke a full close regardless of
// previously completed half closures.
Copy link
Contributor

Choose a reason for hiding this comment

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

This comment is out of date now, right?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Thanks for pointing that out! I have updated the comment so that it reflects the current state of our half-closure behaviour 😄

channelClose(context: context, reason: error)
switch self.state {
case .outputClosed:
break
Copy link
Contributor

Choose a reason for hiding this comment

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

Why do we suppress error handling in this state?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Fixed 🔨

Modifications:

* update comment on why we don't do a full channel close when we execute
  `doShutDownStep` for state `.outputClosed`
* do not suppress error in `NIOSSLHandler.doUnbufferActions` when in
  state `.outputClosed`
@felixschlegel felixschlegel requested a review from Lukasa June 26, 2023 13:06
@Lukasa Lukasa merged commit 678b641 into apple:main Jun 26, 2023
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.

3 participants