5

I'm working on a pre-forking TCP socket server written in PHP.

The daemon (the parent process), forks some number of children and then waits until it's told to exit and the children are all gone or it receives a signal.

SIGINT and SIGTERM cause it to send SIGTERM to all of the children.

The children set up their own signal handlers: SIGTERM causes a clean exit. SIGUSR1 causes it to dump some status information (just print that it received the signal in the sample code below).

If a child exits unexpectedly, the parent starts a new child unless the exiting flag has been set by the SIGINT handler.

The initial children, forked during daemon initialization, react to signals as expected.

Newly forked children to replace an unexpected child exit, do not respond to signals.
The following code can be used to demonstrate this:

<?php
$children = [];
$exiting = false;

pcntl_async_signals( true );
pcntl_signal( SIGCHLD, 'sigchldHandler' );
pcntl_signal( SIGINT, 'sigintHandler' );
pcntl_signal( SIGTERM, 'sigintHandler' );

// Fork our children.
for( $ii = 0; $ii < 1; $ii++ )
{
  startChild();
}

// Forks a single child.
function startChild()
{
  global $children;

  echo "Parent: starting child\n";
  $pid = pcntl_fork();
  switch( true )
  {
    case ( $pid > 0 ):
      $children[$pid] = $pid;
      break;

    case ( $pid === 0 ):
      child();
      exit( 0 );

    default:
      die( 'Parent: pcntl_fork() failed' );
      break;
  }
}

// As long as we have any children...
while( true )
{
  if( empty( $children ) ) break;
  sleep( 1 );
}

// The child process.
function child()
{
  $pid = posix_getpid();
  echo "Child $pid: started\n";
  sleep( 10 ); // Give us a chance to start strace (4/30/19 08:27)

  pcntl_sigprocmask( SIG_SETMASK, [] );           // Make sure nothing is blocked.
  pcntl_async_signals( true );                    // This may be inherited.
  pcntl_signal( SIGINT, SIG_IGN );                // Ignore SIGINT.
  pcntl_signal( SIGTERM, function() use ( $pid )  // Exit on SIGTERM.
  {
    echo "Child $pid: received SIGTERM\n";
    exit( 0 );
  }, false );
  pcntl_signal( SIGUSR1, function() use( $pid )   // Acknowledge SIGUSR1.
  {
    printf( "Child %d: Received SIGUSR1\n", $pid );
  });

  // Do "work" here.
  while( true )
  {
    sleep( 60 );
  }
}

// Handle SIGCHLD in the parent.
// Start a new child unless we're exiting.
function sigchldHandler()
{
  global $children, $exiting;

  echo "Parent: received SIGCHLD\n";
  while( true )
  {
    if( ( $pid = pcntl_wait( $status, WNOHANG ) ) < 1 )
    {
      break;
    }
    echo "Parent: child $pid exited\n";
    unset( $children[$pid] );
    if( !$exiting )
    {
      startChild();
    }
  }
}

// Handle SIGINT in the parent.
// Set exiting to true and send SIGTERM to all children.
function sigintHandler()
{
  global $children, $exiting;

  $exiting = true;
  echo PHP_EOL;
  foreach( $children as $pid )
  {
    echo "Parent: sending SIGTERM to $pid\n";
    posix_kill( $pid, SIGTERM );
  }
}

Run this script in a terminal session. The initial output will be similar to this with a different PID:

Parent: starting child
Child 65016: started

From a different terminal session issue a kill command:

# kill -USR1 65016

The child process will display this in the first terminal session:

Child 65016: Received SIGUSR1

The child is receiving and processing signals as expected. Now terminate that first child:

# kill -TERM 65016 The output to the first terminal session will look like this (with different PIDS):

Child 65016: received SIGTERM
Parent: received SIGCHLD
Parent: child 65016 exited
Parent: starting child
Child 65039: started

The new child process will receive but react to any signals at this point except SIGKILL and SIGSTOP which can't be caught.

