Files
acac97b07d phpbox: the node survives any host and keeps the tunnel up across generations (#143)
* phpbox: survive any host's limits and keep streams alive across handovers

Found on a local emulation of a free host (Apache + PHP-FPM, 60 s CPU per request,
the host's disabled functions, a gateway that gives up on the request):
- disabled set_time_limit/ignore_user_abort/getmypid/getenv/ini_set ended the node
  at once under PHP 8 (a disabled function is undefined): guarded everywhere; ping
  lists what the host took away
- time budget per generation on both clocks (CPU on Linux, wall on Windows/macOS,
  RLIMIT_CPU), handover early when CPU runs short, and learning: a generation that
  died early leaves its age in host.json and the next ones plan below it; on a host
  not yet proven, handovers start at 40 s and grow as generations end well
- cups: the carrier slept 18 ms per message inside the loop (a 64 KB frame ~0.4 s),
  so under a download new connections timed out and acks waited; now a paced
  outbox in the mux: control frames first, streams in turn, whole packets per
  message, at most ~72 KB/s (the server drops a member that sends faster)
- two generations in one room: each now ignores the other's messages (and its own
  echoes, before decoding them), and messages carry whole packets, so a packet cut
  across messages is never mixed with another member's bytes; the Go cupsonline
  receiver keeps one reassembly buffer per member
- a successor takes streams only after its link stayed up 8 s (a Mail.ru document
  drops new connections right after they join, and streams handed over then died)
- a repeated OPEN is answered again instead of redialled; the client asks again
  after 4 s without an answer
- the page restarts a node that ended without Stop while it is open
- --mode=stream without --inbound is SOCKS5 on every OS again (macOS took utun)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* phpbox: send to cups only as fast as the server confirms; release rebuilds an existing tag

- the cups server shows every member's message back to it; our own coming back is
  its confirmation. At most 6 unconfirmed messages: past that the server is behind,
  and the client's OPENs and its pings queued behind our echoes (close 3012
  'no pong' under a download). The link's close code and reason go to the log.
- release.yml: a moved tag replaces the release's assets and notes, as the apps do.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* phpbox: count the cups server's echoes reliably; learn a host limit only from two deaths

- echoes were matched with a regex on the raw line that never matched the server's
  JSON, so the window waited out its 3 s timeout each time (~6 KB/s under load):
  a substring test on the member uuid instead; a live room confirms 10 of 10
- one sudden end (a host restart) no longer teaches a limit that shortens every
  run for a week: two ends at about the same age do

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* phpbox: hand over early and consistently; never reach for a longer run than the host allows

The emulated hosts exposed the real cause of the node dying: the 'hold idle to prove a
longer run' recorded a lifetime (107s) longer than a host's hidden wall-clock kill (90s),
so later generations set their handover past that limit and were killed before they could
start a successor - the chain broke with no generation left to carry the lesson.

- a generation now hands over at ~2/3 of the known limit, never later than 45s, even on a
  host that looks generous: a free host's real limit is unknown until a generation dies by
  it, and that death must happen after a successor exists. Deaths only lower it.
- removed the idle hold and the lifetime-growth; a learned wall/CPU limit still lowers the
  handover, and a wall limit is believed only after two deaths at a similar age
- while a successor shares the room, the predecessor caps its reading (OVERLAP_RATE): the
  document server copies our download to the successor too, and at full speed the client's
  OPENs to the successor waited behind it

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* CHANGELOG: the node-survival and stream-proxy fixes

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* CHANGELOG: fold the node-survival fixes into 0.3.0 (rebuilt in place)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: p1neappleXpress <a@a.a>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 11:28:07 +03:00

764 lines
38 KiB
PHP

