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

214 lines
7.5 KiB
PHP

<?php
// Minimal WebSocket client (RFC 6455) shared by the phpbox carriers. It does
// the HTTP upgrade, masks client frames, and auto-pongs WS-level pings; the
// app-level protocol (Centrifuge, Engine.IO) lives in each carrier.
require_once __DIR__ . '/util.php';
final class WsClient
{
/** @var resource|null */
public $sock = null;
/** connect performs the upgrade to wsURL (ws:// or wss://). */
public function connect(string $wsURL, string $origin, string $cookieHeader): bool
{
$this->closeInfo = '';
$p = parse_url($wsURL);
$secure = ($p['scheme'] ?? 'wss') === 'wss';
$host = $p['host'];
$port = $p['port'] ?? ($secure ? 443 : 80);
$path = ($p['path'] ?? '/') . (isset($p['query']) ? '?' . $p['query'] : '');
$s = @stream_socket_client(($secure ? 'ssl' : 'tcp') . "://$host:$port", $e, $es, 12, STREAM_CLIENT_CONNECT);
if (!$s) {
return false;
}
$key = base64_encode(random_bytes(16));
$req = "GET $path HTTP/1.1\r\nHost: $host\r\nUpgrade: websocket\r\nConnection: Upgrade\r\n"
. "Sec-WebSocket-Key: $key\r\nSec-WebSocket-Version: 13\r\nUser-Agent: " . PhpboxUtil::UA . "\r\n"
. "Origin: $origin\r\n"
. ($cookieHeader !== '' ? "Cookie: $cookieHeader\r\n" : '')
. "\r\n";
fwrite($s, $req);
$resp = '';
while (!feof($s)) {
$resp .= fgets($s);
if (str_contains($resp, "\r\n\r\n")) {
break;
}
}
if (!str_contains($resp, ' 101 ')) {
return false;
}
$this->sock = $s;
$this->closed = false;
$this->rx = $this->frag = '';
$this->q = [];
return true;
}
public function writeText(string $payload): void
{
$this->writeFrame(0x1, $payload);
}
private function writeFrame(int $opcode, string $payload): void
{
if (!$this->sock) {
return;
}
$len = strlen($payload);
$frame = chr(0x80 | $opcode);
if ($len < 126) {
$frame .= chr(0x80 | $len);
} elseif ($len < 65536) {
$frame .= chr(0x80 | 126) . pack('n', $len);
} else {
$frame .= chr(0x80 | 127) . pack('J', $len);
}
$mask = random_bytes(4);
// PHP XORs strings byte by byte up to the shorter one: no per-byte loop.
$frame .= $mask . ($payload ^ str_repeat($mask, intdiv($len, 4) + 1));
@fwrite($this->sock, $frame);
}
// ---- reading ---------------------------------------------------------
// Bytes are buffered and whole frames parsed out of the buffer, so a frame that
// arrives in pieces (a 22 KB message on a slow link) is never cut short, which
// used to desynchronise the stream and break TLS inside the tunnel. Fragmented
// messages are reassembled. "Closed" is a state ($closed), not a timeout.
/** True once the server closed the socket, sent a close frame, or the link broke. */
public bool $closed = false;
private string $rx = '';
private array $q = []; // complete messages not yet handed out
private string $frag = ''; // a fragmented message being assembled
/** More is ready without waiting on the network: a parsed message, or bytes already inside PHP's stream buffer
* (stream_select cannot see those, so a reader must keep reading while this is true). */
public function pending(): bool
{
if ($this->q) {
return true;
}
if (!$this->sock) {
return false;
}
$m = stream_get_meta_data($this->sock);
return ($m['unread_bytes'] ?? 0) > 0;
}
/**
* readFrame returns the next message: its text; '' when bytes were consumed but no whole message is
* ready yet (or a control frame was handled); null when nothing came within $timeout or the link is
* closed (check $closed).
*/
public function readFrame(float $timeout): ?string
{
if ($this->q) {
return array_shift($this->q);
}
if (!$this->sock) {
return null;
}
if (!$this->pending()) {
$r = [$this->sock]; $w = $e = null;
if (!@stream_select($r, $w, $e, (int)$timeout, (int)(($timeout - (int)$timeout) * 1e6))) {
return null;
}
}
$chunk = @fread($this->sock, 65536);
if ($chunk === false || ($chunk === '' && feof($this->sock))) {
$this->markClosed();
return null;
}
if ($chunk === '') {
return null;
}
$this->rx .= $chunk;
$this->parse();
if ($this->q) {
return array_shift($this->q);
}
return $this->closed ? null : '';
}
private function parse(): void
{
while (strlen($this->rx) >= 2) {
$b0 = ord($this->rx[0]);
$b1 = ord($this->rx[1]);
$len = $b1 & 0x7f;
$off = 2;
if ($len === 126) {
if (strlen($this->rx) < 4) { return; }
$len = unpack('n', substr($this->rx, 2, 2))[1];
$off = 4;
} elseif ($len === 127) {
if (strlen($this->rx) < 10) { return; }
$len = unpack('J', substr($this->rx, 2, 8))[1];
$off = 10;
}
$masked = ($b1 & 0x80) !== 0;
if ($masked) { $off += 4; }
if ($len > 67108864) { // 64 MB: not a real message
$this->markClosed();
return;
}
if (strlen($this->rx) < $off + $len) {
return; // the rest of the frame is still on its way
}
$data = substr($this->rx, $off, $len);
if ($masked) {
$mask = substr($this->rx, $off - 4, 4);
$data = $data ^ str_repeat($mask, intdiv($len, 4) + 1);
}
$this->rx = substr($this->rx, $off + $len);
$opcode = $b0 & 0x0f;
$fin = ($b0 & 0x80) !== 0;
if ($opcode === 0x8) { // close: keep the server's code and reason for the log
$this->closeInfo = strlen($data) >= 2 ? 'close ' . unpack('n', $data)[1] . (strlen($data) > 2 ? ' ' . substr($data, 2) : '') : 'close (no code)';
$this->markClosed();
return;
}
if ($opcode === 0x9) { // ping -> pong
$this->writeFrame(0xA, $data);
continue;
}
if ($opcode === 0xA) { // pong
continue;
}
if ($opcode !== 0x0) { // text/binary starts a message
$this->frag = '';
}
$this->frag .= $data;
if ($fin) {
$this->q[] = $this->frag;
$this->frag = '';
}
}
}
/** Why the link ended, as far as the server said: a close frame's code and reason, or how the socket ended. */
public string $closeInfo = '';
private function markClosed(): void
{
if ($this->closeInfo === '') { $this->closeInfo = 'socket ended without a close frame'; }
$this->closed = true;
$this->close();
}
public function close(): void
{
if ($this->sock) {
@fclose($this->sock);
$this->sock = null;
}
$this->rx = '';
$this->frag = '';
}
}