amphp / websocket-client

Async WebSocket client for PHP based on Amp.

Home Page:https://amphp.org/websocket-client

Geek Repo:Geek Repo

Github PK Tool:Github PK Tool

Creating connection in thread context keeps thread from finishing

remorhaz opened this issue · comments

Hello! My application needs to talk with WebSocket server while it's main thread is blocked, so I used amphp/websocket:0.2.2 combined with amphp/parallel:0.2.5 and krakjoe/pthreads:3.1.7dev to do it from separate thread. Everything goes fine (successfull communication with server, I mean) except for closing the thread. I used the following minimalistic script to investigate the problem:

<?php

require_once __DIR__ . '/../vendor/autoload.php';

ob_start();
phpinfo(INFO_MODULES);
$phpinfo = ob_get_clean();
if (1 !== preg_match('#^pthreads(?:.+?)^Version\s+=>\s+(.*?)$#sm', $phpinfo, $matches)) {
    die("Failed to detect pthreads version");
}
echo "Pthreads version: {$matches[1]}\n";

$delay = 2;

echo "Program started\n";
sleep($delay);

\Amp\Loop::run(
    function () use ($delay) {
        echo "Loop started\n";
        sleep($delay);
        $thread = \Amp\Parallel\Context\Thread::run(
            function (\Amp\Parallel\Sync\Channel $channel, int $delay) {
                echo "Thread started\n";

                $ticker = \Amp\Loop::repeat(
                    1000,
                    function () {
                        static $counter = 0;
                        echo "Tick from thread: ", $counter++, "\n";
                    }
                );

                sleep($delay);

                echo "Thread loop started\n";
                \Amp\Loop::run(
                    function () use ($channel, $ticker) {
                        /** @var Amp\Websocket\Connection $connection */
                        $connection = yield Amp\Websocket\connect("ws://local.websocket:8080");
                        $data = yield $channel->receive();

                        if ('stop' == $data) {
                            \Amp\Loop::cancel($ticker);
                            echo "Ticker stopped\n";

                            echo "Connection is closed: ", $connection->isClosed() ? 'YES' : 'NO', "\n";
                            $connection->close();
                            echo "Connection is closed: ", $connection->isClosed() ? 'YES' : 'NO', "\n";
                        }
                    }
                );
                echo "Out of thread loop\n";
            },
            $delay
        );

        sleep($delay * 2);
        yield $thread->send('stop');

        yield $thread->join();
        echo "Thread joined\n";
    }
);

echo "Out of loop\n";

I also used the following dockerfile to run the script:

FROM php:7.2-zts

