#!/usr/bin/env bash

# fail hard
set -o pipefail
# fail harder
set -eu
# for ${DOCUMENT_ROOT%%*(/)} pattern further down
shopt -s extglob
# for detecting when -l 'logs/*.log' matches nothing
shopt -s nullglob

if ! type -p "realpath" > /dev/null; then
	# old macOSes don't have realpath
	# readlink is not an option because BSD readlink does not have the GNU -f option
	# must be a function so subshells, including $(…), can use it
	realpath() {
		python3 -c 'import os,sys; print(os.path.realpath(sys.argv[1]))' "$@"
	}
fi

# we very likely got called via a symlink, so we have to realpath $0 first to find the base buildpack directory
bp_dir=$(cd $(dirname $(realpath $0)); cd ..; pwd)

source "${bp_dir}/bin/util/cgroups.sh"

verbose=
conftest=

php_passthrough() {
	local dir=$(dirname "$1")
	local file=$(basename "$1")
	local out=$(basename "$file" .php)
	if [[ "$out" != "$file" ]]; then
		[[ $verbose ]] && echo "Interpreting ${1#$HEROKU_APP_DIR/} to $out" >&2
		out="$dir/$out"
		php "$1" > "$out"
		echo "$out"
	else
		echo "$1"
	fi
}

check_exists() {
	if [[ ! -f "$HEROKU_APP_DIR/$1" ]]; then
		echo "Cannot read -$2 '$1' (relative to '$HEROKU_APP_DIR')" >&2
		exit 1
	else
		echo "$HEROKU_APP_DIR/$1"
	fi
}

touch_log() {
	mkdir -p $(dirname "$1") && touch "$1"
}

