I'm experimenting with creating a reusable generic cli server, that I can control (start/pause/resume/stop) from a terminal session.
My approach, so far, is that I have one script independently acting as both the console (parent loop) and the server (child loop), not by pcntl_fork()
-ing, but by proc_open()
-ing itself as the child process, so to speak.
The console loop then acts on the server loop by signaling it with posix_kill()
.
Disregarding, for now, whether this is a sensible approach, I stumbled upon something strange — namely, that when the console loop pauses the server loop with the SIGTSTP
signal, the server loop will not respond to the SIGCONT
signal, unless its while
-loop is actually doing something useful.
What could be going on here?
edit:
As per request in the comments, I've simplified my code example. However, as I kind of feared already, this code works just fine.
Perhaps I'm overlooking something in the code with the classes, but I just don't see how both examples differ in their routine — to me it looks like both examples follow the same routine.
And as an important side note: in my more complex example I've tried constantly writing to a file in loop()
, which actually works, even when paused. So, this tells me the loop continues to run properly. The Server simply just doesn't want to respond to signals anymore, after I've paused it.
Anyway, here's the simplified version of my earlier example that I've shown below:
$lockPath = '.lock';
if( file_exists( $lockPath ) ) {
echo 'Process already running; exiting...' . PHP_EOL;
exit( 1 );
}
else if( $argc == 2 && 'child' == $argv[ 1 ] ) {
/* child process */
if( false === ( $lock = fopen( $lockPath, 'x' ) ) ) {
echo 'Unable to acquire lock; exiting...' . PHP_EOL;
exit( 1 );
}
else if( false !== flock( $lock, LOCK_EX ) ) {
echo 'Process started...' . PHP_EOL;
$state = 1;
declare( ticks = 1 );
pcntl_signal( SIGTSTP, function( $signo ) use ( &$state ) {
echo 'pcntl_signal SIGTSTP' . PHP_EOL;
$state = 0;
} );
pcntl_signal( SIGCONT, function( $signo ) use ( &$state ) {
echo 'pcntl_signal SIGCONT' . PHP_EOL;
$state = 1;
} );
pcntl_signal( SIGTERM, function( $signo ) use ( &$state ) {
echo 'pcntl_signal SIGTERM' . PHP_EOL;
$state = -1;
} );
while( $state !== -1 ) {
/**
* It doesn't matter whether I leave the first echo out
* and/or whether I put either echo's in functions,
* Any combination simply works as expected here
*/
echo 'Server state: ' . $state . PHP_EOL;
if( $state !== 0 ) {
echo 'Server tick.' . PHP_EOL;
}
usleep( 1000000 );
}
flock( $lock, LOCK_UN ) && fclose( $lock ) && unlink( $lockPath );
echo 'Process ended; unlocked, closed and deleted lock file; exiting...' . PHP_EOL;
exit( 0 );
}
}
else {
/* parent process */
function consoleRead() {
$fd = STDIN;
$read = array( $fd );
$write = array();
$except = array();
$result = stream_select( $read, $write, $except, 0 );
if( $result === false ) {
throw new RuntimeException( 'stream_select() failed' );
}
if( $result === 0 ) {
return false;
}
return stream_get_line( $fd, 1024, PHP_EOL );
}
$decriptors = array(
0 => STDIN,
1 => STDOUT,
2 => STDERR
);
$childProcess = proc_open( sprintf( 'exec %s child', __FILE__ ), $decriptors, $pipes );
while( 1 ) {
$childStatus = proc_get_status( $childProcess );
$childPid = $childStatus[ 'pid' ];
if( false !== ( $command = consoleRead() ) ) {
switch( $command ) {
case 'status':
var_export( $childStatus );
break;
case 'run':
case 'start':
// nothing?
break;
case 'pause':
case 'suspend':
// SIGTSTP
if( false !== $childPid ) {
posix_kill( $childPid, SIGTSTP );
}
break;
case 'resume':
case 'continue':
// SIGCONT
if( false !== $childPid ) {
posix_kill( $childPid, SIGCONT );
}
break;
case 'halt':
case 'quit':
case 'stop':
// SIGTERM
if( false !== $childPid ) {
posix_kill( $childPid, SIGTERM );
}
break;
}
}
usleep( 1000000 );
}
exit( 0 );
}
When you run either example (above and below) in the console, enter pause<enter>
and then resume<enter>
. The expected behavior is that, after resuming, you'll see (amongst other things) this stream again:
Server tick.
Server tick.
Server tick.
/edit
Here's what I use:
Both the console and server are instances of my abstract LoopedProcess
class:
abstract class LoopedProcess
{
const STOPPED = -1;
const PAUSED = 0;
const RUNNING = 1;
private $state = self::STOPPED;
private $throttle = 50;
final protected function getState() {
return $this->state;
}
final public function isStopped() {
return self::STOPPED === $this->getState();
}
final public function isPaused() {
return self::PAUSED === $this->getState();
}
final public function isRunning() {
return self::RUNNING === $this->getState();
}
protected function onBeforeRun() {}
protected function onRun() {}
final public function run() {
if( $this->isStopped() && false !== $this->onBeforeRun() ) {
$this->state = self::RUNNING;
$this->onRun();
$this->loop();
}
}
protected function onBeforePause() {}
protected function onPause() {}
final public function pause() {
if( $this->isRunning() && false !== $this->onBeforePause() ) {
$this->state = self::PAUSED;
$this->onPause();
}
}
protected function onBeforeResume() {}
protected function onResume() {}
final public function resume() {
if( $this->isPaused() && false !== $this->onBeforeResume() ) {
$this->state = self::RUNNING;
$this->onResume();
}
}
protected function onBeforeStop() {}
protected function onStop() {}
final public function stop() {
if( !$this->isStopped() && false !== $this->onBeforeStop() ) {
$this->state = self::STOPPED;
$this->onStop();
}
}
final protected function setThrottle( $throttle ) {
$this->throttle = (int) $throttle;
}
protected function onLoopStart() {}
protected function onLoopEnd() {}
final private function loop() {
while( !$this->isStopped() ) {
$this->onLoopStart();
if( !$this->isPaused() ) {
$this->tick();
}
$this->onLoopEnd();
usleep( $this->throttle );
}
}
abstract protected function tick();
}
Here's a very rudimentary abstract console class, based on LoopedProcess
:
abstract class Console
extends LoopedProcess
{
public function __construct() {
$this->setThrottle( 1000000 ); // 1 sec
}
public function consoleRead() {
$fd = STDIN;
$read = array( $fd );
$write = array();
$except = array();
$result = stream_select( $read, $write, $except, 0 );
if( $result === false ) {
throw new RuntimeException( 'stream_select() failed' );
}
if( $result === 0 ) {
return false;
}
return stream_get_line( $fd, 1024, PHP_EOL );
}
public function consoleWrite( $data ) {
echo "\r$data\n";
}
}
The following actual server console extends the above abstract console class. Inside ServerConsole::tick()
you'll find that it responds to commands, typed in from the terminal, and sends the signals to the child process (the actual server).
class ServerConsole
extends Console
{
private $childProcess;
private $childProcessId;
public function __construct() {
declare( ticks = 1 );
$self = $this;
pcntl_signal( SIGINT, function( $signo ) use ( $self ) {
$self->consoleWrite( 'Console received SIGINT' );
$self->stop();
} );
parent::__construct();
}
protected function onBeforeRun() {
$decriptors = array( /*
0 => STDIN,
1 => STDOUT,
2 => STDERR
*/ );
$this->childProcess = proc_open( sprintf( 'exec %s child', __FILE__ ), $decriptors, $pipes );
if( !is_resource( $this->childProcess ) ) {
$this->consoleWrite( 'Unable to create child process; exiting...' );
return false;
}
else {
$this->consoleWrite( 'Child process created...' );
}
}
protected function onStop() {
$this->consoleWrite( 'Parent process ended; exiting...' );
$childPid = proc_get_status( $this->childProcess )[ 'pid' ];
if( false !== $childPid ) {
posix_kill( $childPid, SIGTERM );
}
}
protected function tick() {
$childStatus = proc_get_status( $this->childProcess );
$childPid = $childStatus[ 'pid' ];
if( false !== ( $command = $this->consoleRead() ) ) {
var_dump( $childPid, $command );
switch( $command ) {
case 'run':
case 'start':
// nothing, for now
break;
case 'pause':
case 'suspend':
// SIGTSTP
if( false !== $childPid ) {
posix_kill( $childPid, SIGTSTP );
}
break;
case 'resume':
case 'continue':
// SIGCONT
if( false !== $childPid ) {
posix_kill( $childPid, SIGCONT );
}
break;
case 'halt':
case 'quit':
case 'stop':
// SIGTERM
if( false !== $childPid ) {
posix_kill( $childPid, SIGTERM );
}
break;
}
}
}
}
And here is the server implementation. This is where the strange behavior occurs. If don't override the LoopedProcess::onLoopStart()
hook, it will not respond to signals anymore, once it has paused. So, if I remove the hook, LoopedProcess::loop()
effectively is doing nothing of importance anymore.
class Server
extends LoopedProcess
{
public function __construct() {
declare( ticks = 1 );
$self = $this;
// install the signal handlers
pcntl_signal( SIGTSTP, function( $signo ) use ( $self ) {
echo 'pcntl_signal SIGTSTP' . PHP_EOL;
$self->pause();
} );
pcntl_signal( SIGCONT, function( $signo ) use ( $self ) {
echo 'pcntl_signal SIGCONT' . PHP_EOL;
$self->resume();
} );
pcntl_signal( SIGTERM, function( $signo ) use ( $self ) {
echo 'pcntl_signal SIGTERM' . PHP_EOL;
$self->stop();
} );
$this->setThrottle( 2000000 ); // 2 sec
}
protected function tick() {
echo 'Server tick.' . PHP_EOL;
}
protected function onBeforePause() {
echo 'Server pausing.' . PHP_EOL;
}
protected function onPause() {
echo 'Server paused.' . PHP_EOL;
}
protected function onBeforeResume() {
echo 'Server resuming.' . PHP_EOL;
}
protected function onResume() {
echo 'Server resumed.' . PHP_EOL;
}
/**
* if I remove this hook, Server becomes unresponsive
* to signals, after it has been paused
*/
protected function onLoopStart() {
echo 'Server state: ' . ( $this->getState() ) . PHP_EOL;
}
}
And here's the script that ties it all together:
$lockPath = '.lock';
if( file_exists( $lockPath ) ) {
echo 'Process already running; exiting...' . PHP_EOL;
exit( 1 );
}
else if( $argc == 2 && 'child' == $argv[ 1 ] ) {
/* child process */
if( false === ( $lock = fopen( $lockPath, 'x' ) ) ) {
echo 'Unable to acquire lock; exiting...' . PHP_EOL;
exit( 1 );
}
else if( false !== flock( $lock, LOCK_EX ) ) {
echo 'Process started...' . PHP_EOL;
$server = new Server();
$server->run();
flock( $lock, LOCK_UN ) && fclose( $lock ) && unlink( $lockPath );
echo 'Process ended; unlocked, closed and deleted lock file; exiting...' . PHP_EOL;
exit( 0 );
}
}
else {
/* parent process */
$console = new ServerConsole();
$console->run();
exit( 0 );
}
So, to summarize:
When Server
is paused and is effectively doing nothing of importance inside loop()
because I have no hook implemented that outputs anything, it becomes unresponsive to new signals. However, when a hook is implemented, it responds to the signals as expected.
What could be going on here?
I've gotten it to work by adding a call to pcntl_signal_dispatch()
inside loop()
, as per this comment1 on PHP's documentation website, like so:
final private function loop() {
while( !$this->isStopped() ) {
$this->onLoopStart();
if( !$this->isPaused() ) {
$this->tick();
}
$this->onLoopEnd();
pcntl_signal_dispatch(); // adding this worked
// (I actually need to put it in onLoopEnd() though, this was just a temporary hack)
usleep( $this->throttle );
}
}
My simplified example script does not need this, however. So I'd still be interested to know on what occasions it is necessary to call pcntl_signal_dispatch()
and the reason behind it, if someone has any insights in that.
1) The comment is currently hidden behind the site's header, so you might need to scroll up a bit.
If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With