@alex-tartan sent me in the right direction. This post also helped. Just for completeness here's the solution I'm using. Start the SSH tunnel as a background process with a control socket. At shutdown check for the socket and exit the background process. At each unit test setup check for the control socket and skip starting SSH if it's already running.
protected function setUp()
{
...
if (!file_exists('/tmp/tunnel_ctrl_socket')) {
// Redirect to /dev/null or exec will never return
exec("ssh -M -S /tmp/tunnel_ctrl_socket -fnNT -i $key -L 3306:$db:3306 $user@$host &>/dev/null");
$closeTunnel = function($signo = 0) use ($user, $host) {
if (file_exists('/tmp/tunnel_ctrl_socket')) {
exec("ssh -S /tmp/tunnel_ctrl_socket -O exit $user@$host");
}
};
register_shutdown_function($closeTunnel);
// In case we kill the tests early...
pcntl_signal(SIGTERM, $closeTunnel);
}
}
I put this in a class that other tests extend so the tunnel is set up just once and runs until all tests are done or we kill the process.