findconfig() {
	local dir=$(dirname "$2")
	local file=$(basename "$2")
	IFS='.' read -r -a version <<< "$1" # read the parts of $1 (version number) into an array, e.g. (7 2 33)

	# iterate "backwards" over version parts so we try "7/2/11" first, then "7/2", then "7" for a version "7.2.11"
	# we go down to 0, which will yield an empty string, for the last fallback layer without any version number
	for (( i = ${#version[@]}; i >= 0; i--)); do
		version_dir=$(IFS=/; echo "${version[*]:0:$i}") # set IFS to "/" for merging, but echo is a builtin, so it must be a subshell and a separate command
		full_path="${dir}/${version_dir}${version_dir:+/}${file}" # concat, but if scandir is empty (last fallback), don't produce a double slash
		if [[ -f "$full_path" ]]; then
			echo "$full_path";
			return 0;
		fi
	done
	echo "$2"
	return 1;
}

wait_pidfile() {
	i=0
	while ! test -f "$1" && (( i < 25 )); do
		[[ $verbose && ${2:-} ]] && echo "$2" >&2
		sleep 0.1
		((++i)) # don't do i++, that is a post increment operation, and will return status 1, since the expression evaluates to 0...
	done
	if (( i == 25 )); then
		# we timed out
		return 1;
	fi
}

wait_pid_and_pidfile() {
	i=0
	while ! test -f "$2" && (( i < 25 )); do
		if ! kill -0 "$1" 2> /dev/null; then # kill -0 checks if process exists
			break;
		fi
		[[ $verbose && ${3:-} ]] && echo "$3" >&2
		sleep 0.1
		((++i)) # don't do i++, that is a post increment operation, and will return status 1, since the expression evaluates to 0...
	done
	if (( i == 25 )); then
		# we timed out
		return 1;
	fi
}

print_help() {
	cat >&2 <<-EOF
		
		${1:-Boots PHP-FPM together with Nginx on Heroku and for local development.}
		
		Usage:
		  heroku-php-nginx [options] [<DOCUMENT_ROOT>]
		
		Options:
		  -C <nginx.inc.conf>     The path to the configuration file to include inside
		                          the Nginx server config (see option -c below). Will
		                          be included inside the 'server { ... }' block just
		                          after the 'listen', 'root' etc directives.
		                          Recommended approach when customizing Nginx's config
		                          in most cases, unless you need to set http or
		                          fundamental server level options.
		                          [default: <BPDIR>/conf/nginx/default_include.conf.php,
		                          or a more version-specific file from a subdirectory]
		  -c <nginx.conf>         The path to the full configuration file that is
		                          included after Heroku's main Nginx config has been
		                          loaded. It must contain an 'http { ... }' block with a
		                          'server { ... }' inside that contains 'listen' and
		                          'root' (see option -C above) directives.
		                          [default: <BPDIR>/conf/nginx/heroku.conf.php,
		                          or a more version-specific file from a subdirectory]
		  -F <php-fpm.inc.conf>   The path to the configuration file to include at the  
		                          end of php-fpm.conf (see option -f below), in the
		                          '[www]' pool section. Recommended approach when
		                          customizing PHP-FPM's configuration in most cases,
		                          unless you need to set global options.
		  -f <php-fpm.conf>       The path to the full PHP-FPM configuration file.
		                          [default: <BPDIR>/conf/php/php-fpm.conf,
		                          or a more version-specific file from a subdirectory]
		  -h, --help              Display this help screen and exit.
		  -i <php.ini>            The path to the php.ini file to use.
		                          [default: <BPDIR>/conf/php/php.ini]
		  -l <tailme.log>         Path to additional log file to tail to STDERR so its
		                          contents appear in 'heroku logs'. If the file does not
		                          exist, it will be created. Wildcards are allowed, but
		                          must be quoted and must match already existing files.
		                          Note: this option can be repeated multiple times.
		  -p <PORT>               Port to listen on for HTTP traffic. If this argument
		                          is not given, then the port number to use is read from
		                          the \$PORT environment variable, or a random port is
		                          chosen if that variable does not exist.
		  -t, --test              Test PHP-FPM and Nginx configuration. When repeated,
		                          will dump PHP-FPM config (-tt), Nginx config (-ttt),
		                          or both (-tttt).
		  -v, --verbose           Be more verbose during startup.
		
		The <BPDIR> placeholder above represents the base directory of this buildpack:
		$bp_dir
		
		All file paths must be relative to '$HEROKU_APP_DIR'.
		
		Any file name that ends in '.php' will be run through the PHP interpreter first.
		You may use this for templating; this is, for instance, necessary for Nginx,
		where environment variables cannot be referenced in configuration files.
		
		If you would like to use the -C and -c or -F and -f options together, make sure
		you retain the appropriate include mechanisms (see default configs for details).
	EOF
}

# we need this in configs
export HEROKU_APP_DIR=$(pwd)
export DOCUMENT_ROOT="$HEROKU_APP_DIR"
# set a default port if none is given
export PORT=${PORT:-$(( $RANDOM+1024 ))}

# add conf/php/apm-nostart-overrides/ to end of list of PHP INI scan dirs
# that way our special extra INI which prevents startup of a possible newrelic etc is loaded for all the PHP and Composer calls that follow
# an empty value for PHP_INI_SCAN_DIR means nothing is scanned at all, not even compile time default scan dirs
# the variable can hold a list of paths delimited by colon, and if one segment is empty (e.g. "/foo::/bar" or just "/foo:" or ":/foo"), then the compile time default scan dir is used there
if [[ -z ${PHP_INI_SCAN_DIR-} && ${PHP_INI_SCAN_DIR+x} ]]; then
	# PHP_INI_SCAN_DIR was defined, but empty
	# this means the user wants no scan dir, so we have to just place our own value into the variable, without a path separator
	_PHP_INI_SCAN_DIR=
	PHP_INI_SCAN_DIR=
elif [[ -z ${PHP_INI_SCAN_DIR-} ]]; then
	# PHP_INI_SCAN_DIR was not defined at all
	# this means we want to load the compile time default scan dir first, then our special INIs
	# we don't back up to _PHP_INI_SCAN_DIR; that way, we will know to unset it later and won't export an empty string
	unset _PHP_INI_SCAN_DIR # just in case it's around
	PHP_INI_SCAN_DIR=":" # leading empty segment will cause compile time default scan dir to be used
else
	# PHP_INI_SCAN_DIR was defined and not empty
	# we want to append our path to the end of the list, or, if not defined at all, have the first list item be blank, which causes the default scan dir to be used
	_PHP_INI_SCAN_DIR=$PHP_INI_SCAN_DIR
	PHP_INI_SCAN_DIR+=":" # leading empty segment will cause compile time default scan dir to be used
fi
# now append
export PHP_INI_SCAN_DIR="${PHP_INI_SCAN_DIR}${bp_dir}/conf/php/apm-nostart-overrides/"

# init logs array here as empty before parsing options; -l could append to it, but the default list gets added later since we use $PORT in there and that can be set using -p
declare -a logs

optstring=":-:C:c:F:f:i:l:p:vth"

# process flags first
while getopts "$optstring" opt; do
	case $opt in
		-)
			case "$OPTARG" in
				verbose)
					verbose=1
					;;
				test)
					let "conftest++" || echo "$(basename "$0"): Config test mode" >&2 # increment, so it can be repeated (exits 1 for foo=)
					;;
				help)
					print_help 2>&1
					exit
					;;
				*)
					echo "Invalid option: --$OPTARG" >&2
					exit 2
					;;
			esac
			;;
		v)
			verbose=1
			;;
		t)
			let "conftest++" || echo "$(basename "$0"): Config test mode" >&2 # increment, so it can be repeated (exits 1 for foo=)
			;;
		h)
			print_help 2>&1
			exit
			;;
	esac
done

OPTIND=1 # start over with options parsing
while getopts "$optstring" opt; do
	case $opt in
		C)
			nginx_config_include=$(check_exists "$OPTARG" "C")
			;;
		c)
			nginx_config=$(check_exists "$OPTARG" "c")
			;;
		F)
			fpm_config_include=$(check_exists "$OPTARG" "F")
			;;
		f)
			fpm_config=$(check_exists "$OPTARG" "f")
			;;
		i)
			php_config=$(check_exists "$OPTARG" "i")
			;;
		l)
			logarg=( $OPTARG ) # must not quote this or wildcards won't get expanded into individual values
			if [[ ${#logarg[@]} -eq 0 ]]; then # we set nullglob to detect if a pattern matched nothing (then the array is empty)
				echo "Pattern '$OPTARG' passed to option -l matched no files" >&2
				exit 1
			fi
			for logfile in "${logarg[@]}"; do
				if [[ -d "$logfile" ]]; then
					echo "-l '$logfile': is a directory" >&2
					exit 1
				fi
				touch_log "$logfile" || { echo "Could not touch '$logfile'; permissions problem?" >&2; exit 1; }
				[[ $verbose ]] && echo "Tailing '$logfile' to stderr" >&2
				logs+=("$logfile") # must quote here in case a wildcard matched a file with a space in the name
			done
			;;
		p)
			PORT="$OPTARG"
			;;
		\?)
			echo "Invalid option: -$OPTARG" >&2
			exit 2
			;;
		:)
			echo "Option -$OPTARG requires an argument" >&2
			exit 2
			;;
	esac