RUN apt-get update -q && apt-get install -qy --no-install-recommends \
    git \
    procps \
    && rm -r /var/lib/apt/lists/*

RUN pecl install xdebug \
    && git clone https://github.com/krakjoe/pthreads.git \
        && ( \
            cd pthreads \
            && phpize \
            && ./configure --enable-pthreads \
            && make -j$(nproc) \
            && make install \
        ) \
    && rm -r pthreads \
    && docker-php-ext-enable \
        pthreads \
        xdebug

It produces the following output:

Pthreads version: 3.1.7dev
Program started
Loop started
Thread started
Thread loop started
Tick from thread: 0
Tick from thread: 1
Ticker stopped
Connection is closed: NO
Connection is closed: YES
Out of thread loop
Thread joined
Out of loop

But the script hangs forever. If I comment out all $connection stuff, the program terminates okay. Further investigation with top -H revealed that opening a connection starts one more thread that doesn't finish after calling $connection->close(), and probably that "third" thread keeps "second" thread from finishing.
Is there some workaround on this situation? Is this a bug or maybe I'm just doing something wrong?

Can you try with the latest versions of each of the libraries and let me know if you're still having this problem? Thanks!

I've tried to reproduce the problem today. I have upgraded to krakjoe/pthreads:3.2.1dev, amphp/parallel:1.1.1 and amphp/websocket:0.2.3. I had to add yield before Thread::run() call because it returns promise now. But then I experienced some problems.
At first, handshake used League\Uri\WS class that was not available, so I had to add leadue/uri-schemes:1.2.1 manually. Probably you have missed some internal dependencies, please check it.
Then I've stumbled upon the Did not receive switching protocols response: HTTP/1.0 400 Bad Request: invalid request line error and am trying to solve it now (maybe my server behaves wrong or I need more modifications to old code), but at this point (WS connection established) my script hangs in the same manner it used to do before. So my provisional answer is yes, the problem remains in modernized environment. I will try to make my code work as it worked before and then I will provide you with the updated way to reproduce.

Whoops, yes, league/uri-schemes was suppose to be required instead of uri-parser. I missed that because under dev we have amphp/http-server, which installs uri-schemes. I tagged 0.2.4 that fixes this.

The invalid request line error concerns me. Can I ask what URI you're connecting to? Or at least an example of what it looks like?

I've refactored master to use a new shared repo amphp/websocket. Could you try with dev-master as well? You code will require some namespace changes (from Amp\Websocket to Amp\Websocket\Client), and perhaps a couple of minor changes as options are now passed to the Handshake object.

I've investigated the invalid request line case. The bug is in lib/Handshake.php:70:

$path = $this->uri->getPath() ?? '/';

getPath() always returns string, so when it returns empty string, it is not replaced with slash (resulting in broken request). I used URI ws://local.websocket:8080 in my example, adding / to it solves the problem, but you definitely should fix that.

I will repeat experiment with dev-master and report you a bit later.

Ah, guess that's my fault for always having a / in tests and confusing with PSR-7 UriInterface that returns null. I'll let you experiment more before tagging another version.

Now my code runs okay, but establishing a WS connection still hangs the script in the end, so the problem still exists in upgraded environment. I will post here the the updated code for your convenience:

<?php

require_once __DIR__ . '/../vendor/autoload.php';

ob_start();
phpinfo(INFO_MODULES);
$phpinfo = ob_get_clean();
if (1 !== preg_match('#^pthreads(?:.+?)^Version\s+=>\s+(.*?)$#sm', $phpinfo, $matches)) {
    die("Failed to detect pthreads version");
}
echo "Pthreads version: {$matches[1]}\n";

$delay = 2;

echo "Program started\n";
sleep($delay);

\Amp\Loop::run(
    function () use ($delay) {
        echo "Loop started\n";
        sleep($delay);
        /** @var \Amp\Parallel\Context\Thread $thread */
        $thread = yield \Amp\Parallel\Context\Thread::run(
            function (\Amp\Parallel\Sync\Channel $channel, int $delay) {
                echo "Thread started\n";

                $ticker = \Amp\Loop::repeat(
                    1000,
                    function () {
                        static $counter = 0;
                        echo "Tick from thread: ", $counter++, "\n";
                    }
                );

                sleep($delay);

                echo "Thread loop started\n";
                \Amp\Loop::run(
                    function () use ($channel, $ticker) {
                        /** @var Amp\Websocket\Client\Connection $connection */
                        $connection = yield \Amp\Websocket\Client\connect("ws://local.websocket:8080/");
                        $data = yield $channel->receive();

                        if ('stop' == $data) {
                            \Amp\Loop::cancel($ticker);
                            echo "Ticker stopped\n";

                            echo "Connection is open: ", $connection->isConnected() ? 'YES' : 'NO', "\n";
                            $connection->close();
                            echo "Connection is open: ", $connection->isConnected() ? 'YES' : 'NO', "\n";
                        }
                    }
                );
                echo "Out of thread loop\n";
            },
            $delay
        );

        sleep($delay * 2);
        yield $thread->send('stop');

        yield $thread->join();
        echo "Thread joined\n";
    }
);

echo "Out of loop\n";

It outputs (and then hangs):

Pthreads version: 3.2.1dev
Program started
Loop started
Thread started
Thread loop started
Tick from thread: 0
Tick from thread: 1
Ticker stopped
Connection is open: YES
Connection is open: NO
Out of thread loop
Thread joined
Out of loop

My composer.json is:

{
  "minimum-stability": "dev",
  "require": {
    "php": "^7.2",
    "amphp/parallel": "1.1.1",
    "amphp/websocket-client": "dev-master"
  }
}

composer show reports the following library versions:

amphp/amp              v2.1.1             A non-blocking concurrency framework for PHP applications.
amphp/byte-stream      v1.5.1             A stream abstraction to make working with non-blocking I/O simple.
amphp/cache            v1.2.0             A promise-aware caching API for Amp.
amphp/dns              v0.9.13            Async DNS resolution for Amp.
amphp/file             v0.3.3             Allows non-blocking access to the filesystem for Amp.
amphp/http             v1.0.1             Basic HTTP primitives which can be shared by servers and clients.
amphp/parallel         v1.1.1             Parallel processing component for Amp.
amphp/parser           v1.0.0             A generator parser to make streaming parsers simple.
amphp/process          v1.0.3             Asynchronous process manager.
amphp/socket           v0.10.11           Async socket connection / server tools for Amp.
amphp/sync             v1.0.1             Mutex, Semaphore, and other synchronization tools for Amp.
amphp/uri              v0.1.3             Uri Parser and Resolver.
amphp/websocket        dev-master b7f48ad Shared code for websocket servers and clients.
amphp/websocket-client dev-master 9ebf93e Async WebSocket client for PHP based on Amp.
amphp/windows-registry v0.3.2             Windows Registry Reader.
daverandom/libdns      2.x-dev 1ecd825    DNS protocol implementation written in pure PHP
league/uri-interfaces  dev-master 081760c Common interface for URI representation
league/uri-parser      dev-master 6715484 userland URI parser RFC 3986 compliant
league/uri-schemes     dev-master f821a44 URI manipulation library
psr/http-message       dev-master f6561bf Common interface for HTTP messages

@remorhaz Based on your program's output, I'm guessing the problem is actually not in this library, but either in amphp/parallel or in pthreads.

Could you add an echo "Out of Internal Thread Loop\n" after this line within the Thread instance that is being run. The call to Loop::run() in your example should be exiting the same loop, so it shouldn't matter, but I'm curious.

Also note that you do not need to call Loop::run() within the callback given to Thread::run(). The callback will be run as a coroutine within a running event loop. That should not be a problem, but you could try removing that too.

After adding the line you've asked the script outputs the following (and still hangs):

Pthreads version: 3.2.1dev
Program started
Loop started
Thread started
Thread loop started
Tick from thread: 0
Tick from thread: 1
Ticker stopped
Connection is open: YES
Connection is open: NO
Out of thread loop
Out of Internal Thread Loop
Thread joined
Out of loop

Then I've inlined the code from the nested loop (and increased a delay up to 3 seconds to watch if the ticker really ticks) and got the following output (and the script still hangs):

Pthreads version: 3.2.1dev
Program started
Loop started
Thread started
Thread loop started
Tick from thread: 0
Tick from thread: 1
Tick from thread: 2
Ticker stopped
Connection is open: YES
Connection is open: NO
Out of thread loop
Thread joined
Out of loop

It's interesting that without nested callback "Out of Internal Thread Loop" is not printed. Another wierd thing is that I'm always getting one tick more with nested callback - I don't understand why. But anyway, the script hangs.

I've tried to look what's really happening with process threads when my script runs. I've set up delay to 30 and discovered the following:

  1. This is what we see before the thread starts: the process has just a single thread, everything is fine.
root@f865272b6acc:/app# ps -T -p 1
  PID  SPID TTY          TIME CMD
    1     1 pts/0    00:00:00 php
  1. The thread just has started: now the process has two threads, and this is fine, too.
root@f865272b6acc:/app# ps -T -p 1
  PID  SPID TTY          TIME CMD
    1     1 pts/0    00:00:00 php
    1    19 pts/0    00:00:00 php
  1. This occurs when I see the ticks in the output: now we have three threads. I don't really understand the origin of the third thread.
root@f865272b6acc:/app# ps -T -p 1
  PID  SPID TTY          TIME CMD
    1     1 pts/0    00:00:00 php
    1    19 pts/0    00:00:00 php
    1    21 pts/0    00:00:00 php

