/*
 * Copyright © 2018-2019 Apple Inc. and the ServiceTalk project authors
 *
 * Licensed 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
 *
 *   http://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.
 */
package io.servicetalk.concurrent.api.internal;

import io.servicetalk.buffer.api.Buffer;
import io.servicetalk.concurrent.PublisherSource;
import io.servicetalk.concurrent.PublisherSource.Subscriber;
import io.servicetalk.concurrent.PublisherSource.Subscription;
import io.servicetalk.concurrent.api.Publisher;
import io.servicetalk.concurrent.api.TestPublisherSubscriber;
import io.servicetalk.concurrent.internal.ServiceTalkTestTimeout;

import org.junit.After;
import org.junit.Before;
import org.junit.Rule;
import org.junit.Test;
import org.junit.rules.ExpectedException;
import org.junit.rules.Timeout;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import java.io.IOException;
import java.util.Random;
import java.util.concurrent.BrokenBarrierException;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.CyclicBarrier;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.atomic.AtomicReference;
import javax.annotation.Nullable;

import static io.servicetalk.buffer.netty.BufferAllocators.PREFER_HEAP_ALLOCATOR;
import static io.servicetalk.concurrent.api.SourceAdapters.toSource;
import static io.servicetalk.concurrent.api.internal.ConnectablePayloadWriterTest.toRunnable;
import static io.servicetalk.concurrent.api.internal.ConnectablePayloadWriterTest.verifyCheckedRunnableException;
import static io.servicetalk.concurrent.internal.DeliberateException.DELIBERATE_EXCEPTION;
import static io.servicetalk.concurrent.internal.TerminalNotification.complete;
import static java.lang.Math.max;
import static java.lang.Math.min;
import static java.lang.Runtime.getRuntime;
import static org.hamcrest.MatcherAssert.assertThat;
import static org.hamcrest.Matchers.contains;
import static org.hamcrest.Matchers.empty;
import static org.hamcrest.Matchers.equalTo;
import static org.hamcrest.Matchers.instanceOf;
import static org.hamcrest.Matchers.is;
import static org.junit.Assert.assertArrayEquals;
import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.assertNull;
import static org.junit.Assert.assertSame;
import static org.junit.Assert.fail;
import static org.junit.rules.ExpectedException.none;

public class ConnectableBufferOutputStreamTest {
    private static final Logger LOGGER = LoggerFactory.getLogger(ConnectableBufferOutputStreamTest.class);
    @Rule
    public final Timeout timeout = new ServiceTalkTestTimeout();
    @Rule
    public final ExpectedException expectedException = none();

    private final TestPublisherSubscriber<Buffer> subscriber = new TestPublisherSubscriber<>();
    private ConnectableBufferOutputStream cbos;
    private ExecutorService executorService;

    @Before
    public void setUp() {
        cbos = new ConnectableBufferOutputStream(PREFER_HEAP_ALLOCATOR);
        executorService = Executors.newCachedThreadPool();
    }

    @After
    public void teardown() {
        executorService.shutdown();
    }

    @Test
    public void subscribeDeliverDataSynchronously() throws Exception {
        AtomicReference<Future<?>> futureRef = new AtomicReference<>();
        toSource(cbos.connect().afterOnSubscribe(subscription -> {
            subscriber.request(1); // request from the TestPublisherSubscriber!
            // We want to increase the chance that the writer thread has to wait for the Subscriber to become
            // available, instead of waiting for the requestN demand.
            CyclicBarrier barrier = new CyclicBarrier(2);
            futureRef.compareAndSet(null, executorService.submit(toRunnable(() -> {
                barrier.await();
                cbos.write(1);
                cbos.flush();
                cbos.close();
            })));
            try {
                barrier.await();
            } catch (InterruptedException | BrokenBarrierException e) {
                throw new RuntimeException(e);
            }
        })).subscribe(subscriber);

        Future<?> f = futureRef.get();
        assertNotNull(f);
        f.get();
        assertThat(subscriber.takeItems(), contains(buf(1)));
        assertThat(subscriber.takeTerminal(), is(complete()));
    }

