mirror of
https://github.com/p1neappleXpress/OpenFlux.git
synced 2026-10-20 06:11:02 +08:00
* 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>
214 lines
7.5 KiB
PHP
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 = '';
|
|
}
|
|
}
|