Sending the parent a SIGINT will cause it to send a SIGTERM to the new child. The child won't get it and parent will wait until the child is forcibly killed before exiting (yes, the production code will include a timeout and SIGKILL any remaining children).

Environment:
- Ubuntu 18.04.2
- macOS Mojave 10.14.3 (same behavior)
- PHP 7.2.17 (cli)

I find myself out of ideas. Thoughts?

EDIT 30-Apr-2019 08:27 PDT:
I have a little more information. I added a sleep( 10 ) right after the 'echo "Child $pid: started\n";' to give me a chance to run strace on the child.

Based on the strace output, it looks like the signals are being delivered, but the child signal handler is not called.

# sudo strace - p 69710
strace: Process 69710 attached
restart_syscall(<... resuming interrupted nanosleep ...>) = 0
rt_sigprocmask( SIG_SETMASK, [], ~[ KILL STOP RTMIN RT_1], 8) = 0
rt_sigaction( SIGINT, {sa_handler = SIG_IGN, sa_mask = [], sa_flags = SA_RESTORER, sa_restorer = 0x7f6e8881cf20}, null, 8) = 0
rt_sigprocmask( SIG_UNBLOCK, [ INT ], null, 8 ) = 0
rt_sigaction( SIGTERM, {sa_handler = 0x55730bdaf2e0, sa_mask = ~[ ILL TRAP ABRT BUS FPE KILL SEGV CONT STOP TSTP TTIN TTOU SYS RTMIN RT_1], sa_flags = SA_RESTORER | SA_INTERRUPT | SA_SIGINFO, sa_restorer = 0x7f6e8881cf20}, null, 8) = 0
rt_sigprocmask( SIG_UNBLOCK, [ TERM ], null, 8 ) = 0
rt_sigaction( SIGUSR1, {sa_handler = 0x55730bdaf2e0, sa_mask = ~[ ILL TRAP ABRT BUS FPE KILL SEGV CONT STOP TSTP TTIN TTOU SYS RTMIN RT_1], sa_flags = SA_RESTORER | SA_RESTART | SA_SIGINFO, sa_restorer = 0x7f6e8881cf20}, null, 8) = 0
rt_sigprocmask( SIG_UNBLOCK, [ USR1 ], null, 8 ) = 0
nanosleep({tv_sec = 60, tv_nsec = 0}, 0x7ffe79859470) = 0
nanosleep({tv_sec = 60, tv_nsec = 0}, {tv_sec = 37, tv_nsec = 840636107}) = ? ERESTART_RESTARTBLOCK( Interrupted by signal)
--- SIGUSR1 {si_signo = SIGUSR1, si_code = SI_USER, si_pid = 69544, si_uid = 1000} ---
rt_sigreturn({mask = []})                 = -1 EINTR( Interrupted system call)
rt_sigprocmask( SIG_BLOCK, ~[ RTMIN RT_1], [], 8) = 0
rt_sigprocmask( SIG_SETMASK, [], null, 8 ) = 0
nanosleep({tv_sec = 60, tv_nsec = 0}, 0x7ffe79859470) = 0
David Patterson
  • 1,430
  • 2
  • 11
  • 35

1 Answers1

2

I believe the problem is PHP signal handling doesn't work as one may intend to when pcntl_fork is called inside of a registered signal handling function. Since the second child process is created inside of sigchldHandler it won't receive process subsequent signals.

Edit: Unfortunately I don't have any references for this. I've been bashing my head against the wall myself with a similar problem as OP (hence the new account!) and I can't find any definitive answers or explanations for this behavior, just the evidence from manual stub tests. I'd love to know as well (:

dongus
  • 36
  • 3
  • Do you have some references to back up this assertion? – miken32 May 02 '19 at 15:49
  • Adding a $childExited flag, setting that in sigchldHandler(), and starting a new child in the parent's loop does seem to solve the problem. I too, though would love to see references explaining why this is the case. Is the a PHP bug or expected behavior (in which case it should probably be considered a documentation bug). Thanks @dongus! – David Patterson May 02 '19 at 17:00