When the script ends(and hangs) the picture is the same as on 3), so joining the threads didn't really occur on join() call.
When I comment out all the work with $connection, the picture is the following:

  1. The thread hasn't started yet:
root@8a506f92be7b:/app# ps -T -p 1
  PID  SPID TTY          TIME CMD
    1     1 pts/0    00:00:00 php
  1. The thread has started, and we see it:
root@8a506f92be7b:/app# ps -T -p 1
  PID  SPID TTY          TIME CMD
    1     1 pts/0    00:00:00 php
    1    18 pts/0    00:00:00 php
  1. The thread has joined: the second thread disappeared.
root@8a506f92be7b:/app# ps -T -p 1
  PID  SPID TTY          TIME CMD
    1     1 pts/0    00:00:00 php

And the script doesn't hang.
So we have two unanswered questions:

  • Why connection to WS creates extra thread?
  • Why join() leaves both threads alive and reports success?

Looks like another thread is being started to do the file access to read the hosts file so amphp/dns can lookup local.websocket. If you have some time to debug, I'd be interested in knowing why this thread isn't being stopped automatically by this code. It probably has something to do with the fact that the worker is starting a thread from within another thread. You can try adding define('AMP_WORKER', 'amp-worker') to the top of the function provided to Thread::run(). It will force the blocking (sync) file driver to be used within the thread. If that helps, I may consider using the blocking file driver by default within thread contexts.

We recently removed the async file access in amphp/dns for reading the host file. I just tagged v0.9.14. Note that you can also install either ext-eio or ext-uv to have thread-based, async file reading with better performance than that provided by amphp/parallel file driver.

Well, defining 'AMP_WORKER' solves the problem, so you were right and the problem is in async file driver.

I've added a bit more debugging information (file/line applied) and got wierd results.

Experiment 1: WITH nested Loop::run()

Pthreads version: 3.2.1dev
Program started
Loop started
Internal thread constructed (/app/vendor/amphp/parallel/lib/Context/Internal/Thread.php:44)
#0  Amp\Parallel\Context\Internal\Thread->__construct() called at [/app/vendor/amphp/parallel/lib/Context/Thread.php:160]
#1  Amp\Parallel\Context\Thread->start() called at [/app/vendor/amphp/parallel/lib/Context/Thread.php:70]
#2  Amp\Parallel\Context\Thread::Amp\Parallel\Context\{closure}()
#3  Generator->current() called at [/app/vendor/amphp/amp/lib/Coroutine.php:41]
#4  Amp\Coroutine->__construct() called at [/app/vendor/amphp/amp/lib/functions.php:66]
#5  Amp\call() called at [/app/vendor/amphp/parallel/lib/Context/Thread.php:72]
#6  Amp\Parallel\Context\Thread::run() called at [/app/src/demo.php:56]
#7  {closure}()
#8  Generator->current() called at [/app/vendor/amphp/amp/lib/Coroutine.php:41]
#9  Amp\Coroutine->__construct() called at [/app/vendor/amphp/amp/lib/Loop/Driver.php:123]
#10 Amp\Loop\Driver->tick() called at [/app/vendor/amphp/amp/lib/Loop/Driver.php:72]
#11 Amp\Loop\Driver->run() called at [/app/vendor/amphp/amp/lib/Loop.php:84]
#12 Amp\Loop::run() called at [/app/src/demo.php:64]
Internal thread running: 139956973586176 (/app/vendor/amphp/parallel/lib/Context/Internal/Thread.php:86)
Thread started

Okay, first thread *176 is the one constructed directly in our example.