done
# clear processed arguments
shift $((OPTIND-1))

if [[ "$#" -gt "1" ]]; then
	print_help "$(cat <<-EOF
		$(basename $0): too many arguments. If you're using options,
		make sure to list them before any document root argument you're providing.
	EOF
	)"
	exit 2
fi

# our standard logs
logs+=( "/tmp/heroku.php-fpm.$PORT.log" "/tmp/heroku.php-fpm.www.$PORT.log" "/tmp/heroku.php-fpm.$PORT.www.slowlog" "/tmp/heroku.nginx_access.$PORT.log" )

# a bunch of checks; don't load any INIs to prevent newrelic etc from loading, we don't need all that stuff
php -r 'exit((int)version_compare(PHP_VERSION, "5.5.11", "<"));' || { echo "This program requires PHP 5.5.11 or newer; check your 'php' command." >&2; exit 1; }
{ php-fpm -n -v | php -r 'exit((int)version_compare(preg_replace("#PHP (\S+) \(fpm-fcgi\).+$#sm", "\\1", file_get_contents("php://stdin")), "5.5.11", "<"));'; } || { echo "This program requires PHP 5.5.11 or newer; check your 'php-fpm' command." >&2; exit 1; }

php_version="$(php-fpm -n -v | php -r 'echo preg_replace("#PHP (\S+) \(fpm-fcgi\).+$#sm", "\\1", file_get_contents("php://stdin"));')"
nginx_version="$(nginx -v 2>&1 | php -r 'echo preg_replace("#.*nginx/([\d\.]+)$#sm", "\\1", file_get_contents("php://stdin"));')"

# make sure we try a local composer.phar if no global installation is available
composer_bin=$(command -v composer ./composer.phar) || { echo "This program requires 'composer' on \$PATH or a 'composer.phar' in the CWD." >&2; exit 1; }
composer_bin=$(head -n1 <<< "$composer_bin") # if both were there, we'd have two lines of output, no good for invoking something :)
composer() {
	# check if the composer binary is executable by PHP
	if file --brief --dereference "$composer_bin" | grep -e "shell" -e "bash" > /dev/null ; then # newer versions of file return "data" for .phar
		# run it directly; it's probably a bash script or similar (homebrew-php does this)
		# disable interactive mode just in case our environment doesn't have that set (or 'composer config vendor-dir' will hang with a prompt if there is no composer.json in the cwd)
		# re-set COMPOSER_AUTH to ensure a malformed `heroku config:set` will not cause immediate outage
		COMPOSER_NO_INTERACTION=1 COMPOSER_AUTH= "$composer_bin" "$@"
	else
		COMPOSER_NO_INTERACTION=1 COMPOSER_AUTH= php "$composer_bin" "$@"
	fi
}
# these exports are used in default web server configs to lock down access to composer directories
COMPOSER_VENDOR_DIR=$(composer config vendor-dir 2> /dev/null | tail -n 1) && export COMPOSER_VENDOR_DIR || { echo "Unable to determine Composer vendor-dir setting; is 'composer' executable on path or 'composer.phar' in current working directory?" >&2; exit 1; } # tail, as composer echos outdated version warnings to STDOUT; export after the assignment or exit status will that be of 'export
COMPOSER_BIN_DIR=$(composer config bin-dir 2> /dev/null | tail -n 1) && export COMPOSER_BIN_DIR || { echo "Unable to determine Composer bin-dir setting; is 'composer' executable on path or 'composer.phar' in current working directory?" >&2; exit 1; } # tail, as composer echos outdated version warnings to STDOUT; export after the assignment or exit status will that be of 'export

if [[ "$#" == "1" ]]; then
	DOCUMENT_ROOT="$HEROKU_APP_DIR/$1"
	if [[ ! -d "$DOCUMENT_ROOT" ]]; then
		echo "DOCUMENT_ROOT '$1' does not exist" >&2
		exit 1
	else
		# strip trailing slashes if present
		DOCUMENT_ROOT=${DOCUMENT_ROOT%%*(/)} # powered by extglob
		if [[ $verbose ]]; then
			echo "DOCUMENT_ROOT changed to '$DOCUMENT_ROOT'" >&2
		else
			echo "DOCUMENT_ROOT changed to '${1%%*(/)}/'" >&2
		fi
	fi
fi

if [[ -n ${fpm_config_include:-} ]]; then
	echo "Using PHP-FPM configuration include '${fpm_config_include#$HEROKU_APP_DIR/}'" >&2
	fpm_config_include=$(php_passthrough "$fpm_config_include")
	export HEROKU_PHP_FPM_CONFIG_INCLUDE="$fpm_config_include"
fi