<?php
// The page + control side of a phpbox exit: one controller every exit file
// (cupsexit.php, mailruexit.php, ...) hands its Carrier factory to.
//
// node.php?k=TOKEN&url=TARGET browser -> the status page (starts the node for you)
// anything else (a pinger, curl) -> runs the node, as before
// ...&a=run | ui | status | log | stop explicit actions (JSON for status/log/stop)
//
// "Already running" is decided from a heartbeat the running node writes every
// second (plus an flock), so a second open - or a pinger - never starts a
// second node on the same target; it just reports the first one.
//
// Self-renewing tunnel (&chain=1, what the page asks for): a request lives only
// ~cap seconds on these hosts, so before it ends a node starts its successor -
// a request to its own host, which passes the host's bot check because it comes
// from the host itself - and they hand over:
// 1. the successor joins and reports "serving";
// 2. the old generation stops taking NEW streams ("draining") and keeps
// serving the ones it has until they end or its own cap;
// 3. while both are up, a new stream goes to whichever creates its marker
// directory first (mkdir is atomic), so no stream is served twice.
// Only one generation ever takes new streams; a stop (the page, &a=stop) ends
// the whole chain and keeps a dying generation from spawning another.
//
// Links are NOT parsed here. The page parses openflux:// links in the browser
// with the core's own parser (WebAssembly), so there is one parser for every
// client; this file only ever sees the carrier's own address (?url=).
require_once __DIR__ . '/util.php';
require_once __DIR__ . '/mux.php';
/** An append-only ring log, one JSON object per line: {"t":ms,"l":level,"m":message}. */
final class PhpboxLog
{
const MAX_BYTES = 262144;
public function __construct(private string $file) {}
public function write(string $level, string $msg): void
{
$line = json_encode(['t' => (int)(microtime(true) * 1000), 'l' => $level, 'm' => $msg], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . "\n";
@file_put_contents($this->file, $line, FILE_APPEND | LOCK_EX);
if (mt_rand(1, 50) === 1) {
$this->trim();
}
}
private function trim(): void
{
if (@filesize($this->file) <= self::MAX_BYTES) {
return;
}
$keep = @file_get_contents($this->file, false, null, (int)(self::MAX_BYTES / 2));
if ($keep === false) {
return;
}
$nl = strpos($keep, "\n");
@file_put_contents($this->file, $nl === false ? '' : substr($keep, $nl + 1), LOCK_EX);
}
public function clear(): void
{
@file_put_contents($this->file, '', LOCK_EX);
}
/** Lines after byte offset $since. Offsets restart at 0 when the ring was trimmed. */
public function tail(int $since): array
{
$size = (int)@filesize($this->file);
$reset = $since > $size;
if ($reset) {
$since = 0;
}
$lines = [];
$next = $since;
if ($size > $since) {
$chunk = (string)@file_get_contents($this->file, false, null, $since, 65536);
$end = strrpos($chunk, "\n"); // only whole lines; a half-written one waits
if ($end !== false) {
$chunk = substr($chunk, 0, $end + 1);
$next = $since + strlen($chunk);
foreach (explode("\n", rtrim($chunk, "\n")) as $l) {
$o = json_decode($l, true);
if (is_array($o)) {
$lines[] = $o;
}
}
}
}
return ['lines' => $lines, 'next' => $next, 'reset' => $reset];
}
}
/** Heartbeat file of one node (per carrier+target). */
final class PhpboxState
{
const STALE = 15; // a node that has not beaten for this long is gone
public function __construct(private string $file) {}
public function read(): array
{
$o = json_decode((string)@file_get_contents($this->file), true);
return is_array($o) ? $o : [];
}
public function write(array $s): void
{
$tmp = $this->file . '.' . PhpboxUtil::pid();
if (@file_put_contents($tmp, json_encode($s)) !== false) {
@rename($tmp, $this->file);
}
}
public static function alive(array $s): bool
{
return in_array($s['phase'] ?? '', ['connecting', 'serving', 'draining'], true)
&& (time() - (int)($s['beat'] ?? 0)) < self::STALE;
}
/** Alive and still taking new streams (not draining). */
public static function accepting(array $s): bool
{
return self::alive($s) && ($s['phase'] ?? '') !== 'draining';
}
/** All generations of one node that have a state file: gen => state. */
public static function generations(string $dir, string $key): array
{
$out = [];
foreach (glob("$dir/$key.g*.json") ?: [] as $f) {
if (preg_match('/\.g(\d+)\.json$/', $f, $m)) {
$o = json_decode((string)@file_get_contents($f), true);
if (is_array($o)) { $out[(int)$m[1]] = $o; }
}
}
ksort($out);
return $out;
}
}
/**
* How long one request may run on this host, on both clocks, so a generation hands over before the host ends it.
*
* Hosts end a PHP request in different ways: max_execution_time counts CPU time on Linux but wall time on Windows
* and macOS; some add a wall-clock kill outside PHP (php-fpm's request_terminate_timeout, a watchdog) or an RLIMIT_CPU.
* What PHP can see is used up front; what it cannot see is learned: a generation that died before its plan leaves its
* age and CPU behind, and the next ones plan around it (host.json in the state folder, shared by every node).
*/
final class PhpboxBudget
{
const LEARN_TTL = 7 * 86400; // a host's limits can change; forget what was learned after a week
const MIN_WALL = 30; // shorter than this nothing useful fits: never plan below it
const SAFE_HANDOVER = 45; // a generation hands over this early even on a host that looks generous: a free host's
// real limit is unknown until one generation dies by it, and that death must happen
// AFTER a successor was started, or the chain cannot carry the lesson forward. So the
// first generations always hand over well under any plausible limit; deaths only lower it.
public int $wall; // seconds of wall time a limit (known or default) would end this request at
public float $cpu = 0.0; // CPU seconds this request may use (0 = no limit known)
public array $why = []; // where the numbers came from, for the log
private float $cpu0;
public function __construct(int $cap, string $dir)
{
$this->cpu0 = PhpboxNode::cpuSeconds();
$this->wall = $cap;
$ini = function_exists('ini_get') ? (int)ini_get('max_execution_time') : 0; // 0 once set_time_limit(0) worked
if ($ini > 0) {
if (in_array(PHP_OS_FAMILY, ['Windows', 'Darwin'], true)) { // there the timer is a wall clock
$this->capWall($ini - 5, "max_execution_time {$ini}s (wall clock on " . PHP_OS_FAMILY . ')');
} else {
$this->capCpu($ini, "max_execution_time {$ini}s (CPU)");
}
}
if (function_exists('posix_getrlimit')) {
$rl = @posix_getrlimit();
if (is_array($rl) && is_numeric($rl['soft cpu'] ?? null) && (int)$rl['soft cpu'] > 0) {
$this->capCpu((int)$rl['soft cpu'] - PhpboxNode::cpuSeconds(), "RLIMIT_CPU {$rl['soft cpu']}s");
}
}
$h = self::learned($dir);
if (!empty($h['wall'])) { $this->capWall((int)$h['wall'] - 10, "a generation died at {$h['wall']}s"); }
if (!empty($h['cpu'])) { $this->capCpu((float)$h['cpu'], "a generation died after {$h['cpu']}s of CPU"); }
}
private function capWall(int $s, string $why): void
{
$s = max(self::MIN_WALL, $s);
if ($s < $this->wall) { $this->wall = $s; $this->why[] = $why; }
}
private function capCpu(float $s, string $why): void
{
if ($s > 0 && ($this->cpu === 0.0 || $s < $this->cpu)) { $this->cpu = round($s, 1); $this->why[] = $why; }
}
/** CPU seconds this request has used. */
public function cpuUsed(): float
{
return round(PhpboxNode::cpuSeconds() - $this->cpu0, 2);
}
/** Share of the CPU budget used (0 when there is none). */
public function cpuShare(): float
{
return $this->cpu > 0 ? $this->cpuUsed() / $this->cpu : 0.0;
}
/**
* When a generation starts its successor: early and fixed, so that even on a host whose limit we have never seen a
* generation hands over long before that limit and starts the next one. Lowered only when a known limit is close.
* Not grown toward the limit: a generation that handed over at this point never observed the host would allow more,
* so there is nothing to safely grow from, and reaching for a longer run is exactly what broke the chain.
*/
public function spawnAt(): int
{
return max(4, min(self::SAFE_HANDOVER, (int)($this->wall * 0.65))); // ~2/3 of the limit, never later than SAFE_HANDOVER
}
public function describe(): string
{
return "hand over at {$this->spawnAt()}s; limit " . ($this->cpu > 0 ? "CPU {$this->cpu}s" : "{$this->wall}s wall")
. ($this->why ? ' (' . implode('; ', $this->why) . ')' : '');
}
// ---- learning ----------------------------------------------------------
public static function learned(string $dir): array
{
$h = json_decode((string)@file_get_contents("$dir/host.json"), true);
if (!is_array($h) || time() - (int)($h['at'] ?? 0) > self::LEARN_TTL) { return []; }
return $h;
}
/**
* Look at the generations before this one: one that ended without saying so (killed outright: its heartbeat just
* stops), or that PHP ended for its time limit, before the plan it started with, tells how long requests live here.
* Returns what was learned now, for the log.
*/
public static function learn(string $dir, array $gens): ?string
{
$h = self::learned($dir);
$said = null;
foreach ($gens as $g) {
$phase = $g['phase'] ?? '';
$reason = (string)($g['reason'] ?? '');
$silent = time() - (int)($g['beat'] ?? 0) >= PhpboxState::STALE; // its heartbeat just stopped
$died = (in_array($phase, ['connecting', 'serving', 'draining', 'holding'], true) && $silent)
|| str_starts_with($reason, 'died: Maximum execution time') || $reason === 'process ended';
if (!$died) { continue; }
$age = (int)(($g['ended'] ?? $g['beat'] ?? 0) - ($g['started'] ?? 0));
$planned = (int)($g['cap'] ?? 0);
if ($age <= 0 || $age >= $planned - 3) { continue; } // it lived its plan: nothing to learn
$cpu = (float)($g['cpu'] ?? 0);
$cpuB = (float)($g['budget']['cpu'] ?? 0);
if ($cpu > 0 && $cpu >= 0.75 * $age) { // busy all along: the CPU ran out
$c = max(5.0, round($cpu * 0.9, 1));
if (empty($h['cpu']) || $c < $h['cpu']) { $h['cpu'] = $c; $said = "the host ends a request after about {$cpu}s of CPU"; }
} elseif ($cpuB <= 0 || $cpu < 0.7 * $cpuB) { // not the CPU we knew of: a clock did it
// Believed only when it happens twice at about the same age: one sudden end can be the host
// restarting, and a limit learned from that would shorten every run for a week.
$key = ($g['gen'] ?? 0) . '@' . ($g['started'] ?? 0);
$deaths = array_filter($h['deaths'] ?? [], fn($d) => time() - (int)$d['at'] < self::LEARN_TTL);
if (!in_array($key, array_column($deaths, 'id'), true)) {
foreach ($deaths as $d) {
if (abs((int)$d['age'] - $age) <= 15 && (empty($h['wall']) || min($age, (int)$d['age']) < $h['wall'])) {
$h['wall'] = max(self::MIN_WALL, min($age, (int)$d['age']));
$said = "the host ends a request after about {$h['wall']}s";
}
}
$deaths[] = ['id' => $key, 'age' => $age, 'at' => time()];
$h['deaths'] = array_slice(array_values($deaths), -6);
$said = $said ?? '';
}
}
}
if ($said !== null) {
$h['at'] = time();
@file_put_contents("$dir/host.json", json_encode($h));
}
return $said === '' ? null : $said; // '' = a death noted, nothing believed yet
}
}
final class PhpboxNode
{
const VERSION = '0.4';
const CHAIN_CAP = 240; // lifetime of one generation in chain mode (plain mode: the exit's own cap, 140)
const MAX_DRAIN = 60; // seconds a generation keeps serving its streams after handing over
// A successor takes streams only once its link has stayed up this long: a Mail.ru document closes the first
// connections right after they join, and streams handed over then were lost with the link.
const SETTLE = 8;
const OVERLAP_RATE = 131072; // bytes/s a generation sends while its successor shares the room
/**
* @param string $carrier 'cupsonline' | 'mailru' - the transport type a link must carry to fit this exit
* @param string $title shown in the page
* @param callable $target fn(array $get): string - the carrier's own address from the request ('' if none)
* @param callable $factory fn(string $target): Carrier
*/
public function __construct(
private string $carrier,
private string $title,
private $target,
private $factory,
private int $cap = 140,
) {}
public function handle(): void
{
error_reporting(E_ALL & ~E_DEPRECATED);
$token = PhpboxUtil::env('PHPBOX_TOKEN') ?: (defined('PHPBOX_TOKEN') ? PHPBOX_TOKEN : 'CHANGE-ME'); // putenv is disabled on some free hosts: config.php also define()s it
if (!hash_equals($token, (string)($_GET['k'] ?? ''))) {
http_response_code(404);
header('Content-Type: text/plain; charset=utf-8');
exit("no\n");
}
$target = trim((string)($this->target)($_GET));
$action = (string)($_GET['a'] ?? '');
if ($action === '') {
$action = $this->wantsPage() ? 'ui' : 'run';
}
if ($action === 'wasm') { $this->serveAsset('share.wasm.gz', 'application/wasm', true); }
if ($action === 'ping') { $this->json($this->ping()); } // what a deploy step asks: is this the node, and can this host run it?
if ($action === 'ui') { $this->servePage($target); }
if ($target === '') {
$this->json(['error' => 'need_target'], 400);
}
$key = substr(sha1($this->carrier . '|' . $target), 0, 12);
$dir = PhpboxUtil::stateDir();
$log = new PhpboxLog("$dir/$key.log");
switch ($action) {
case 'status':
$this->json($this->status($dir, $key));
case 'log':
$this->json($log->tail((int)($_GET['since'] ?? 0)));
case 'stop':
$alive = array_filter(PhpboxState::generations($dir, $key), fn($g) => PhpboxState::alive($g));
if ($alive) {
@touch("$dir/$key.stop"); // every generation sees it; none spawns another
$log->write('info', 'stop requested from the page');
}
$this->json(['ok' => true, 'was_running' => (bool)$alive]);
case 'run':
$this->run($target, $key, $dir, $log);
exit;
default:
$this->json(['error' => 'unknown_action'], 400);
}
}
// ---- run ------------------------------------------------------------
private function run(string $target, string $key, string $dir, PhpboxLog $log): void
{
header('Content-Type: text/plain; charset=utf-8');
header('Cache-Control: no-store');
header('X-Accel-Buffering: no');
if (function_exists('ini_set')) { @ini_set('zlib.output_compression', '0'); }
PhpboxUtil::keepRunning(); // a dropped tab or a proxy 504 must not stop the node (where the host lets us ask)
while (ob_get_level() > 0) { ob_end_flush(); }
$chain = !empty($_GET['chain']);
$succ = !empty($_GET['succ']); // started by the previous generation, not by a person
$from = (int)($_GET['from'] ?? 0);
$cap = $chain ? self::CHAIN_CAP : $this->cap;
if (isset($_GET['cap'])) {
$cap = max(PhpboxUtil::testMode() ? 6 : 30, min(900, (int)$_GET['cap']));
}
$gens = PhpboxState::generations($dir, $key);
$learnedNow = PhpboxBudget::learn($dir, $gens); // a generation before us died early: plan around it
$budget = new PhpboxBudget($cap, $dir);
$cap = $budget->wall;
$spawnAt = $chain ? $budget->spawnAt() : $cap - min(self::MAX_DRAIN, intdiv($cap, 3)); // when this generation starts its successor (or earlier: CPU)
if (!$succ) {
$accepting = array_filter($gens, fn($g) => PhpboxState::accepting($g));
if ($accepting) {
$g = max(array_keys($accepting));
$log->write('info', "another open asked to start the node: already running (gen $g)");
echo "already running\n";
return;
}
@unlink("$dir/$key.stop"); // a person starting it again clears an earlier stop
} elseif (is_file("$dir/$key.stop")) {
$log->write('info', 'successor not started: stop was requested');
echo "stopped\n";
return;
}
$gen = $succ ? $from + 1 : ($gens ? max(array_keys($gens)) : 0) + 1;
if ($succ && isset($gens[$gen]) && PhpboxState::alive($gens[$gen])) {
echo "already running\n"; // a duplicate successor request
return;
}
$lock = @fopen("$dir/$key.g$gen.lock", 'c');
if ($lock && !flock($lock, LOCK_EX | LOCK_NB)) {
echo "already running\n"; // another request holds this generation but has not beaten yet
return;
}
foreach ($gens as $old => $_) { // forget generations long gone
if ($old < $gen - 4) { @unlink("$dir/$key.g$old.json"); @unlink("$dir/$key.g$old.lock"); }
}
$state = new PhpboxState("$dir/$key.g$gen.json");
$started = time();
$sensitive = !empty($_GET['sensitive']);
$s = [
'carrier' => $this->carrier, 'pid' => PhpboxUtil::pid(), 'gen' => $gen, 'phase' => 'connecting',
'started' => $started, 'beat' => $started, 'cap' => $cap, 'elapsed' => 0,
'chain' => $chain, 'spawn_at' => $spawnAt, 'from' => $succ ? $from : null,
'streams' => 0, 'opened' => 0, 'failed' => 0, 'up' => 0, 'down' => 0,
'sensitive' => $sensitive, 'php' => PHP_VERSION, 'version' => self::VERSION,
'cpu' => 0, 'budget' => ['wall' => $budget->wall, 'cpu' => $budget->cpu],
];
$state->write($s);
$log->write('info', "node gen $gen starting on {$this->carrier}" . ($succ ? " (successor of gen $from)" : '')
. ' (phpbox ' . self::VERSION . ', php ' . PHP_VERSION . ')');
if ($learnedNow !== null) { $log->write('warn', "learned: $learnedNow; generations hand over earlier from now on"); }
$log->write('info', 'time budget: ' . $budget->describe());
// Whatever a carrier echoes is also a log line (and still goes to the response).
// The response goes nowhere useful: a successor's request was closed by the generation that started it, and
// a person's tab or a proxy's 504 closes the first one. On hosts where ignore_user_abort does not hold, PHP
// stops the script at its next write to a closed connection - which silently ended every successor right
// after "joined". So a successor writes nothing, and a first run only its opening lines.
$quietAt = time() + 3;
ob_start(function (string $buf) use ($log, $succ, $quietAt) {
foreach (preg_split('/\R/', $buf) as $l) {
if (trim($l) !== '') { $log->write('info', trim($l)); }
}
return ($succ || time() > $quietAt) ? '' : $buf;
}, 1);
$ownMine = "$dir/$key.own.$gen"; // markers for streams while a successor is coming up
$ownPrev = "$dir/$key.own.$from"; // ... and while we are the successor
$rmOwn = function (string $d) {
foreach (glob("$d/*") ?: [] as $f) { @rmdir($f); }
@rmdir($d);
};
$ended = false;
$finish = function (string $why, string $lvl = 'info') use (&$s, $state, $log, &$ended, $rmOwn, $ownMine, $budget) {
if ($ended) { return; }
$ended = true;
$s['phase'] = 'idle';
$s['ended'] = time();
$s['elapsed'] = time() - $s['started'];
$s['cpu'] = $budget->cpuUsed();
$s['reason'] = $why;
$state->write($s);
$rmOwn($ownMine);
$log->write($lvl, "node gen {$s['gen']} ended: $why");
};
register_shutdown_function(function () use (&$finish) { // CPU limit, fatal error
$e = error_get_last();
$finish($e ? 'died: ' . $e['message'] : 'process ended', $e ? 'error' : 'info');
});
$carrier = ($this->factory)($target);
if (!$carrier->connect()) {
$finish('could not join (see the lines above)', 'error');
echo "connect failed\n";
return;
}
$s['phase'] = $succ ? 'connecting' : 'serving'; // a successor serves once its link has settled (onTick)
$s['beat'] = time();
if (method_exists($carrier, 'member')) { $s['member'] = $carrier->member(); } // the others ignore what we send
$state->write($s);
$log->write('info', "joined; serving" . ($chain ? ", handing over to a successor at {$spawnAt}s" : '') . " up to {$cap}s"
. ($sensitive ? ' (destinations are logged)' : ' (destinations are hidden: add &sensitive=1 to log them)'));
echo "{$this->carrier} exit ready; serving up to {$cap}s\n";
$mux = new Mux($carrier);
$mux->accepting = !$succ;
$settled = !$succ; $upSince = time(); $seenReconnects = 0;
$mux->sensitive = $sensitive;
if (isset($_GET['win'])) { $w = max(0, min(4194304, (int)$_GET['win'])); $mux->streamWindow = $w; $mux->totalWindow = $w * 2; } // 0 = no flow control
if (isset($_GET['idle'])) { $mux->idleTimeout = max(0, min(3600, (int)$_GET['idle'])); }
if (isset($_GET['chunk'])) { $mux->readChunk = max(2048, min(262144, (int)$_GET['chunk'])); }
$mux->log = fn(string $lvl, string $m) => $log->write($lvl, $m);
// Handover bookkeeping (chain mode).
$asSucc = $succ; // the previous generation may still take the same OPENs
$asPred = false; // we spawned a successor that may take them too
$spawned = false; $lastSpawn = 0; $spawnTries = 0; $frozen = false; $endWhy = '';
$predState = $succ ? new PhpboxState("$dir/$key.g$from.json") : null;
$succState = new PhpboxState("$dir/$key." . 'g' . ($gen + 1) . '.json');
if ($asSucc) { @mkdir($ownPrev, 0700, true); }
$mux->claim = function (int $sid) use (&$asSucc, &$asPred, $ownPrev, $ownMine): bool {
// mkdir is atomic: the first generation to create the marker owns the stream. Recursive, because the
// previous generation removes its marker directory when it ends, up to a second before we notice.
if ($asSucc && !@mkdir("$ownPrev/$sid", 0700, true)) { return false; }
if ($asPred && !@mkdir("$ownMine/$sid", 0700, true)) { return false; }
return true;
};
$mux->onTick = function (Mux $m) use (&$s, $state, $started, $dir, $key, $chain, $spawnAt, $target, $cap, $sensitive, $gen,
$log, &$asSucc, &$asPred, &$spawned, &$lastSpawn, &$spawnTries, &$frozen, &$endWhy,
$predState, $succState, $ownMine, $budget, $carrier, &$settled, &$upSince, &$seenReconnects) {
$s['beat'] = time();
$s['elapsed'] = time() - $started;
$s['streams'] = $m->activeStreams();
$s['opened'] = $m->stats['opened'];
$s['failed'] = $m->stats['failed'];
$s['up'] = $m->stats['up'];
$s['down'] = $m->stats['down'];
$s['cpu'] = $budget->cpuUsed();
if (method_exists($carrier, 'member')) { $s['member'] = $carrier->member(); } // a reconnect joins anew
$state->write($s);
if (method_exists($carrier, 'ignoreUsers') && $s['elapsed'] % 2 === 0) {
$others = []; // the other generations in this room: not our client
foreach (PhpboxState::generations($dir, $key) as $g => $o) {
if ($g !== $gen && !empty($o['member'])) { $others[] = $o['member']; }
}
$carrier->ignoreUsers($others);
}
if (!$settled) {
$rc = $m->stats['reconnects'] ?? 0;
if ($rc !== $seenReconnects && !PhpboxUtil::testMode()) {
$seenReconnects = $rc;
$upSince = time(); // the link dropped and joined again: start counting anew
}
if (time() - $upSince >= (PhpboxUtil::testMode() ? 1 : self::SETTLE)) {
$settled = true;
$m->accepting = true;
$s['phase'] = 'serving';
$state->write($s);
$log->write('info', 'link steady: taking new streams');
}
}
if (file_exists("$dir/$key.stop")) { // left in place: every generation and any spawn sees it
$endWhy = 'stopped';
return false;
}
if ($asSucc && !PhpboxState::accepting($predState->read())) {
$asSucc = false; // the previous generation no longer takes streams: no contest
$log->write('debug', 'previous generation stopped taking new streams');
}
if ($budget->cpuShare() >= 0.85) { // end on our terms, not the host's: clients get CLOSE now
$endWhy = 'cpu';
return false;
}
if ($chain && !$frozen) {
$due = $s['elapsed'] >= $spawnAt || $budget->cpuShare() >= 0.55;
if ($due && time() - $lastSpawn >= 15) {
$lastSpawn = time();
@mkdir($ownMine, 0700, true);
$asPred = true; // from now on new streams are claimed, not assumed
if ($this->spawn($target, $gen, $cap, $sensitive, $log, $spawnTries++)) {
$spawned = true;
$log->write('info', "started gen " . ($gen + 1) . ' to take over');
} else {
$log->write('warn', 'could not start the successor; retrying');
}
}
if ($spawned) {
$n = $succState->read();
if (PhpboxState::alive($n) && ($n['phase'] ?? '') === 'serving') {
$m->accepting = false; // the successor takes every new stream from here
$frozen = true;
$asPred = false;
$s['phase'] = 'draining';
$state->write($s);
$log->write('info', 'handed over to gen ' . ($gen + 1) . "; draining {$m->activeStreams()} stream(s)");
}
}
}
// While a successor is in the room, go easy on it: it receives everything we send (see Mux::$readRate).
$m->readRate = ($spawned || $frozen) ? self::OVERLAP_RATE : 0;
if ($frozen && $m->activeStreams() === 0) {
$endWhy = 'handed';
return false;
}
return true;
};
$why = $mux->run($cap);
if ($endWhy === 'stopped') { $finish('stopped from the page'); }
elseif ($endWhy === 'handed') { $finish('handed over to gen ' . ($gen + 1) . ' (its streams ended)'); }
elseif ($endWhy === 'cpu' && $frozen) { $finish('handed over to gen ' . ($gen + 1) . "; the CPU budget ({$budget->cpu}s) cut the streams still open"); }
elseif ($endWhy === 'cpu' && $chain) { $finish("chain broken: the CPU budget ({$budget->cpu}s) ran out before a successor was serving", 'error'); }
elseif ($endWhy === 'cpu') { $finish("ended before the host's CPU limit ({$budget->cpu}s)"); }
elseif ($frozen) { $finish('handed over to gen ' . ($gen + 1) . "; {$cap}s cap cut the streams still open"); }
elseif ($chain) { $finish("chain broken: no successor was serving before the {$cap}s cap", 'error'); }
else { $finish("reached the {$cap}s cap"); }
echo "exit done\n";
}
/** Start the next generation: a request to our own host, which the host's bot check lets through. */
private function spawn(string $target, int $gen, int $cap, bool $sensitive, PhpboxLog $log, int $attempt = 0): bool
{
$hostport = (string)($_SERVER['HTTP_HOST'] ?? '');
if ($hostport === '') { return false; }
[$h, $p] = array_pad(explode(':', $hostport, 2), 2, null);
$tls = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off')
|| ($_SERVER['HTTP_X_FORWARDED_PROTO'] ?? '') === 'https' || ($_SERVER['SERVER_PORT'] ?? '') === '443';
if ($attempt % 2 === 1) { $tls = !$tls; } // a retry tries the other scheme (hosts that redirect http to https)
$port = (int)($p ?: ($tls ? 443 : 80));
$path = (string)strtok((string)($_SERVER['REQUEST_URI'] ?? '/'), '?');
$qs = http_build_query([
'k' => (string)$_GET['k'], 'a' => 'run', 'url' => $target, 'succ' => 1, 'from' => $gen,
'chain' => 1, 'cap' => $cap, 'sensitive' => $sensitive ? 1 : 0,
]);
$fp = @fsockopen(($tls ? 'ssl://' : '') . $h, $port, $en, $es, 10);
if (!$fp) {
$log->write('warn', "self-request to " . ($tls ? 'https' : 'http') . "://$h:$port failed: $en $es");
return false;
}
fwrite($fp, "GET $path?$qs HTTP/1.1\r\nHost: $hostport\r\nUser-Agent: phpbox-chain\r\nAccept: text/plain\r\nConnection: close\r\n\r\n");
// A running successor stays silent, so no answer is what success looks like. An answer within a moment is a
// redirect, the host's bot check or an error page - say so instead of waiting for a generation that never comes.
$r = [$fp]; $w = $e = null;
if (@stream_select($r, $w, $e, 0, 300000)) {
$head = (string)@fread($fp, 1024);
$line = strtok($head, "\r\n");
$ok = preg_match('#^HTTP/\S+ 200#', (string)$line) && stripos($head, 'slowAES') === false;
if (!$ok) {
$log->write('warn', 'self-request to ' . ($tls ? 'https' : 'http') . "://$h:$port was answered: "
. ($line !== false && $line !== '' ? $line : 'nothing usable') . (stripos($head, 'slowAES') !== false ? ' (the host\'s browser check)' : ''));
fclose($fp);
return false;
}
}
fclose($fp); // the successor outlives this socket and writes nothing to it
return true;
}
/** CPU seconds this process has used (user + system): what a host's max_execution_time counts. */
public static function cpuSeconds(): float
{
if (!function_exists('getrusage')) { return 0.0; }
$u = @getrusage();
if (!is_array($u)) { return 0.0; }
return round(($u['ru_utime.tv_sec'] ?? 0) + ($u['ru_utime.tv_usec'] ?? 0) / 1e6
+ ($u['ru_stime.tv_sec'] ?? 0) + ($u['ru_stime.tv_usec'] ?? 0) / 1e6, 2);
}
// ---- ping -----------------------------------------------------------
/** Facts about this host for the installer: what the node needs, and whether the host has it. */
private function ping(): array
{
$dir = PhpboxUtil::stateDir();
$needs = [
'fsockopen' => function_exists('fsockopen'),
'stream_select' => function_exists('stream_select'),
'stream_socket_client' => function_exists('stream_socket_client'),
'usleep' => function_exists('usleep'),
'openssl' => extension_loaded('openssl'),
'json' => function_exists('json_encode'),
];
return [
'phpbox' => self::VERSION,
'carrier' => $this->carrier,
'php' => PHP_VERSION,
'sapi' => PHP_SAPI,
'needs' => $needs,
'missing' => array_keys(array_filter($needs, fn($ok) => !$ok)),
// The node runs without these, but the host has taken them away: worth knowing when it misbehaves.
'disabled' => array_values(array_filter(['ignore_user_abort', 'set_time_limit', 'getrusage', 'getmypid', 'getenv', 'ini_set'],
fn($f) => !function_exists($f))),
'cpu_limit' => function_exists('ini_get') ? (int)ini_get('max_execution_time') : null,
'state_dir' => is_dir($dir) && is_writable($dir),
'parser' => is_file(dirname(__DIR__) . '/assets/share.wasm.gz'),
'time' => time(),
];
}
// ---- status ---------------------------------------------------------
private function status(string $dir, string $key): array
{
$gens = PhpboxState::generations($dir, $key);
$alive = array_filter($gens, fn($g) => PhpboxState::alive($g));
$accepting = array_filter($alive, fn($g) => PhpboxState::accepting($g));
// The state shown: the generation taking streams, else one that is winding down, else the last one that ran.
$serving = array_filter($accepting, fn($g) => ($g['phase'] ?? '') === 'serving');
$cur = $serving ? $serving[max(array_keys($serving))]
: ($accepting ? $accepting[max(array_keys($accepting))]
: ($alive ? $alive[max(array_keys($alive))] : ($gens ? $gens[max(array_keys($gens))] : [])));
if (PhpboxState::alive($cur)) {
$cur['elapsed'] = max((int)($cur['elapsed'] ?? 0), time() - (int)$cur['started']);
}
foreach (['streams', 'opened', 'failed', 'up', 'down'] as $f) { // totals across overlapping generations
$cur[$f] = array_sum(array_map(fn($g) => (int)($g[$f] ?? 0), $alive ?: [$cur]));
}
$draining = count($alive) - count($accepting);
return [
'running' => (bool)$accepting,
'draining' => $draining,
'chain' => !empty($cur['chain']),
'next_in' => ($accepting && !empty($cur['chain'])) ? max(0, (int)$cur['spawn_at'] - (int)$cur['elapsed']) : null,
'stopping' => $alive && is_file("$dir/$key.stop"),
'state' => $cur,
'age' => isset($cur['beat']) ? time() - (int)$cur['beat'] : null,
'now' => time(),
];
}
// ---- page -----------------------------------------------------------
/** A browser navigating here wants the page; a pinger or curl wants the node to run. */
private function wantsPage(): bool
{
if (!empty($_GET['headless'])) { return false; }
$accept = (string)($_SERVER['HTTP_ACCEPT'] ?? '');
$mode = (string)($_SERVER['HTTP_SEC_FETCH_MODE'] ?? '');
if ($mode !== '' && $mode !== 'navigate') { return false; }
return stripos($accept, 'text/html') !== false;
}
private function servePage(string $target): void
{
require_once __DIR__ . '/ui.php';
$assets = dirname(__DIR__) . '/assets';
header('Content-Type: text/html; charset=utf-8');
header('Cache-Control: no-store');
header('X-Robots-Tag: noindex');
$self = strtok((string)($_SERVER['REQUEST_URI'] ?? ''), '?');
echo PhpboxUi::render([
'carrier' => $this->carrier,
'title' => $this->title,
'target' => $target,
'k' => (string)$_GET['k'],
'self' => $self,
'auto' => !isset($_GET['auto']) || $_GET['auto'] !== '0',
'sensitive' => !empty($_GET['sensitive']),
'cap' => $this->cap,
'version' => self::VERSION,
'php' => PHP_VERSION,
'hasWasm' => is_file("$assets/share.wasm.gz"),
'wasmExec' => (string)@file_get_contents("$assets/wasm_exec.js"),
]);
exit;
}
private function serveAsset(string $name, string $type, bool $gzip): void
{
$f = dirname(__DIR__) . '/assets/' . $name;
if (!is_file($f)) {
http_response_code(404);
exit;
}
header('Content-Type: ' . $type);
if ($gzip) { header('Content-Encoding: gzip'); }
header('Cache-Control: private, max-age=86400');
header('Content-Length: ' . filesize($f));
readfile($f);
exit;
}
private function json(array $o, int $code = 200): void
{
http_response_code($code);
header('Content-Type: application/json; charset=utf-8');
header('Cache-Control: no-store');
echo json_encode($o, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
exit;
}
}