Thread loop started
Worker constructed: Amp\Parallel\Context\Thread (/app/vendor/amphp/parallel/lib/Worker/TaskWorker.php:42)
Internal thread constructed (/app/vendor/amphp/parallel/lib/Context/Internal/Thread.php:44)
#0  Amp\Parallel\Context\Internal\Thread->__construct() called at [/app/vendor/amphp/parallel/lib/Context/Thread.php:160]
#1  Amp\Parallel\Context\Thread->start() called at [/app/vendor/amphp/parallel/lib/Worker/TaskWorker.php:105]
#2  Amp\Parallel\Worker\TaskWorker->Amp\Parallel\Worker\{closure}()
#3  Generator->current() called at [/app/vendor/amphp/amp/lib/Coroutine.php:41]
#4  Amp\Coroutine->__construct() called at [/app/vendor/amphp/amp/lib/functions.php:66]
#5  Amp\call() called at [/app/vendor/amphp/parallel/lib/Worker/TaskWorker.php:129]
#6  Amp\Parallel\Worker\TaskWorker->enqueue() called at [/app/vendor/amphp/parallel/lib/Worker/DefaultPool.php:152]
#7  Amp\Parallel\Worker\DefaultPool->enqueue() called at [/app/vendor/amphp/file/lib/ParallelDriver.php:50]
#8  Amp\File\ParallelDriver->runFileTask()
#9  Generator->current() called at [/app/vendor/amphp/amp/lib/Coroutine.php:41]
#10 Amp\Coroutine->__construct() called at [/app/vendor/amphp/file/lib/ParallelDriver.php:286]
#11 Amp\File\ParallelDriver->get() called at [/app/vendor/amphp/file/lib/functions.php:339]
#12 Amp\File\get() called at [/app/vendor/amphp/dns/lib/UnixConfigLoader.php:27]
#13 Amp\Dns\UnixConfigLoader->Amp\Dns\{closure}()
#14 Generator->current() called at [/app/vendor/amphp/amp/lib/Coroutine.php:41]
#15 Amp\Coroutine->__construct() called at [/app/vendor/amphp/amp/lib/functions.php:66]
#16 Amp\call() called at [/app/vendor/amphp/dns/lib/UnixConfigLoader.php:78]
#17 Amp\Dns\UnixConfigLoader->loadConfig() called at [/app/vendor/amphp/dns/lib/BasicResolver.php:329]
#18 Amp\Dns\BasicResolver->Amp\Dns\{closure}()
#19 Generator->current() called at [/app/vendor/amphp/amp/lib/Coroutine.php:41]
#20 Amp\Coroutine->__construct() called at [/app/vendor/amphp/amp/lib/functions.php:66]
#21 Amp\call() called at [/app/vendor/amphp/dns/lib/BasicResolver.php:330]
#22 Amp\Dns\BasicResolver->reloadConfig() called at [/app/vendor/amphp/dns/lib/BasicResolver.php:89]
#23 Amp\Dns\BasicResolver->Amp\Dns\{closure}()
#24 Generator->current() called at [/app/vendor/amphp/amp/lib/Coroutine.php:41]
#25 Amp\Coroutine->__construct() called at [/app/vendor/amphp/amp/lib/functions.php:66]
#26 Amp\call() called at [/app/vendor/amphp/dns/lib/BasicResolver.php:166]
#27 Amp\Dns\BasicResolver->resolve() called at [/app/vendor/amphp/dns/lib/functions.php:46]
#28 Amp\Dns\resolve() called at [/app/vendor/amphp/socket/src/functions.php:90]
#29 Amp\Socket\{closure}()
#30 Generator->current() called at [/app/vendor/amphp/amp/lib/Coroutine.php:41]
#31 Amp\Coroutine->__construct() called at [/app/vendor/amphp/amp/lib/functions.php:66]
#32 Amp\call() called at [/app/vendor/amphp/socket/src/functions.php:179]
#33 Amp\Socket\connect() called at [/app/vendor/amphp/websocket-client/src/Rfc6455Connector.php:31]
#34 Amp\Websocket\Client\Rfc6455Connector->Amp\Websocket\Client\{closure}()
#35 Generator->current() called at [/app/vendor/amphp/amp/lib/Coroutine.php:41]
#36 Amp\Coroutine->__construct() called at [/app/vendor/amphp/amp/lib/functions.php:66]
#37 Amp\call() called at [/app/vendor/amphp/websocket-client/src/Rfc6455Connector.php:58]
#38 Amp\Websocket\Client\Rfc6455Connector->connect() called at [/app/vendor/amphp/websocket-client/src/functions.php:52]
#39 Amp\Websocket\Client\connect() called at [/app/src/demo.php:41]
#40 Amp\Parallel\Context\Internal\Thread::{closure}()
#41 Generator->current() called at [/app/vendor/amphp/amp/lib/Coroutine.php:41]
#42 Amp\Coroutine->__construct() called at [/app/vendor/amphp/amp/lib/Loop/Driver.php:123]
#43 Amp\Loop\Driver->tick() called at [/app/vendor/amphp/amp/lib/Loop/Driver.php:72]
#44 Amp\Loop\Driver->run() called at [/app/vendor/amphp/amp/lib/Loop.php:84]
#45 Amp\Loop::run() called at [/app/src/demo.php:52]
#46 Amp\Parallel\Context\Internal\Thread::{closure}() called at [/app/vendor/amphp/amp/lib/functions.php:60]
#47 Amp\call() called at [/app/vendor/amphp/parallel/lib/Context/Internal/Thread.php:129]
#48 Amp\Parallel\Context\Internal\Thread->execute() called at [/app/vendor/amphp/parallel/lib/Context/Internal/Thread.php:102]
#49 Amp\Parallel\Context\Internal\Thread->Amp\Parallel\Context\Internal\{closure}()
#50 Generator->current() called at [/app/vendor/amphp/amp/lib/Coroutine.php:41]
#51 Amp\Coroutine->__construct() called at [/app/vendor/amphp/amp/lib/Loop/Driver.php:123]
#52 Amp\Loop\Driver->tick() called at [/app/vendor/amphp/amp/lib/Loop/Driver.php:72]
#53 Amp\Loop\Driver->run() called at [/app/vendor/amphp/amp/lib/Loop.php:84]
#54 Amp\Loop::run() called at [/app/vendor/amphp/parallel/lib/Context/Internal/Thread.php:108]
#55 Amp\Parallel\Context\Internal\Thread->run()
Tick from thread: 0
Internal thread running: 139956959835904 (/app/vendor/amphp/parallel/lib/Context/Internal/Thread.php:86)