if [[ -n ${fpm_config:-} || ( ${fpm_config:=$(findconfig "$php_version" "$bp_dir/conf/php/php-fpm.conf")} && $verbose ) ]]; then
	echo "Using PHP-FPM configuration file '${fpm_config#$HEROKU_APP_DIR/}'" >&2
fi
fpm_config=$(php_passthrough "$fpm_config")

# we explicitly use the INI file php-fpm would load if no INI is given
# this is to ensure that e.g. our WEB_CONCURRENCY calculation doesn't pick up a php-cli.ini
fpm_ini=$(php-fpm -i | awk -F' => ' '/^Loaded Configuration File/{print $2}')
if [[ -n ${php_config:-} || ( "$fpm_ini" != "(none)" && ${php_config:=$fpm_ini} && $verbose ) ]]; then
	echo "Using PHP configuration (php.ini) file '${php_config#$HEROKU_APP_DIR/}'" >&2
fi
[[ -n ${php_config:-} ]] && php_config=$(php_passthrough "$php_config")

if [[ -n ${nginx_config_include:-} || ( ${nginx_config_include:=$(findconfig "$nginx_version" "$bp_dir/conf/nginx/default_include.conf.php")} && $verbose ) ]]; then
	echo "Using Nginx server-level configuration include '${nginx_config_include#$HEROKU_APP_DIR/}'" >&2
fi
nginx_config_include=$(php_passthrough "$nginx_config_include")
export HEROKU_PHP_NGINX_CONFIG_INCLUDE="$nginx_config_include"

if [[ -n ${nginx_config:-} || ( ${nginx_config:=$(findconfig "$nginx_version" "$bp_dir/conf/nginx/heroku.conf.php")} && $verbose) ]]; then
	echo "Using Nginx configuration file '${nginx_config#$HEROKU_APP_DIR/}'" >&2
fi
nginx_config=$(php_passthrough "$nginx_config")

nginx_main=$(findconfig "$nginx_version" "$bp_dir/conf/nginx/main.conf")

fpm_pidfile=$(mktemp -t "heroku.php-fpm.pid-$PORT.XXXXXX" -u)
nginx_pidfile=$(mktemp -t "heroku.nginx.pid-$PORT.XXXXXX" -u)

# we need to dump the FPM config to fetch possible values for memory_limit in php_value and php_admin_value pool declarations
# parsing this by hand is cumbersome - there might be recursive includes in the config, glob patterns, etc
# also, the FPM behavior isn't quite correct (https://github.com/php/php-src/issues/13249), but might be fixed at some point
# so we use php-fpm -tt to dump the config, and extract the values from there
# we need to ensure the log level is NOTICE, since FPM uses its logging for dumping - but we default to WARNING
# that's why we're making a temporary config with the log level overridden
# (the same temporary file is also used for our own -tt and -tttt config test modes)
fpm_config_tmp=$(mktemp "$fpm_config.XXXXX")
cp "$fpm_config" "$fpm_config_tmp"
trap 'trap - EXIT; rm -f "$fpm_config_tmp"' EXIT
echo -e "\n[global]\nlog_level = notice" >> "$fpm_config_tmp"

# build command string arrays for PHP-FPM and Nginx
# we're using an array, because we need to correctly preserve quoting, spaces, etc
fpm_command=( php-fpm --pid "$fpm_pidfile" --nodaemonize -y "$fpm_config_tmp" ${php_config:+-c "$php_config"} )
nginx_command=( nginx -c "$nginx_main" -g "pid $nginx_pidfile; include $nginx_config;" )

ram=
# attempt to read a "correct" limit from cgroupfs
# this will handle cgroups v1 and v2, and, for v2, prefer memory.high over memory.max over memory.low
if [[ -d "/proc" ]]; then # check for /proc, just in case someone is running this on e.g. macOS
	[[ $verbose ]] && echo "Checking cgroupfs for memory limits..." >&2
	ram=$(CGROUP_UTIL_VERBOSE=${verbose:+1} cgroup_util_read_cgroup_memory_limit_with_fallback) || {
		# 99 means the limit was exceeded; in verbose mode, a message was then already printed
		if (( $? != 99)) && [[ $verbose ]]; then
			echo "No cgroup memory limits found" >&2
		fi
	}
fi
if [[ -z "$ram" ]]; then
	ram="512M"
	echo "Assuming RAM to be ${ram} Bytes" >&2
fi

# read number of available processor cores in a portable (Linux, macOS, BSDs) fashion (leading underscore is not always there)
# we prefer 'nproc', because that applies cgroup limits for us (e.g. when using 'docker run --cpuset-cpus=1-2'), but it's from GNU coreutils
cores=$(set -o pipefail; nproc 2>/dev/null || getconf -a | grep -E '_?NPROCESSORS_ONLN' | head -n1 | tr -s ' ' | cut -d" " -f2) || {
	echo "WARNING: failed to determine _NPROCESSORS_ONLN via getconf" >&2
	cores=1
	echo "Assuming number of CPU cores to be ${cores}" >&2
}

