Files
OpenFlux/deploy/phpbox/test/mux_test.php
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

233 lines
13 KiB
PHP

<?php
// Mux self-test over a real local TCP echo server (no network, no hosting):
// php test/mux_test.php
// Covers async dial, OPEN_ERR, id reuse, ordered echo, and the write queue
// (2 MB into a server that does not read for 2 s used to lose data).
putenv('PHPBOX_ALLOW_PRIVATE=1');
require __DIR__ . '/../lib/mux.php';
final class MemCarrier implements Carrier
{
public array $out = []; // frames the mux sent: [type, sid, payload]
private Mux $m;
public function setMux(Mux $m): void { $this->m = $m; }
public function connect(): bool { return true; }
public function sockets(): array { return []; }
public function onReadable($sock): void {}
public array $dataBytes = []; // sid => DATA bytes seen (payloads are kept only up to a cap)
private int $stored = 0;
public function sendPacket(string $f): void
{
$h = unpack('Ctype/Nsid/Nlen', substr($f, 0, 9));
if ($h['type'] === Mux::DATA) {
$this->dataBytes[$h['sid']] = ($this->dataBytes[$h['sid']] ?? 0) + $h['len'];
if ($this->stored > 8 * 1024 * 1024) { return; } // a flood in a test must not eat the memory
$this->stored += $h['len'];
}
$this->out[] = [$h['type'], $h['sid'], substr($f, 9, $h['len'])];
}
public function feed(int $type, int $sid, string $payload): void
{
$this->m->onPacket(pack('CNN', $type, $sid, strlen($payload)) . $payload);
}
public function take(int $type, int $sid): string
{
$b = '';
foreach ($this->out as $o) { if ($o[0] === $type && $o[1] === $sid) { $b .= $o[2]; } }
return $b;
}
public function has(int $type, int $sid): bool
{
foreach ($this->out as $o) { if ($o[0] === $type && $o[1] === $sid) { return true; } }
return false;
}
}
function server(int $port, int $delay)
{
$p = proc_open([PHP_BINARY, __DIR__ . '/echoserver.php', (string)$port, (string)$delay], [1 => ['pipe', 'w'], 2 => ['file', '/dev/null', 'w']], $pipes);
fgets($pipes[1]); // "ready"
return $p;
}
$fast = random_int(41000, 41999); $slow = $fast + 1000; $dead = $fast + 2000; $alt = $fast + 3000;
$s1 = server($fast, 0); $s2 = server($slow, 2); $s3 = server($alt, 0); // the echo server serves one connection at a time
$c = new MemCarrier();
$mux = new Mux($c);
$logs = [];
$mux->log = function ($l, $m) use (&$logs) { $logs[] = "$l $m"; };
$mux->resolver = fn(string $h) => $h === 'multi.test' ? ['::1', '127.0.0.1'] : [$h];
$big = random_bytes(2 * 1024 * 1024);
$ok = true;
$check = function (string $name, bool $pass, string $why = "") use (&$ok) { printf("%-46s %s\n", $name, $pass ? "OK" : "FAIL $why"); $ok = $ok && $pass; };
$n = 0;
$mux->onTick = function (Mux $m) use (&$n, $c, $fast, $slow, $dead, $alt, $big, $check) {
$n++;
if ($n === 1) {
$c->feed(Mux::OPEN, 1, "127.0.0.1:$fast");
$c->feed(Mux::OPEN, 2, "127.0.0.1:$dead"); // nothing listens there
$c->feed(Mux::OPEN, 3, "127.0.0.1:$slow");
$c->feed(Mux::OPEN, 6, "multi.test:$alt"); // its first address refuses, the second works
$c->feed(Mux::DATA, 1, 'hel'); // DATA right behind OPEN, before the dial completes
$c->feed(Mux::DATA, 1, 'lo');
} elseif ($n === 2) {
$check('async dial: OPEN_OK for a live server', $c->has(Mux::OPEN_OK, 1));
$check('async dial: OPEN_ERR for a dead port', $c->has(Mux::OPEN_ERR, 2));
$check('data sent during the dial arrives in order', $c->take(Mux::DATA, 1) === 'hello');
$check('a slow dial does not block the others', $c->has(Mux::OPEN_OK, 3));
$check('a refused address falls back to the next one', $c->has(Mux::OPEN_OK, 6) && !$c->has(Mux::OPEN_ERR, 6));
foreach (str_split($big, 60000) as $part) { $c->feed(Mux::DATA, 3, $part); } // 2 MB at a server that is not reading
$c->feed(Mux::CLOSE, 1, '');
$c->feed(Mux::OPEN, 1, "127.0.0.1:$fast"); // the id is reused
} elseif ($n === 3) {
$c->feed(Mux::DATA, 1, 'again');
} elseif ($n === 4) {
$check('reused stream id works', str_ends_with($c->take(Mux::DATA, 1), 'again'));
} elseif ($n >= 9) {
return false;
}
return true;
};
$why = $mux->run(30);
$echoed = $c->take(Mux::DATA, 3);
$check('write queue: 2 MB echoed back intact', strlen($echoed) === strlen($big) && md5($echoed) === md5($big));
$check('run stops when a tick says so', $why === 'stopped');
$check('stats count both directions', $mux->stats['up'] >= strlen($big) && $mux->stats['down'] >= strlen($big));
$check('stats count the failed open (only the dead port)', $mux->stats['failed'] === 1);
$check('destinations are hidden from the log by default', !preg_grep('/127\.0\.0\.1/', $logs));
$check('ending a run tells the client about streams still open', $c->has(Mux::CLOSE, 3) && $c->has(Mux::CLOSE, 1));
// ---- idle streams are reaped (a lost CLOSE must not leak a socket) ----
$ci = new MemCarrier(); $mi = new Mux($ci); $mi->idleTimeout = 2;
$ticks = 0;
$mi->onTick = function (Mux $m) use (&$ticks, $ci, $fast) {
$ticks++;
if ($ticks === 1) { $ci->feed(Mux::OPEN, 1, "127.0.0.1:$fast"); }
return $ticks < 6;
};
$mi->run(20);
$check('an idle stream is closed and the client told', $ci->has(Mux::CLOSE, 1));
// ---- handover rules (two generations read the same OPEN) ----
$open = fn(Mux $m, MemCarrier $cc, int $sid) => $cc->feed(Mux::OPEN, $sid, "127.0.0.1:$fast");
$cd = new MemCarrier(); $md = new Mux($cd); $md->accepting = false; $open($md, $cd, 9);
$check('a draining generation ignores new streams', $md->activeStreams() === 0 && !$cd->out);
$cn = new MemCarrier(); $mn = new Mux($cn); $mn->claim = fn(int $sid) => false; $open($mn, $cn, 9);
$check('a stream claimed by the other generation is not dialed', $mn->activeStreams() === 0);
$cy = new MemCarrier(); $my = new Mux($cy); $my->claim = fn(int $sid) => true; $open($my, $cy, 9);
$check('a stream this generation claims is dialed', $my->activeStreams() === 1);
$dirClaim = sys_get_temp_dir() . '/mux-claim-' . getmypid(); @mkdir($dirClaim);
$claimFn = fn(int $sid) => @mkdir("$dirClaim/$sid"); // what node.php does: mkdir is atomic
$ca = new MemCarrier(); $ma = new Mux($ca); $ma->claim = $claimFn;
$cb = new MemCarrier(); $mb = new Mux($cb); $mb->claim = $claimFn;
foreach ([1, 2, 3, 4, 5] as $sid) { $open($ma, $ca, $sid); $open($mb, $cb, $sid); }
$check('two generations never both serve a stream', $ma->activeStreams() + $mb->activeStreams() === 5);
foreach (glob("$dirClaim/*") as $f) { @rmdir($f); } @rmdir($dirClaim);
// ---- flow control: a client that asks for windows gets them; one that does not, does not ----
function sendserver(int $port) {
$p = proc_open([PHP_BINARY, __DIR__ . '/sendserver.php', (string)$port], [1 => ['pipe', 'w'], 2 => ['file', '/dev/null', 'w']], $pipes);
fgets($pipes[1]);
return $p;
}
$bulkPort = $fast + 4000; $bulk2 = $fast + 5000;
$sb1 = sendserver($bulkPort); $sb2 = sendserver($bulk2);
$fcRun = function (string $caps, int $port, callable $script) {
$c = new MemCarrier(); $m = new Mux($c); $m->resolver = fn(string $h) => [$h];
$n = 0;
$m->onTick = function (Mux $mm) use (&$n, $c, $port, $caps, $script) { $n++; if ($n === 1) { $c->feed(Mux::OPEN, 1, "127.0.0.1:$port" . $caps); } return $script($n, $c, $mm); };
$m->run(30);
return $c;
};
$got = fn(MemCarrier $c) => $c->dataBytes[1] ?? 0;
$okPayload = fn(MemCarrier $c) => (function () use ($c) { foreach ($c->out as $o) { if ($o[0] === Mux::OPEN_OK && $o[1] === 1) { return $o[2]; } } return null; })();
$noAck = $fcRun("\0fc", $bulkPort, fn($n, $c, $mm) => $n < 4);
$check('OPEN with the fc suffix is answered OPEN_OK "fc"', $okPayload($noAck) === 'fc');
$check('without acks the exit stops at about one window', $got($noAck) >= 262144 && $got($noAck) <= 262144 + 2 * 65536);
$acked = $fcRun("\0fc", $bulkPort, function ($n, $c, $mm) {
if ($n >= 2 && $n <= 6) { // the client consumes what it got, and says so
$have = $c->dataBytes[1] ?? 0;
$c->feed(Mux::ACK, 1, pack('N', $have & 0xFFFFFFFF));
}
return $n < 7;
});
$check('acks let the stream run past a window', $got($acked) > 2 * 262144);
$old = $fcRun('', $bulkPort + 1000, fn($n, $c, $mm) => $n < 4);
$check('a client that did not ask gets OPEN_OK with no caps', $okPayload($old) === '');
$check('and no windows: data flows far past one window without acks', $got($old) > 3 * 262144);
// the exit acknowledges what it writes to destinations (running total, so a lost ack is repaired by the next)
$upAck = $fcRun("\0fc", $fast, function ($n, $c, $mm) {
if ($n === 2) { foreach (str_split(str_repeat('u', 100000), 25000) as $part) { $c->feed(Mux::DATA, 1, $part); } }
return $n < 5;
});
$total = 0; foreach ($upAck->out as $o) { if ($o[0] === Mux::ACK && $o[1] === 1) { $total = unpack('N', $o[2])[1]; } }
$check('the exit acks the bytes it wrote (running total = 100000)', $total === 100000);
// ---- a paced carrier (cups): the mux keeps the pace, messages hold whole packets, control first, streams take turns ----
final class PacedMem implements PacedCarrier
{
public array $msgs = []; // [time, bytes]
public array $frames = []; // [time, type, sid, payload]
private Mux $m;
public function setMux(Mux $m): void { $this->m = $m; }
public function connect(): bool { return true; }
public function sockets(): array { return []; }
public function onReadable($sock): void {}
public function sendPacket(string $f): void { throw new LogicException('a paced carrier is sent whole messages'); }
public function msgCap(): int { return 3000; }
public function interval(): float { return 0.01; }
public function rate(): int { return 0; }
public function unconfirmed(): int { return 0; }
public function confirmAll(): void {}
public function sendPackets(string $p): void
{
$t = microtime(true);
$this->msgs[] = [$t, $p];
for ($o = 0; $o < strlen($p);) { // must be whole packets, back to back, and nothing else
$ln = unpack('n', substr($p, $o, 2))[1];
$f = substr($p, $o + 2, $ln);
if (strlen($f) !== $ln || $ln < 9) { $this->frames[] = [$t, -1, 0, '']; return; }
$h = unpack('Ctype/Nsid/Nlen', $f);
$this->frames[] = [$t, $h['type'], $h['sid'], substr($f, 9)];
$o += 2 + $ln;
}
}
public function feed(int $type, int $sid, string $payload): void { $this->m->onPacket(pack('CNN', $type, $sid, strlen($payload)) . $payload); }
}
$echoP = $fast + 6000; $s4 = server($echoP, 0);
$pc = new PacedMem(); $pm = new Mux($pc); $pm->resolver = fn(string $h) => [$h];
$n = 0; $tOpen3 = 0.0;
$pm->onTick = function (Mux $mm) use (&$n, $pc, $bulkPort, $bulk2, $echoP, &$tOpen3) {
$n++;
if ($n === 1) { $pc->feed(Mux::OPEN, 1, "127.0.0.1:$bulkPort"); $pc->feed(Mux::OPEN, 2, "127.0.0.1:$bulk2"); }
if ($n === 3) { $tOpen3 = microtime(true); $pc->feed(Mux::OPEN, 3, "127.0.0.1:$echoP"); $pc->feed(Mux::DATA, 3, 'ping-through-a-busy-carrier'); }
return $n < 6;
};
$pm->run(30);
$whole = !array_filter($pc->frames, fn($f) => $f[1] === -1);
$check('paced: every message is whole packets and fits the cap', $whole && !array_filter($pc->msgs, fn($m) => strlen($m[1]) > 3000));
$gaps = []; for ($i = 1; $i < count($pc->msgs); $i++) { $gaps[] = $pc->msgs[$i][0] - $pc->msgs[$i - 1][0]; }
$check('paced: never faster than the interval', $gaps && min($gaps) >= 0.0095);
$check('paced: the carrier is kept busy (no sleeping in the loop)', count($pc->msgs) > 250);
$ok3 = null; $echo3 = ''; $tEcho = null;
foreach ($pc->frames as [$t, $type, $sid, $pl]) {
if ($sid !== 3) { continue; }
if ($type === Mux::OPEN_OK && $ok3 === null) { $ok3 = $t; }
if ($type === Mux::DATA) { $echo3 .= $pl; if ($tEcho === null && str_contains($echo3, 'ping-through')) { $tEcho = $t; } }
}
$check('paced: a new stream gets OPEN_OK at once while two streams saturate the carrier', $ok3 !== null && $ok3 - $tOpen3 < 0.3);
$check('paced: and its first answer within a few messages', $tEcho !== null && $tEcho - $tOpen3 < 0.5 && $echo3 === 'ping-through-a-busy-carrier', sprintf('echo=%s after=%s ok3=%s', var_export($echo3, true), $tEcho === null ? 'never' : round($tEcho - $tOpen3, 3), $ok3 === null ? 'none' : round($ok3 - $tOpen3, 3)));
$per = [1 => 0, 2 => 0]; foreach ($pc->frames as $f) { if ($f[1] === Mux::DATA && isset($per[$f[2]])) { $per[$f[2]] += strlen($f[3]); } }
$check('paced: busy streams share the carrier', min($per) > 0.3 * max($per));
proc_terminate($s4);
foreach ([$sb1, $sb2] as $p) { proc_terminate($p); }
foreach ([$s1, $s2, $s3] as $p) { proc_terminate($p); }
echo $ok ? "MUX TEST PASS\n" : "MUX TEST FAIL\n" . implode("\n", array_slice($logs, -15)) . "\n";
exit($ok ? 0 : 1);