    @Test
    public void subscribeCloseSynchronously() throws Exception {
        AtomicReference<Future<?>> futureRef = new AtomicReference<>();
        toSource(cbos.connect().afterOnSubscribe(subscription -> {
            // We want to increase the chance that the writer thread has to wait for the Subscriber to become
            // available, instead of waiting for the requestN demand.
            CyclicBarrier barrier = new CyclicBarrier(2);
            futureRef.compareAndSet(null, executorService.submit(toRunnable(() -> {
                barrier.await();
                cbos.close();
            })));
            try {
                barrier.await();
            } catch (InterruptedException | BrokenBarrierException e) {
                throw new RuntimeException(e);
            }
        })).subscribe(subscriber);

        Future<?> f = futureRef.get();
        assertNotNull(f);
        f.get();
        assertThat(subscriber.takeTerminal(), is(complete()));
    }

    @Test
    public void writeAfterCloseShouldThrow() throws IOException {
        cbos.close();
        expectedException.expect(IOException.class);
        cbos.write(1);

        // Make sure the Subscription thread isn't blocked.
        subscriber.request(1);
        subscriber.cancel();
    }

    @Test
    public void multipleWriteAfterCloseShouldThrow() throws Exception {
        Future<?> f = executorService.submit(toRunnable(() -> {
            cbos.write(1);
            cbos.flush();
            cbos.close();
            cbos.write(2);
            cbos.flush();
        }));

        toSource(cbos.connect()).subscribe(subscriber);
        subscriber.request(2);
        try {
            f.get();
            fail();
        } catch (ExecutionException e) {
            verifyCheckedRunnableException(e, IOException.class);
        }

        assertThat(subscriber.takeItems(), contains(buf(1)));
        assertThat(subscriber.takeTerminal(), is(complete()));

        // Make sure the Subscription thread isn't blocked.
        subscriber.request(1);
        subscriber.cancel();
    }

    @Test
    public void connectMultipleWriteAfterCloseShouldThrow() throws Exception {
        toSource(cbos.connect()).subscribe(subscriber);
        subscriber.request(2);
        Future<?> f = executorService.submit(toRunnable(() -> {
            cbos.write(1);
            cbos.flush();
            cbos.close();
            cbos.write(2);
            cbos.flush();
        }));

        try {
            f.get();
            fail();
        } catch (ExecutionException e) {
            verifyCheckedRunnableException(e, IOException.class);
        }

        assertThat(subscriber.takeItems(), contains(buf(1)));
        assertThat(subscriber.takeTerminal(), is(complete()));

        // Make sure the Subscription thread isn't blocked.
        subscriber.request(1);
        subscriber.cancel();
    }

    @Test
    public void cancelUnblocksWrite() throws Exception {
        CyclicBarrier afterFlushBarrier = new CyclicBarrier(2);
        Future<?> f = executorService.submit(toRunnable(() -> {
            cbos.write(1);
            cbos.flush();
            afterFlushBarrier.await();
            cbos.write(2);
            cbos.flush();
        }));

        toSource(cbos.connect()).subscribe(subscriber);
        subscriber.request(1);
        afterFlushBarrier.await();
        subscriber.cancel();
        try {
            f.get();
            fail();
        } catch (ExecutionException e) {
            verifyCheckedRunnableException(e, IOException.class);
        }

        assertThat(subscriber.takeItems(), contains(buf(1)));
        assertThat(subscriber.takeTerminal(), is(complete()));
        cbos.close(); // should be idempotent

        // Make sure the Subscription thread isn't blocked.
        subscriber.request(1);
        subscriber.cancel();
    }

    @Test
    public void connectCancelUnblocksWrite() throws Exception {
        toSource(cbos.connect()).subscribe(subscriber);
        subscriber.cancel();
        Future<?> f = executorService.submit(toRunnable(() -> cbos.write(1)));

        try {
            f.get();
            fail();
        } catch (ExecutionException e) {
            verifyCheckedRunnableException(e, IOException.class);
        }

        assertThat(subscriber.takeItems(), is(empty()));
        assertThat(subscriber.takeTerminal(), is(complete()));
        cbos.close(); // should be idempotent

        // Make sure the Subscription thread isn't blocked.
        subscriber.request(1);
        subscriber.cancel();
    }