if [[ -z ${WEB_CONCURRENCY:-} ]]; then
	if [[ -n ${DYNO:-} && $(ulimit -u) == "32768" && ( "$php_version" == 5.* || "$php_version" == 7.[0-3].* ) ]]; then
		# on a Performance-L dyno, limit to 6 GB of RAM for backwards compatibility for PHP versions before 7.4
		# this is to ensure that apps previously running on PX dynos (6 GB RAM) do not get a sudden jump in the number of worker processes
		ram="6G"
		echo "Limiting RAM usage to ${ram} Bytes" >&2
	fi

	[[ $verbose ]] && echo "Calculating WEB_CONCURRENCY..." >&2

	# dump the FPM config and parse out memory_limit declarations
	# for that to work, we need the WEB_CONCURRENCY env var set, as the FPM config references it
	export WEB_CONCURRENCY=1
	# inside this subshell, we want pipefail on to ensure that a failing FPM command causes an exit
	# grep might not match anything, so if it doesn't (status 1), we carry on, since that's fine (but other exit statuses will bubble up)
	fpm_limits=$(set -o pipefail; "${fpm_command[@]}" -tt 2>&1 | { grep -E $'NOTICE: \tphp(_admin)?_value\[memory_limit\]' || test $? = 1; } | cut -f2) || {
		# on failure, we should output something meaningful
		echo "PHP-FPM config test failed with status $?:" >&2
		# restore the original config to avoid any potential for confusion
		cp "$fpm_config" "$fpm_config_tmp"
		# single -t is enough this time
		"${fpm_command[@]}" -t
		exit 1
	}

	# determine number of FPM processes to run
	# we feed it the PHP config we found much earlier (it must be the one FPM loads, not the CLI one!), docroot (for .user.ini), detected RAM, and RAM cap
	# on STDIN, we also pass it any php_value or php_admin_value lines from the FPM config dump that may have a memory_limit (via a herestring, easier than echo)
	autotune_verbose=
	if [[ $verbose ]]; then
		autotune_verbose="--verbose" # like -vv
	elif [[ -z ${DYNO:-} ]]; then
		autotune_verbose="-v" # only print a little more info on non-dynos
	fi
	WEB_CONCURRENCY=$(php ${php_config:+-c "$php_config"} -f "$bp_dir/bin/util/autotune.php" -- ${autotune_verbose} -t "$DOCUMENT_ROOT" "$ram" "$cores" <<<"$fpm_limits")
	export WEB_CONCURRENCY
else
	echo '$WEB_CONCURRENCY env var is set, skipping automatic calculation' >&2
fi

if ! [[ "$WEB_CONCURRENCY" =~ ^[1-9][0-9]*$ ]] || (( WEB_CONCURRENCY < 1 )); then
	WEB_CONCURRENCY=1
	echo "Setting WEB_CONCURRENCY=1 (was outside allowed range)" >&2;
fi

if [[ $conftest ]]; then
	case $conftest in
		1)
			fpm_conftest="-t"
			nginx_conftest="-t"
			;;
		2)
			fpm_conftest="-tt"
			nginx_conftest="-t"
			;;
		3)
			fpm_conftest="-t"
			nginx_conftest="-T"
			;;
		*)
			fpm_conftest="-tt"
			nginx_conftest="-T"
			;;
	esac

	echo -e "\n$(basename "$0"): Config testing php-fpm using ${fpm_command[@]} ${fpm_conftest}:" >&2
	"${fpm_command[@]}" ${fpm_conftest}
	echo -e "\n$(basename "$0"): Config testing nginx using ${nginx_command[@]} ${nginx_conftest}:" >&2
	"${nginx_command[@]}" ${nginx_conftest}
	echo -e "\n$(basename "$0"): All configs okay." >&2
	exit 0
fi

# we're done dumping stuff; restore original FPM config
cp "$fpm_config" "$fpm_config_tmp"

# make a shared pipe; we'll write the name of the process that exits to it once that happens, and wait for that event below
# this particular call works on Linux and Mac OS (will create a literal ".XXXXXX" on Mac, but that doesn't matter).
wait_pipe=$(mktemp -t "heroku.waitpipe-$PORT.XXXXXX" -u)
rm -f $wait_pipe
mkfifo $wait_pipe
exec 3<> $wait_pipe

pids=()
graceful_sigterm=
if [[ -n "${DYNO:-}" && "${HEROKU_PHP_GRACEFUL_SIGTERM:-1}" != "0" ]]; then
	export HEROKU_PHP_GRACEFUL_SIGTERM=1
	graceful_sigterm=1
fi