The second thread *904 is indeed constructed by DNS resolver, it is constructed before first tick (because of using Loop::run()) and is run after first tick. Okay.

Tick from thread: 1
Tick from thread: 2
Tick from thread: 3
Tick from thread: 4
Joining thread: 139956973586176 (/app/vendor/amphp/parallel/lib/Context/Thread.php:229)
Ticker stopped
Connection is open: YES
Connection is open: NO
Out of thread loop
Worker shutdown started: Amp\Parallel\Context\Thread (/app/vendor/amphp/parallel/lib/Worker/TaskWorker.php:44)
Joining thread: 139956959835904 (/app/vendor/amphp/parallel/lib/Context/Thread.php:229)
Thread joined: 139956959835904 (/app/vendor/amphp/parallel/lib/Context/Thread.php:251)
Thread joined: 139956973586176 (/app/vendor/amphp/parallel/lib/Context/Thread.php:251)
Thread joined
Out of loop

We've send 'stop' string to first thread and then call join() for the first thread, then shutdown callback is triggered that "joins" second thread (in fact it stays alive) and then it "joins" first thread (in fact it also stays alive).
So, everything looks fine except for physical threads left alive for some hidden reason.

Experiment 2: WITHOUT nested Loop::run() (just commented it out)

Pthreads version: 3.2.1dev
Program started
Loop started
Internal thread constructed (/app/vendor/amphp/parallel/lib/Context/Internal/Thread.php:44)
#0  Amp\Parallel\Context\Internal\Thread->__construct() called at [/app/vendor/amphp/parallel/lib/Context/Thread.php:160]
#1  Amp\Parallel\Context\Thread->start() called at [/app/vendor/amphp/parallel/lib/Context/Thread.php:70]
#2  Amp\Parallel\Context\Thread::Amp\Parallel\Context\{closure}()
#3  Generator->current() called at [/app/vendor/amphp/amp/lib/Coroutine.php:41]
#4  Amp\Coroutine->__construct() called at [/app/vendor/amphp/amp/lib/functions.php:66]
#5  Amp\call() called at [/app/vendor/amphp/parallel/lib/Context/Thread.php:72]
#6  Amp\Parallel\Context\Thread::run() called at [/app/src/demo.php:56]
#7  {closure}()
#8  Generator->current() called at [/app/vendor/amphp/amp/lib/Coroutine.php:41]
#9  Amp\Coroutine->__construct() called at [/app/vendor/amphp/amp/lib/Loop/Driver.php:123]
#10 Amp\Loop\Driver->tick() called at [/app/vendor/amphp/amp/lib/Loop/Driver.php:72]
#11 Amp\Loop\Driver->run() called at [/app/vendor/amphp/amp/lib/Loop.php:84]
#12 Amp\Loop::run() called at [/app/src/demo.php:64]
Internal thread running: 140093791782656 (/app/vendor/amphp/parallel/lib/Context/Internal/Thread.php:86)
Thread started