    @Test
    public void closeShouldBeIdempotent() throws Exception {
        Future<?> f = executorService.submit(toRunnable(() -> {
            cbos.write(1);
            cbos.flush();
            cbos.close();
        }));

        toSource(cbos.connect()).subscribe(subscriber);
        subscriber.request(1);
        f.get();
        assertThat(subscriber.takeItems(), contains(buf(1)));
        assertThat(subscriber.takeTerminal(), is(complete()));
        cbos.close(); // should be idempotent
    }

    @Test
    public void closeShouldBeIdempotentWhenNotSubscribed() throws IOException {
        cbos.connect();
        cbos.close();
        cbos.close(); // should be idempotent
    }

    @Test
    public void multipleConnectWithInvalidRequestnShouldFailConnect() throws Exception {
        CountDownLatch onSubscribe = new CountDownLatch(1);
        CountDownLatch onComplete = new CountDownLatch(1);
        AtomicReference<Throwable> errorRef = new AtomicReference<>();
        toSource(cbos.connect()).subscribe(new Subscriber<Buffer>() {
            @Override
            public void onSubscribe(final Subscription s) {
                s.request(-1);
                onSubscribe.countDown();
            }

            @Override
            public void onNext(final Buffer buffer) {
            }

            @Override
            public void onError(final Throwable t) {
                errorRef.set(t);
            }

            @Override
            public void onComplete() {
                onComplete.countDown();
            }
        });
        cbos.close();
        onSubscribe.await();
        assertThat(errorRef.get(), instanceOf(IllegalArgumentException.class));
        toSource(cbos.connect()).subscribe(subscriber);
        assertThat(subscriber.takeError(), instanceOf(IllegalStateException.class));
        assertThat(onComplete.getCount(), equalTo(1L));
    }

    @Test
    public void multipleConnectWhileEmittingShouldFailConnect() throws Exception {
        CountDownLatch onNext = new CountDownLatch(1);
        CountDownLatch onComplete = new CountDownLatch(1);
        toSource(cbos.connect()).subscribe(new Subscriber<Buffer>() {
            @Override
            public void onSubscribe(final Subscription s) {
                s.request(1);
            }

            @Override
            public void onNext(final Buffer buffer) {
                onNext.countDown();
            }

            @Override
            public void onError(final Throwable t) {
            }

            @Override
            public void onComplete() {
                onComplete.countDown();
            }
        });
        cbos.write(0);
        cbos.flush();
        cbos.close();
        onNext.await();
        toSource(cbos.connect()).subscribe(subscriber);
        assertThat(subscriber.takeError(), instanceOf(IllegalStateException.class));
        onComplete.await();
    }

    @Test
    public void multipleConnectWhileSubscribedShouldFailConnect() throws Exception {
        CountDownLatch onSubscribe = new CountDownLatch(1);
        CountDownLatch onComplete = new CountDownLatch(1);
        toSource(cbos.connect()).subscribe(new Subscriber<Buffer>() {
            @Override
            public void onSubscribe(final Subscription s) {
                onSubscribe.countDown();
            }

            @Override
            public void onNext(final Buffer buffer) {
            }

            @Override
            public void onError(final Throwable t) {
            }

            @Override
            public void onComplete() {
                onComplete.countDown();
            }
        });
        cbos.close();
        onSubscribe.await();
        toSource(cbos.connect()).subscribe(subscriber);
        assertThat(subscriber.takeError(), instanceOf(IllegalStateException.class));
        onComplete.await();
    }

