Subscriber receives new values after subscription to Publishers.Delay publisher has been cancelled

Environment:
Xcode 11.4.1
Xcode 11.5 beta 2
iOS 13.4.1
iOS 13.5 (simulator)
macOS 10.15.4
Sample code
You can find example playground on github

Description
Subscriber receives new values after subscription to Publishers.Delay publisher has been cancelled.

  • Create pipeline with Delay publisher.
  • Wait for upstream to produce value.
  • Cancel subscription before subscriber receives delayed value event.

Playground example:

import Cocoa
import Combine
import PlaygroundSupport

let page = PlaygroundPage.current
page.needsIndefiniteExecution = true

let delayedTimer = Timer.publish(every: 1.4, on: .main, in: .default).autoconnect() // or Just(Date())
    .print("timer")
    .delay(for: .seconds(5), scheduler: RunLoop.main)
    .print("delay")
    .eraseToAnyPublisher()

var sinkCancellable: AnyCancellable?
sinkCancellable = delayedTimer
    .sink {
        print("value \($0)")
    }

DispatchQueue.main
    .asyncAfter(deadline: .now() + .seconds(3)) {
        sinkCancellable = nil
        print("---- cancel ----")
    }

DispatchQueue.main.asyncAfter(deadline: .now() + .seconds(15)) {
    print("Playground Finished")
    page.finishExecution()
}

Output

timer: receive subscription: (Timer)
delay: receive subscription: (SUBSCRIPTION_TYPE)
delay: request unlimited
timer: request unlimited
timer: receive value: (2020-05-10 09:07:09 +0000)
timer: receive value: (2020-05-10 09:07:11 +0000)
delay: receive cancel
timer: receive cancel
---- cancel ----
delay: receive value: (2020-05-10 09:07:09 +0000)
value 2020-05-10 09:07:09 +0000
delay: receive value: (2020-05-10 09:07:11 +0000)
value 2020-05-10 09:07:11 +0000
Playground Finished

As you can see provided closure is called after cancellable returned by sink is deallocated.
Is this expected behavior?

This means we can't rely on lifecycle of returned cancellable when subscribing to a publisher.
For example, following code will crash if object is deallocated before delayed value is received.

protocol SomeDependency {
    func someMethod() -> AnyPublisher<Any, Never>
}

class SomeObject {
    private var cancellables = Set<AnyCancellable>()

    private let dependency: SomeDependency

    init(dependency: SomeDependency) {
        self.dependency = dependency
    }

    func perform() {
        dependency.someMethod()
            .sink { [unowned self] value in 
                self.doStuff(with: value)
            }
            .store(in: &cancellables)
    }

    private func doStuff(with value: Any) {
        // some logic
    }
}

I believe it just does as (lightly) documented:

A publisher that delays delivery of elements and completion to the downstream receiver.

It does just that: it passes along every event the Timer fires until the sink is cancelled, with a delay. It may not be that useful other than for debugging; if you want a delay-shaped publisher that drops currently-delayed events at the moment it's canceled, you probably need a different publisher.

The code now behaves as expected. No more values are sent after the cancellable is deallocated. Apple must have changed something.

Yes, Combine was changed so that publishers no longer emit values after cancellation in the 2020 version.

:raised_hands:Rejoice :raised_hands:

Yet in Xcode 12.4/Simulator 14.4 here we are again receiving values after cancelation. Color me confused.

delay: request unlimited
timer: request unlimited
timer: receive value: (Hello world!)
timer: receive finished
delay: receive cancel
---- cancel ----
delay: receive value: (Hello world!)
delay: receive finished
Playground Finished

Xcode Playground (can't speak for the Playgrounds app) was and remains just bad for this type of testing. Please re-run your code in a simulator or on a real device and compare the behavior. I've run multiple times into weird issues using playground, such as that it didn't deallocate some values, ect.

As I just said, double check as it could be another playground only related issue, which you should always report at: bugs.swift.org