# trap SIGTERM (for when we get killed) and EXIT (upon failure of any command due to set -e, or because of the exit at the very end), we then
# 1) ignore the trap for the signal in question so it doesn't fire again should the signal arrive a second time (this happens, e.g. when 'timeout' terminates something)
# 1a) such a second identical signal arriving may occasionally cause a "warning: run_pending_traps: bad value in trap_list[15]: 0x1" due to a race condition in Bash versions before 5.1, see 4b)
# 2) call cleanup() to
# 2a) kill all the subshells we've spawned, in reverse order - they in turn have their own traps to kill their respective subprocesses
# 2b) send STDERR to /dev/null so we don't see "no such process" errors - after all, one of the subshells may be gone
# 2c) || true so that set -e doesn't cause a mess if the kill returns 1 on "no such process" cases
# 2d) 'wait' on that killed subprocess, sending wait's output to /dev/null - this prevents the logs getting cluttered with "vendor/bin/heroku-...: line 309:    96 Terminated" messages (we can't use 'disown' after launching programs for that purpose because that removes the jobs from the shell's jobs table and then we can no longer 'wait' on the program)
# 2e) remove our FIFO from above
# 3) as the signal to subshells, we use SIGUSR1
# 3a) this is so subshells can tell whether we sent them the signal and they should in turn stop gracefully (SIGUSR1)...
# 3b) ... or if something crashed, or e.g. Heroku's process manager sent SIGTERM to all processes in the group, in which case a graceful shutdown attempt is futile anyway
# 3c) SIGINT oder SIGQUIT are not an option since backgrounded subshells cannot trap them (http://vaab.blog.kal.fr/2016/07/30/trapping-sigint-sigquit-in-asynchronous-tasks/) in Bash 3 (it works in Bash 4+ in interactive shells at least)
# 3d) because we're sending SIGUSR1 via cleanup(), we have to ignore that signal right before - otherwise, the backgrounded subshells exiting upon this exit status would cause our main process to terminate due to set -e
# 3e) we do not simply set USR1 to ignored globally, because then we would not catch the case where something external sends a USR1 to our subshell - instead, our exit trap will fire in these cases where we did not initiate the cleanup, and tear everything down
# 4) kill ourselves with the correct signal in case we're handling SIGTERM or, further down, SIGINT (http://www.cons.org/cracauer/sigint.html and http://mywiki.wooledge.org/SignalTrap#Special_Note_On_SIGINT1)
# 4a) we have to re-set the trap handler to the default (using "-") for that
# 4b) we still exit 0 as a last resort, because Bash has a race condition bug (see run_pending_traps in trap.c) where if another signal arriving during the 'trap "" TERM' call (to ignore the same signal and prevent multiple executions), the trap would hang at the end, despite the 'kill -TERM $$'
cleanup() {
	signal=${1:-TERM}
	# reverse through list of child processes and kill them
	# we want that order so the web server stops accepting connections and drains properly, then the app is terminated, and then finally the logs redirect, which we want alive up until the very last moment
	for (( i=${#pids[@]}-1 ; i>=0 ; i-- )) ; do
		kill -"$signal" "${pids[$i]}" 2> /dev/null || true # redirect stderr and || true the call because the process might be gone
		wait "${pids[$i]}" 2> /dev/null || true # redirect stderr so that the Bash's confusing "Terminated: …" lines are suppressed and || true the call because wait returns the exit status of the program, and not 0
	done
	rm -f ${wait_pipe} || true;
	echo "Shutdown complete." >&2
}

# we always try to shut down gracefully on SIGTERM
# this may not work depending on env vars and platform behavior, but the subshells will take care of those details
trap 'trap "" TERM; trap - EXIT; echo "SIGTERM received, attempting graceful shutdown..." >&2; trap "" USR1; cleanup USR1; trap - TERM; kill -TERM $$; exit 0' TERM

# on "regular" EXIT (i.e. when we reached the end of the script because a process exited unexpectedly) just clean up and let the exit status bubble through (see last line)
# this will also fire when any unhandled signal is received, in which case $exitproc will be unset
trap 'echo "Process exited unexpectedly: ${exitproc-$(basename $0)}, shutting down..." >&2; trap "" USR1; cleanup USR1' EXIT

# if FD 1 is a TTY (that's the -t 1 check), trap SIGINT/Ctrl+C, then same procedure as for TERM above
if [[ -t 1 ]]; then
	trap 'trap "" INT; trap - EXIT; echo "SIGINT received, initiating graceful shutdown..." >&2; trap "" USR1; cleanup USR1; trap - INT; kill -INT $$; exit 0' INT;
# if FD 1 is not a TTY (e.g. when we're run through 'foreman start'), do nothing on SIGINT; the assumption is that the parent will send us a SIGTERM or something when this happens. With the trap above, Ctrl+C-ing out of a 'foreman start' run would trigger the INT trap both in Foreman and here (because Ctrl+C sends SIGINT to the entire process group, but there is no way to tell the two cases apart), and while the trap is still doing its shutdown work triggered by the SIGTERM from the Ctrl+C, Foreman would then send a SIGTERM because that's what it does when it receives a SIGINT itself.
else
	trap '' INT;
fi

# we are now launching a subshell for each of the tasks (log tail, app server, web server)
# 1) each subshell has an echo as an EXIT trap that echos the command name to FD 3 (see the FIFO set up above)
# 1a) a 'read' at the end of the script will block on reading from that FD and, when something is written to the pipe, will unblock, which finishes the script, which triggers the exit trap further above, which does some cleanup
# 2) each subshell also has traps that on TERM or USR1 kill the processes inside the subshell
# 2a) if a SIGTERM or SIGUSR1 is received, this will unblock the first 'wait' and execute the corresponding trap
# 2b) a second 'wait' then ensures that the subshell remains alive until the process inside has shut down
# 2c) both 'wait' calls use || true to prevent 'set -e' failures: 'wait' returns the exit status of the given program, and that won't be zero, because it's getting killed
# 2d) we restore traps before the second wait, because restoring them inside the trap handler occasionally caused bizarre signal handling failures
# 2e) inside the TERM and USR1 traps, we ignore both of those signals as the very first thing, to ensure another similar signal arriving does not interrupt our shutdown
# 3) each subshell has another trap on INT which does nothing (to prevent Ctrl+C interference)
# 4) we execute the command in the background, recording its PID(s) for the subshell's TERM trap
# 5) we also execute the subshell in the background, recording its PID for the main TERM/INT traps

(
	# the TERM trap here is special, because
	# 1) there is a pipeline from tail to sed
	# 2) we thus need to kill several children
	# 3) job control (set -m, where we could then kill %% instead) has weird side effects e.g. on ctrl+c (kills the parent terminal after that too)
	# 4) so we try to kill all PIDs that we recorded
	# 4a) gracefully, by redirecting STDERR to /dev/null - one of the children may already be gone
	# 5) the sed with the Darwin/GNU sed arg case used to be a function, but that was even worse with an extra wrapping subshell for sed
	# FIXME: when that sed is killed or crashes, it'll take until the next log line, when tail fails to write to sed, to error and bring everything down
	trap 'echo "log redirection" >&3; wait 2> /dev/null' EXIT
	trap '' INT;

	[[ $verbose ]] && echo "Starting log redirection..." >&2
	unset logs_procs

	logs_pipe=$(mktemp -t "heroku.logspipe-$$.XXXXXX" -u)
	trap 'rm -f $logs_pipe; echo "log redirection" >&3;' EXIT
	rm -f $logs_pipe
	mkfifo $logs_pipe

	touch "${logs[@]}"

	if [[ $(uname) == "Darwin" ]]; then
		sedbufarg="-l" # mac/bsd sed: -l buffers on line boundaries
	else
		sedbufarg="-u" # unix/gnu sed: -u unbuffered (arbitrary) chunks of data
	fi

	# we need 'tail' and 'sed' to ignore SIGTERM as well
	# we do that by setting the handler for SIGTERM to SIG_IGN, then exec-ing the program, which preserves the signal handler (see https://pubs.opengroup.org/onlinepubs/9699919799/functions/execle.html):
	# "Signals set to the default action (SIG_DFL) in the calling process image shall be set to the default action in the new process image. Except for SIGCHLD, signals set to be ignored (SIG_IGN) by the calling process image shall be set to be ignored by the new process image. Signals set to be caught by the calling process image shall be set to the default action in the new process image (see <signal.h>)."
	# this of course doesn't work in more complex cases like Nginx or Apache or PHP, which install their own signal handlers on startup, but tail and sed don't do that
	(
		[[ $graceful_sigterm ]] && trap '' TERM
		exec tail -qF -n 0 "${logs[@]}" > "$logs_pipe"
	) & logs_procs+=($!)
	(
		[[ $graceful_sigterm ]] && trap '' TERM
		# messages that are too long are cut off using "..." by FPM instead of closing double quotation marks; we want to preserve those three dots but not the closing double quotes
		exec sed $sedbufarg -E -e 's/^\[[^]]+\] WARNING: \[pool [^]]+\] child [0-9]+ said into std(err|out): "(.*)("|...)$/\2\3/' -e 's/"$//' < "$logs_pipe" 1>&2
	) & logs_procs+=($!)
	# after the kill, we are redirecting stderr to /dev/null using exec to suppress "… Terminated …" messages from the shell, which it would print right before the next command is invoked
	# we have to send SIGHUP to each process (could also use SIGUSR1 or any other process where the default behavior is termination, see https://man7.org/linux/man-pages/man7/signal.7.html); SIGQUIT or SIGINT doesn't work - this is because of the SIG_IGN workaround above, which is done using a backgrounded subshell and an exec - and backgrounded subshells cannot trap SIGTERM or SIGINT: http://vaab.blog.kal.fr/2016/07/30/trapping-sigint-sigquit-in-asynchronous-tasks/
	trap 'trap "" USR1; trap - EXIT; [[ $verbose ]] && echo "Stopping log redirection..." >&2; kill -HUP "${logs_procs[@]}" 2> /dev/null || true; exec 2> /dev/null; wait "${logs_procs[@]}" 2> /dev/null || true; trap - USR1; kill -USR1 $$' USR1
	# ignore process group wide SIGTERM for this subshell if that env var is set
	if [[ $graceful_sigterm ]]; then
		trap '' TERM
	else
		trap 'trap "" TERM; [[ $verbose ]] && echo "Stopping log redirection..." >&2; kill -TERM "${logs_procs[@]}" 2> /dev/null || true; exec 2> /dev/null; wait "${logs_procs[@]}" 2> /dev/null || true; trap - TERM; kill -TERM $$' TERM
	fi

	wait 2> /dev/null || true # redirect stderr to prevent possible trap race condition warnings
	trap - USR1
	[[ $graceful_sigterm ]] || trap - TERM # only reset to default if we're not ignoring, or a SIGTERM from the outside that tries to kill all running processes might arrive just in this moment to unblock the next wait
	wait 2> /dev/null || true # redirect stderr to prevent possible trap race condition warnings
) & pids+=($!)

(
	trap 'echo "php-fpm" >&3; wait 2> /dev/null' EXIT
	trap '' INT;

	echo "Starting php-fpm with ${WEB_CONCURRENCY} workers..." >&2

	# earlier, we added special INIs that prevent newrelic etc from starting to the scan dir
	# we do not want that to apply to php-fpm, which uses the same scan dir for its configs as the regular PHP CLI SAPI
	# we now restore the original env var from the backup variable, if it existed (important, because empty string and unset are different)
	# doing this in this subshell means any later code can still invoke PHP CLI with the special INIs applied
	unset PHP_INI_SCAN_DIR
	if [[ -n ${_PHP_INI_SCAN_DIR+x} ]]; then
		export PHP_INI_SCAN_DIR=$_PHP_INI_SCAN_DIR
	fi

	# execute the command we built earlier, with the correct quoting etc expanded
	"${fpm_command[@]}" & pid=$!
	# wait for the pidfile in the trap; otherwise, a previous subshell failing may result in us getting a SIGTERM and forwarding it to the child process before that child process is ready to process signals
	trap 'trap "" USR1; trap - EXIT; echo "Stopping php-fpm gracefully..." >&2; wait_pid_and_pidfile $pid "$fpm_pidfile"; kill -QUIT $pid 2> /dev/null || true; wait $pid 2> /dev/null || true; trap - USR1; kill -USR1 $$' USR1
	# we always want to try and stop gracefully, especially since on Heroku we might be getting a process group wide SIGTERM but running a patched PHP-FPM that ignores SIGTERM; so if that env var is set, we ignore SIGTERM in this subshell as well
	if [[ $graceful_sigterm ]]; then
		trap '' TERM
	else
		trap 'trap "" TERM; echo "Stopping php-fpm..." >&2; wait_pid_and_pidfile $pid "$fpm_pidfile"; kill -TERM $pid 2> /dev/null || true; wait $pid 2> /dev/null || true; trap - TERM; kill -TERM $$' TERM
	fi

	wait $pid 2> /dev/null || true # redirect stderr to prevent possible trap race condition warnings
	trap - USR1
	[[ $graceful_sigterm ]] || trap - TERM # only reset to default if we're not ignoring, or a SIGTERM from the outside that tries to kill all running processes might arrive just in this moment to unblock the next wait
	wait $pid 2> /dev/null || true # redirect stderr to prevent possible trap race condition warnings
) & pids+=($!)

(
	trap 'echo "nginx" >&3; wait 2> /dev/null' EXIT
	trap '' INT;

	# wait for FPM to write its PID file, which it does after initialization
	# if we didn't wait, the web server would start sending traffic to the FCGI backend before it's ready (or before the socket is even created)
	# if FPM fails to start, the FPM subshell will exit, causing the main cleanup procedure to run, which sends this subshell a SIGTERM
	# there is however a possible race condition where this happens exactly the moment we hit the function below, which is why it internally has a timeout of 2500 ms, and we use that to exit 1
	wait_pidfile "$fpm_pidfile" "Waiting for php-fpm to finish initializing..." || { echo "Timed out waiting for php-fpm." >&2; exit 1; }

	echo "Starting nginx..." >&2

	# execute the command we built earlier, with the correct quoting etc expanded
	"${nginx_command[@]}" & pid=$!
	# wait for the pidfile in the trap; otherwise, a previous subshell failing may result in us getting a SIGTERM and forwarding it to the child process before that child process is ready to process signals
	trap 'trap "" USR1; trap - EXIT; echo "Stopping nginx gracefully..." >&2; wait_pid_and_pidfile $pid "$nginx_pidfile"; kill -QUIT $pid 2> /dev/null || true; wait $pid 2> /dev/null || true; trap - USR1; kill -USR1 $$' USR1
	# we always want to try and stop gracefully, especially since on Heroku we might be getting a process group wide SIGTERM but running a patched Nginx that ignores SIGTERM; so if that env var is set, we ignore SIGTERM in this subshell as well
	if [[ $graceful_sigterm ]]; then
		trap '' TERM
	else
		trap 'trap "" TERM; echo "Stopping nginx..." >&2; wait_pid_and_pidfile $pid "$nginx_pidfile"; kill -TERM $pid 2> /dev/null || true; wait $pid 2> /dev/null || true; trap - TERM; kill -TERM $$' TERM
	fi

	# first, we wait for the pid file to be written, so that we can emit a ready message
	wait_pid_and_pidfile $pid "$nginx_pidfile"
	# on Heroku, there is a "state changed from starting to up", but for local execution, we want a "ready" message
	[[ -z ${DYNO:-} || $verbose ]] && kill -0 $pid 2> /dev/null && echo "Application ready for connections on port $PORT." >&2

	wait $pid 2> /dev/null || true # redirect stderr to prevent possible trap race condition warnings
	trap - USR1
	[[ $graceful_sigterm ]] || trap - TERM # only reset to default if we're not ignoring, or a SIGTERM from the outside that tries to kill all running processes might arrive just in this moment to unblock the next wait
	wait $pid 2> /dev/null || true # redirect stderr to prevent possible trap race condition warnings
) & pids+=($!)

# wait for something to come from the FIFO attached to FD 3, which means that the given process was killed or has failed
# this will be interrupted by a SIGTERM or SIGINT in the traps further up
# if the pipe unblocks and this executes, then we won't read it again, so if the traps further up kill the remaining subshells above, their writing to FD 3 will have no effect
read exitproc <&3
# we'll only reach this if one of the processes above has terminated
# this will trigger the main EXIT trap further up and kill all remaining children
# code 70 is EX_SOFTWARE from sysexits.h
# Ctrl+C/SIGINT or SIGTERM exits will, through the respective trap's 'kill $$', exit with the correct 128+$signalcode status
exit 70