    @Test
    public void multipleConnectWhileSubscriberFailedShouldFailConnect() throws Exception {
        CountDownLatch onError = new CountDownLatch(1);
        CountDownLatch onComplete = new CountDownLatch(1);
        toSource(cbos.connect()).subscribe(new Subscriber<Buffer>() {
            @Override
            public void onSubscribe(final Subscription s) {
                s.request(1);
            }

            @Override
            public void onNext(final Buffer buffer) {
                throw DELIBERATE_EXCEPTION;
            }

            @Override
            public void onError(final Throwable t) {
                onError.countDown();
            }

            @Override
            public void onComplete() {
                onComplete.countDown();
            }
        });
        try {
            cbos.write(1);
            fail();
        } catch (RuntimeException cause) {
            assertSame(DELIBERATE_EXCEPTION, cause);
        }
        try {
            cbos.flush();
            fail();
        } catch (IOException ignored) {
            // expected
        }
        cbos.close();
        onError.await();
        toSource(cbos.connect()).subscribe(subscriber);
        assertThat(subscriber.takeError(), instanceOf(IllegalStateException.class));
        assertThat(onComplete.getCount(), equalTo(1L));
    }

    @Test
    public void writeFlushCloseConnectSubscribeRequest() throws Exception {
        Future<?> f = executorService.submit(toRunnable(() -> {
            cbos.write(1);
            cbos.flush();
            cbos.close();
        }));

        toSource(cbos.connect()).subscribe(subscriber);
        subscriber.request(1);
        f.get();
        assertThat(subscriber.takeItems(), contains(buf(1)));
        assertThat(subscriber.takeTerminal(), is(complete()));
    }

    @Test
    public void connectSubscribeRequestWriteFlushClose() throws Exception {
        toSource(cbos.connect()).subscribe(subscriber);
        assertThat(subscriber.takeItems(), is(empty()));
        subscriber.request(1);
        Future<?> f = executorService.submit(toRunnable(() -> {
            cbos.write(1);
            cbos.flush();
            cbos.close();
        }));
        f.get();
        assertThat(subscriber.takeItems(), contains(buf(1)));
        assertThat(subscriber.takeTerminal(), is(complete()));
    }

    @Test
    public void connectSubscribeWriteFlushCloseRequest() throws Exception {
        toSource(cbos.connect()).subscribe(subscriber);
        Future<?> f = executorService.submit(toRunnable(() -> {
            cbos.write(1);
            cbos.flush();
            cbos.close();
        }));
        assertThat(subscriber.takeItems(), is(empty()));
        subscriber.request(1);
        f.get();
        assertThat(subscriber.takeItems(), contains(buf(1)));
        assertThat(subscriber.takeTerminal(), is(complete()));
    }

    @Test
    public void requestWriteSingleWriteSingleFlushClose() throws Exception {
        toSource(cbos.connect()).subscribe(subscriber);
        assertThat(subscriber.takeItems(), is(empty()));
        subscriber.request(2);
        Future<?> f = executorService.submit(toRunnable(() -> {
            cbos.write(1);
            cbos.write(2);
            cbos.flush();
            cbos.close();
        }));
        f.get();
        assertThat(subscriber.takeItems(), contains(buf(1), buf(2)));
        assertThat(subscriber.takeTerminal(), is(complete()));
    }

    @Test
    public void requestWriteSingleFlushWriteSingleFlushClose() throws Exception {
        toSource(cbos.connect()).subscribe(subscriber);
        assertThat(subscriber.takeItems(), is(empty()));
        subscriber.request(2);
        Future<?> f = executorService.submit(toRunnable(() -> {
            cbos.write(1);
            cbos.flush();
            cbos.write(2);
            cbos.flush();
            cbos.close();
        }));
        f.get();
        assertThat(subscriber.takeItems(), contains(buf(1), buf(2)));
        assertThat(subscriber.takeTerminal(), is(complete()));
    }