Same picture as in first experiment.

Thread loop started
Worker constructed: Amp\Parallel\Context\Thread (/app/vendor/amphp/parallel/lib/Worker/TaskWorker.php:42)
Internal thread constructed (/app/vendor/amphp/parallel/lib/Context/Internal/Thread.php:44)
#0  Amp\Parallel\Context\Internal\Thread->__construct() called at [/app/vendor/amphp/parallel/lib/Context/Thread.php:160]
#1  Amp\Parallel\Context\Thread->start() called at [/app/vendor/amphp/parallel/lib/Worker/TaskWorker.php:105]
#2  Amp\Parallel\Worker\TaskWorker->Amp\Parallel\Worker\{closure}()
#3  Generator->current() called at [/app/vendor/amphp/amp/lib/Coroutine.php:41]
#4  Amp\Coroutine->__construct() called at [/app/vendor/amphp/amp/lib/functions.php:66]
#5  Amp\call() called at [/app/vendor/amphp/parallel/lib/Worker/TaskWorker.php:129]
#6  Amp\Parallel\Worker\TaskWorker->enqueue() called at [/app/vendor/amphp/parallel/lib/Worker/DefaultPool.php:152]
#7  Amp\Parallel\Worker\DefaultPool->enqueue() called at [/app/vendor/amphp/file/lib/ParallelDriver.php:50]
#8  Amp\File\ParallelDriver->runFileTask()
#9  Generator->current() called at [/app/vendor/amphp/amp/lib/Coroutine.php:41]
#10 Amp\Coroutine->__construct() called at [/app/vendor/amphp/file/lib/ParallelDriver.php:286]
#11 Amp\File\ParallelDriver->get() called at [/app/vendor/amphp/file/lib/functions.php:339]
#12 Amp\File\get() called at [/app/vendor/amphp/dns/lib/UnixConfigLoader.php:27]
#13 Amp\Dns\UnixConfigLoader->Amp\Dns\{closure}()
#14 Generator->current() called at [/app/vendor/amphp/amp/lib/Coroutine.php:41]
#15 Amp\Coroutine->__construct() called at [/app/vendor/amphp/amp/lib/functions.php:66]
#16 Amp\call() called at [/app/vendor/amphp/dns/lib/UnixConfigLoader.php:78]
#17 Amp\Dns\UnixConfigLoader->loadConfig() called at [/app/vendor/amphp/dns/lib/BasicResolver.php:329]
#18 Amp\Dns\BasicResolver->Amp\Dns\{closure}()
#19 Generator->current() called at [/app/vendor/amphp/amp/lib/Coroutine.php:41]
#20 Amp\Coroutine->__construct() called at [/app/vendor/amphp/amp/lib/functions.php:66]
#21 Amp\call() called at [/app/vendor/amphp/dns/lib/BasicResolver.php:330]
#22 Amp\Dns\BasicResolver->reloadConfig() called at [/app/vendor/amphp/dns/lib/BasicResolver.php:89]
#23 Amp\Dns\BasicResolver->Amp\Dns\{closure}()
#24 Generator->current() called at [/app/vendor/amphp/amp/lib/Coroutine.php:41]
#25 Amp\Coroutine->__construct() called at [/app/vendor/amphp/amp/lib/functions.php:66]
#26 Amp\call() called at [/app/vendor/amphp/dns/lib/BasicResolver.php:166]
#27 Amp\Dns\BasicResolver->resolve() called at [/app/vendor/amphp/dns/lib/functions.php:46]
#28 Amp\Dns\resolve() called at [/app/vendor/amphp/socket/src/functions.php:90]
#29 Amp\Socket\{closure}()
#30 Generator->current() called at [/app/vendor/amphp/amp/lib/Coroutine.php:41]
#31 Amp\Coroutine->__construct() called at [/app/vendor/amphp/amp/lib/functions.php:66]
#32 Amp\call() called at [/app/vendor/amphp/socket/src/functions.php:179]
#33 Amp\Socket\connect() called at [/app/vendor/amphp/websocket-client/src/Rfc6455Connector.php:31]
#34 Amp\Websocket\Client\Rfc6455Connector->Amp\Websocket\Client\{closure}()
#35 Generator->current() called at [/app/vendor/amphp/amp/lib/Coroutine.php:41]
#36 Amp\Coroutine->__construct() called at [/app/vendor/amphp/amp/lib/functions.php:66]
#37 Amp\call() called at [/app/vendor/amphp/websocket-client/src/Rfc6455Connector.php:58]
#38 Amp\Websocket\Client\Rfc6455Connector->connect() called at [/app/vendor/amphp/websocket-client/src/functions.php:52]
#39 Amp\Websocket\Client\connect() called at [/app/src/demo.php:41]
#40 Amp\Parallel\Context\Internal\Thread::{closure}()
#41 Generator->current() called at [/app/vendor/amphp/amp/lib/Coroutine.php:41]
#42 Amp\Coroutine->__construct() called at [/app/vendor/amphp/amp/lib/functions.php:66]
#43 Amp\call() called at [/app/vendor/amphp/parallel/lib/Context/Internal/Thread.php:129]
#44 Amp\Parallel\Context\Internal\Thread->execute() called at [/app/vendor/amphp/parallel/lib/Context/Internal/Thread.php:102]
#45 Amp\Parallel\Context\Internal\Thread->Amp\Parallel\Context\Internal\{closure}()
#46 Generator->current() called at [/app/vendor/amphp/amp/lib/Coroutine.php:41]
#47 Amp\Coroutine->__construct() called at [/app/vendor/amphp/amp/lib/Loop/Driver.php:123]
#48 Amp\Loop\Driver->tick() called at [/app/vendor/amphp/amp/lib/Loop/Driver.php:72]
#49 Amp\Loop\Driver->run() called at [/app/vendor/amphp/amp/lib/Loop.php:84]
#50 Amp\Loop::run() called at [/app/vendor/amphp/parallel/lib/Context/Internal/Thread.php:108]
#51 Amp\Parallel\Context\Internal\Thread->run()
Internal thread running: 140093708826368 (/app/vendor/amphp/parallel/lib/Context/Internal/Thread.php:86)
Tick from thread: 0

Okay, we don't use nested loop and thus we're getting here before first tick.

Tick from thread: 1
Tick from thread: 2
Tick from thread: 3
Joining thread: 140093791782656 (/app/vendor/amphp/parallel/lib/Context/Thread.php:229)
Ticker stopped
Connection is open: YES
Connection is open: NO
Out of thread loop
Thread joined: 140093791782656 (/app/vendor/amphp/parallel/lib/Context/Thread.php:251)
Thread joined
Out of loop

Shutdown callback is not triggered at all (and thus no even attempt to join the DNS thread *368), but why? What changed but executing the nested loop callback before the first tick?

I tried looking into this some more, but I'm not really sure what is going on here. I'm not terribly motivated to investigate much further as ext-thread is no longer going to be updated, so I will probably deprecate everything having to do with the extension in upcoming releases of amphp/sync and amphp/parallel. I would recommend trying out ext-parallel, which is now supported by amphp/parallel v1.2.