    @Test
    public void writeSingleFlushWriteSingleFlushRequestClose() throws Exception {
        toSource(cbos.connect()).subscribe(subscriber);
        assertThat(subscriber.takeItems(), is(empty()));
        subscriber.request(1);
        Future<?> f = executorService.submit(toRunnable(() -> {
            cbos.write(1);
            cbos.flush();
            cbos.write(2);
            cbos.flush();
            cbos.close();
        }));
        subscriber.request(1);
        f.get();
        assertThat(subscriber.takeItems(), contains(buf(1), buf(2)));
        assertThat(subscriber.takeTerminal(), is(complete()));
    }

    @Test
    public void invalidRequestN() throws IOException {
        AtomicReference<Throwable> failure = new AtomicReference<>();
        toSource(cbos.connect()).subscribe(new Subscriber<Buffer>() {
            @Override
            public void onSubscribe(final PublisherSource.Subscription s) {
                s.request(-1);
            }

            @Override
            public void onNext(final Buffer buffer) {
                failure.set(new AssertionError("onNext received for illegal request-n"));
            }

            @Override
            public void onError(final Throwable t) {
                failure.set(t);
            }

            @Override
            public void onComplete() {
                failure.set(new AssertionError("onComplete received for illegal request-n"));
            }
        });

        cbos.close();
        assertThat("Unexpected failure", failure.get(), is(instanceOf(IllegalArgumentException.class)));
    }

    @Test
    public void onNextThrows() throws IOException {
        AtomicReference<Throwable> failure = new AtomicReference<>();
        toSource(cbos.connect()).subscribe(new Subscriber<Buffer>() {
            @Override
            public void onSubscribe(final PublisherSource.Subscription s) {
                s.request(1);
            }

            @Override
            public void onNext(final Buffer buffer) {
                throw DELIBERATE_EXCEPTION;
            }

            @Override
            public void onError(final Throwable t) {
                failure.set(t);
            }

            @Override
            public void onComplete() {
                failure.set(new AssertionError("onComplete received when onNext threw."));
            }
        });
        try {
            cbos.write(1);
            fail();
        } catch (RuntimeException cause) {
            assertSame(DELIBERATE_EXCEPTION, cause);
        }
        cbos.close();
        assertThat("Unexpected failure", failure.get(), is(DELIBERATE_EXCEPTION));
    }

    @Test
    public void cancelCloses() throws Exception {
        toSource(cbos.connect()).subscribe(subscriber);
        assertThat(subscriber.takeItems(), is(empty()));
        subscriber.cancel();
        Future<?> f = executorService.submit(toRunnable(() -> cbos.write(1)));
        expectedException.expect(ExecutionException.class);
        expectedException.expectCause(is(instanceOf(RuntimeException.class)));
        f.get();
    }

    @Test
    public void cancelCloseAfterWrite() throws Exception {
        toSource(cbos.connect()).subscribe(subscriber);
        assertThat(subscriber.takeItems(), is(empty()));
        subscriber.request(1);
        Future<?> f = executorService.submit(toRunnable(() -> {
            cbos.write(1);
            cbos.flush();
        }));
        f.get();
        assertThat(subscriber.takeItems(), contains(buf(1)));

        subscriber.cancel();
        expectedException.expect(is(instanceOf(IOException.class)));
        cbos.write(2);
    }

    @Test
    public void requestNegativeWrite() throws Exception {
        toSource(cbos.connect()).subscribe(subscriber);
        assertThat(subscriber.takeItems(), is(empty()));
        subscriber.request(-1);
        Future<?> f = executorService.submit(toRunnable(() -> {
            cbos.write(1);
            cbos.flush();
        }));
        try {
            f.get();
            fail();
        } catch (ExecutionException e) {
            verifyCheckedRunnableException(e, IOException.class);
        }
        assertThat(subscriber.takeError(), is(instanceOf(IllegalArgumentException.class)));
    }

    @Test
    public void writeRequestNegative() throws Exception {
        toSource(cbos.connect()).subscribe(subscriber);
        assertThat(subscriber.takeItems(), is(empty()));
        CyclicBarrier cb = new CyclicBarrier(2);
        Future<?> f = executorService.submit(toRunnable(() -> {
            cb.await();
            cbos.write(1);
            cbos.flush();
        }));
        cb.await();
        subscriber.request(-1);
        try {
            f.get();
            fail();
        } catch (ExecutionException e) {
            verifyCheckedRunnableException(e, IOException.class);
        }
        assertThat(subscriber.takeError(), is(instanceOf(IllegalArgumentException.class)));
    }

    @Test
    public void closeNoWrite() throws Exception {
        CyclicBarrier cb = new CyclicBarrier(2);
        Future<?> f = executorService.submit(toRunnable(() -> {
            cb.await();
            cbos.close();
        }));
        final Publisher<Buffer> connect = cbos.connect();
        cb.await();
        toSource(connect).subscribe(subscriber);
        subscriber.request(1);
        f.get();
        assertThat(subscriber.takeItems(), is(empty()));
        assertThat(subscriber.takeTerminal(), is(complete()));
    }

    @Test
    public void requestWriteArrWriteArrFlushClose() throws Exception {
        final Publisher<Buffer> connect = cbos.connect();
        toSource(connect).subscribe(subscriber);
        subscriber.request(1);
        Future<?> f = executorService.submit(toRunnable(() -> {
            cbos.write(new byte[]{1, 2});
            cbos.write(new byte[]{3, 4});
            cbos.flush();
            cbos.close();
        }));
        subscriber.request(1);
        f.get();
        assertThat(subscriber.takeItems(), contains(buf(1, 2), buf(3, 4)));
        assertThat(subscriber.takeTerminal(), is(complete()));
    }

    @Test
    public void requestWriteArrFlushWriteArrFlushClose() throws Exception {
        final Publisher<Buffer> connect = cbos.connect();
        toSource(connect).subscribe(subscriber);
        subscriber.request(2);
        executorService.submit(toRunnable(() -> {
            cbos.write(new byte[]{1, 2});
            cbos.flush();
            cbos.write(new byte[]{3, 4});
            cbos.flush();
            cbos.close();
        })).get();
        assertThat(subscriber.takeItems(), contains(buf(1, 2), buf(3, 4)));
        assertThat(subscriber.takeTerminal(), is(complete()));
    }

    @Test
    public void writeArrFlushWriteArrFlushRequestClose() throws Exception {
        final Publisher<Buffer> connect = cbos.connect();
        toSource(connect).subscribe(subscriber);
        Future<?> f = executorService.submit(toRunnable(() -> {
            cbos.write(new byte[]{1, 2});
            cbos.flush();
            cbos.write(new byte[]{3, 4});
            cbos.flush();
            cbos.close();
        }));
        subscriber.request(2);
        f.get();
        assertThat(subscriber.takeItems(), contains(buf(1, 2), buf(3, 4)));
        assertThat(subscriber.takeTerminal(), is(complete()));
    }

    @Test
    public void requestWriteArrOffWriteArrOffFlushClose() throws Exception {
        final Publisher<Buffer> connect = cbos.connect();
        toSource(connect).subscribe(subscriber);
        subscriber.request(2);
        executorService.submit(toRunnable(() -> {
            cbos.write(new byte[]{1, 2, 3, 4}, 1, 3);
            cbos.write(new byte[]{5, 6, 7, 8}, 1, 3);
            cbos.flush();
            cbos.close();
        })).get();
        assertThat(subscriber.takeItems(), contains(buf(2, 3, 4), buf(6, 7, 8)));
        assertThat(subscriber.takeTerminal(), is(complete()));
    }

    @Test
    public void requestWriteArrOffFlushWriteArrOffFlushClose() throws Exception {
        final Publisher<Buffer> connect = cbos.connect();
        toSource(connect).subscribe(subscriber);
        subscriber.request(2);
        executorService.submit(toRunnable(() -> {
            cbos.write(new byte[]{1, 2, 3, 4}, 1, 3);
            cbos.flush();
            cbos.write(new byte[]{5, 6, 7, 8}, 1, 3);
            cbos.flush();
            cbos.close();
        })).get();
        assertThat(subscriber.takeItems(), contains(buf(2, 3, 4), buf(6, 7, 8)));
        assertThat(subscriber.takeTerminal(), is(complete()));
    }

    @Test
    public void writeArrOffFlushWriteArrOffFlushRequestClose() throws Exception {
        final Publisher<Buffer> connect = cbos.connect();
        toSource(connect).subscribe(subscriber);
        Future<?> f = executorService.submit(toRunnable(() -> {
            cbos.write(new byte[]{1, 2, 3, 4}, 1, 3);
            cbos.flush();
            cbos.write(new byte[]{5, 6, 7, 8}, 1, 2);
            cbos.flush();
            cbos.close();
        }));
        subscriber.request(2);
        f.get();
        assertThat(subscriber.takeItems(), contains(buf(2, 3, 4), buf(6, 7)));
        assertThat(subscriber.takeTerminal(), is(complete()));
    }

    @Test
    public void multiThreadedProducerConsumer() throws Exception {

        final Random r = new Random();
        final long seed = r.nextLong();  // capture seed to have repeatable tests
        r.setSeed(seed);

        // 3% of heap or max of 100 MiB
        final int dataSize = (int) min(getRuntime().maxMemory() * 0.03, 100 * 1024 * 1024);
        LOGGER.info("Test seed = {} – data size = {}", seed, dataSize);

        final AtomicReference<Throwable> error = new AtomicReference<>();

        final byte[] data = new byte[dataSize];
        final byte[] received = new byte[dataSize];
        r.nextBytes(data);

        final Publisher<Buffer> pub = cbos.connect();

        final Thread producerThread = new Thread(() -> {
            int writeIndex = 0;
            try {
                while (writeIndex < dataSize) {
                    // write at most 25% of remaining bytes
                    final int length = (int) max(1, r.nextInt(dataSize - (writeIndex - 1)) * 0.25);
                    LOGGER.debug("Writing {} bytes - writeIndex = {}", length, writeIndex);
                    cbos.write(data, writeIndex, length);
                    writeIndex += length;
                    if (r.nextDouble() < 0.4) {
                        LOGGER.debug("Flushing - writeIndex = {}", writeIndex);
                        cbos.flush();
                    }
                }
                LOGGER.debug("Closing - writeIndex = {}", writeIndex);
                cbos.close();
            } catch (Throwable t) {
                error.compareAndSet(null, t);
            }
        });

        final Thread consumerThread = new Thread(() -> {
            try {
                final CountDownLatch consumerDone = new CountDownLatch(1);
                toSource(pub).subscribe(new Subscriber<Buffer>() {
                    @Nullable
                    private Subscription sub;
                    private int writeIndex;

                    @Override
                    public void onSubscribe(final Subscription s) {
                        sub = s;
                        sub.request(1);
                    }

                    @Override
                    public void onNext(final Buffer buffer) {
                        final int readingBytes = buffer.readableBytes();
                        LOGGER.debug("Reading {} bytes, writeIndex = {}", readingBytes, writeIndex);
                        buffer.readBytes(received, writeIndex, readingBytes);
                        writeIndex += readingBytes;
                        assert sub != null : "Subscription can not be null in onNext.";
                        sub.request(1);
                    }

                    @Override
                    public void onError(final Throwable t) {
                        error.compareAndSet(null, t);
                        consumerDone.countDown();
                    }

                    @Override
                    public void onComplete() {
                        consumerDone.countDown();
                    }
                });
                consumerDone.await();
            } catch (Throwable t) {
                error.compareAndSet(null, t);
            }
        });

        producerThread.start();
        consumerThread.start();

        // make sure both threads exit
        producerThread.join();
        consumerThread.join(); // provides visibility for received from consumerThread
        assertNull(error.get());
        assertArrayEquals(data, received); // assertThat() times out
    }

    private static Buffer buf(int... bytes) {
        final byte[] byteArray = new byte[bytes.length];
        for (int i = 0; i < bytes.length; i++) {
            byteArray[i] = (byte) bytes[i];
        }
        return PREFER_HEAP_ALLOCATOR.wrap(byteArray);
    }
}