Files
MobileGL/scripts/ci/tcp_supervisor_smoke.py

1120 lines
64 KiB
Python

#!/usr/bin/env python3
# MobileGL - scripts/ci/tcp_supervisor_smoke.py
# Copyright (c) 2025-2026 MobileGL-Dev
# Licensed under the GNU Lesser General Public License v3.0:
# https://www.gnu.org/licenses/gpl-3.0.txt
# https://www.gnu.org/licenses/lgpl-3.0.txt
# SPDX-License-Identifier: LGPL-3.0-only
# End of Source File Header
"""Exercise a real TCP supervisor's LISTEN, Busy, token, version, build-policy and REAP controls.
P7 wave 0 registered this script as `TcpLane.SupervisorProtocolControls` in
MG_IntegrationTest/CMakeLists.txt. Until then it was referenced by NOTHING in the tree - no
ctest entry, no workflow step - so the four controls it asserts had been green or red for an
unknown number of commits and nobody would have known which.
P7 wave 2-F, PH-7 (1)(2)(3) (ID-P7-3), added the two LISTEN controls at the top of main(). They
are the negative controls for the policy AuthToken.h now owns, and both are end-to-end against a
real supervisor process because that is the only place the policy is reachable: an in-process
unit test of `SocketTransport::Listen` can assert the return code but not that an operator who
misconfigured a token gets a dead server with a named reason rather than a live one that silently
serves loopback only.
P7 F2, PH-7 (5): the supervisor now reads and authenticates every first frame before it forks, so
the controls here assert that an unauthenticated peer learns nothing of ours
(`unauthenticated_wrong_layout`), that a silent one costs no fork and is answered at the pre-auth
deadline (`silent_peer`), that Busy is the answer to an AUTHENTICATED second client (`busy`), the
pending cap and the backoff (`pending_cap`, `auth_backoff`), and ID-P7-44's queued-DataBind
refusal (`data_bind_queued_before_bound`). The f2-auth fix round added that the pending queue is
shared between addresses (`pending_queue_is_shared`) and that an address holding slots with silent
connections is backed off (`silent_slot_holder_backed_off`), that routing DataBinds does not stall
the supervisor (`data_bind_routing_does_not_stall`), and that a child which refuses its Hello
refuses the DataBinds queued to it (`data_bind_queued_before_refused_hello`, ID-P7-44), and that
running out of descriptors pauses accepting instead of ending the supervisor
(`accept_out_of_descriptors`). The five
malformed-frame shapes are fuzz arm 1's own entry, scripts/ci/ph_fuzz_control_frames.py.
It needs flatc, which is deliberately absent from the default build graph (gen_protocol.py's
header block says why). Resolution order is gen_protocol.py's, minus the build-it-for-you arm:
--flatc, then $MOBILEGL_FLATC_EXECUTABLE, then <repo>/../flatc-build/flatc. With none of them
the script exits kSkipExit and ctest reports SKIP - loudly absent rather than quietly passing,
which is the failure mode a gate nothing references already had.
"""
import argparse
from contextlib import contextmanager
import importlib
import json
import os
from pathlib import Path
import re
import resource
import shutil
import signal
import socket
import struct
import subprocess
import sys
import tempfile
import time
ROOT = Path(__file__).resolve().parents[2]
# ctest's SKIP_RETURN_CODE for this entry. 77 is the automake convention the rest of the world
# uses and ctest has no opinion of its own.
kSkipExit = 77
# PH-7 (3): >= AuthToken.h's kMinimumAuthTokenBytes (16). The old value, `p65-smoke-token`, was
# fifteen bytes - one short - which is a pleasing accident and exactly the kind of token the
# minimum exists to refuse. The value is arbitrary; its LENGTH is the fixture.
kToken = 'p7-ph-f-supervisor-token'
assert len(kToken) >= 16, kToken
# A token that is a credential in every respect except length.
kShortToken = 'p7-ph-f-short'
assert len(kShortToken) < 16, kShortToken
# ServerMain's `SocketTransport::Listen(...) != MOBILEGL_OK` arm.
kListenRefusedExit = 72
def resolve_flatc(explicit):
"""gen_protocol.py's order, without its build-it-for-you arm. None when there is none."""
for candidate in (explicit, os.environ.get('MOBILEGL_FLATC_EXECUTABLE', ''),
str(ROOT.parent / 'flatc-build' / 'flatc')):
if not candidate:
continue
found = candidate if os.path.isfile(candidate) else shutil.which(candidate)
if found:
return found
return None
def read_exact(peer, size):
result = bytearray()
while len(result) < size:
chunk = peer.recv(size - len(result))
if not chunk:
raise RuntimeError('control channel closed before a complete frame')
result.extend(chunk)
return result
def welcome_terms(welcome):
"""The four byte counts a Welcome states, or {} when it carries no LinkTerms."""
terms = welcome.LinkTerms()
if terms is None:
return {}
return {'maxReply': terms.MaxReplyBytes(), 'cmdWindow': terms.CmdWindowBytes(),
'stageWindow': terms.StageWindowBytes(), 'eventWindow': terms.EventWindowBytes()}
def receive(peer, schema):
magic, size = struct.unpack('<4sI', read_exact(peer, 8))
if magic != b'MGLF' or size > 64 * 1024 * 1024:
raise RuntimeError('invalid control frame')
data = read_exact(peer, size)
envelope = schema['CtrlEnvelope'].CtrlEnvelope.GetRootAs(data, 0)
table = envelope.Msg()
kind = envelope.MsgType()
if kind == schema['CtrlMsg'].CtrlMsg.Refuse:
refusal = schema['Refuse'].Refuse()
refusal.Init(table.Bytes, table.Pos)
return {'code': refusal.Code(), 'expected': refusal.Expected(),
'detail': (refusal.Detail() or b'').decode()}
if kind == schema['CtrlMsg'].CtrlMsg.Welcome:
welcome = schema['Welcome'].Welcome()
welcome.Init(table.Bytes, table.Pos)
return {'welcome': welcome.ServerPid(), 'fingerprint': welcome.WireFingerprint(),
'nonce': bytes(welcome.DataNonce(j) for j in range(welcome.DataNonceLength())).hex(),
'terms': welcome_terms(welcome)}
raise RuntimeError(f'unexpected first control reply {kind}')
def hello(schema, flatbuffers, fingerprint=0, major=1, token=kToken, identifier=b'MGLC', ask=0):
builder = flatbuffers.Builder(512)
build = builder.CreateString('intentionally-different-build-for-p65-control')
auth = builder.CreateString(token)
terms = schema['LinkTerms']
terms.Start(builder)
terms.AddDataPlane(builder, 1)
if ask:
# PH-8: a client ASKING for windows. The server sizes the session itself; see
# `hello_asks_64_gib` below.
terms.AddWireForm(builder, 0)
terms.AddMaxReplyBytes(builder, ask)
terms.AddCmdWindowBytes(builder, ask)
terms.AddStageWindowBytes(builder, ask)
terms.AddEventWindowBytes(builder, ask)
link = terms.End(builder)
message = schema['Hello']
message.Start(builder)
message.AddAbiMajor(builder, major)
message.AddBuildFingerprint(builder, build)
message.AddBackendType(builder, 0) # DirectGLES
message.AddPid(builder, os.getpid())
message.AddWireFingerprint(builder, fingerprint)
message.AddLinkTerms(builder, link)
message.AddToken(builder, auth)
message.AddDialMode(builder, 2) # Connect
body = message.End(builder)
envelope = schema['CtrlEnvelope']
envelope.Start(builder)
envelope.AddMsgType(builder, schema['CtrlMsg'].CtrlMsg.Hello)
envelope.AddMsg(builder, body)
# `identifier` is a parameter for ONE caller: the steady-state loop's identifier check.
# flatbuffers' VerifyCtrlEnvelopeBuffer passes a nullptr identifier to VerifyBuffer, so a
# frame of some other schema that happens to verify used to reach GetCtrlEnvelope here.
builder.Finish(envelope.End(builder), identifier)
payload = bytes(builder.Output())
return struct.pack('<4sI', b'MGLF', len(payload)) + payload
def data_bind(schema, flatbuffers, nonce):
"""PH-7 (4): the framed DataBind a client writes as its data connection's first bytes."""
builder = flatbuffers.Builder(64)
vector = builder.CreateByteVector(nonce)
message = schema['DataBind']
message.Start(builder)
message.AddNonce(builder, vector)
body = message.End(builder)
envelope = schema['CtrlEnvelope']
envelope.Start(builder)
envelope.AddMsgType(builder, schema['CtrlMsg'].CtrlMsg.DataBind)
envelope.AddMsg(builder, body)
builder.Finish(envelope.End(builder), b'MGLC')
payload = bytes(builder.Output())
return struct.pack('<4sI', b'MGLF', len(payload)) + payload
def open_data(port, schema, flatbuffers, nonce):
"""A data connection that has presented `nonce`. Left open: the server owns what happens next."""
data = socket.create_connection(('127.0.0.1', port), timeout=3)
data.sendall(data_bind(schema, flatbuffers, nonce))
return data
def supervisor_environment(log, token, same_build=False):
"""The env a supervisor is started with, minus everything ServerMain's scrub catches."""
env = dict(os.environ, MOBILEGL_IPC_ROLE='server', MOBILEGL_IPC_DIAL='no',
MOBILEGL_IPC_REQUIRE_SAME_BUILD=str(int(same_build)),
MOBILEGL_IPC_LOG_FORWARD='0', MOBILEGL_LOG_FILE_PATH=str(log))
if token is None:
env.pop('MOBILEGL_IPC_TOKEN', None)
else:
env['MOBILEGL_IPC_TOKEN'] = token
for name in ('MOBILEGL_TRANSPORT', 'MOBILEGL_IPC_SERVER_PATH', 'MOBILEGL_IPC_RING_MB', 'MOBILEGL_IPC_STAGE_MB',
'MOBILEGL_IPC_CONTROL', 'MOBILEGL_IPC_ENDPOINT', 'MOBILEGL_BACKEND_TYPE'):
env.pop(name, None)
return env
def free_loopback_port():
with socket.socket() as reserve:
reserve.bind(('127.0.0.1', 0))
return reserve.getsockname()[1]
def refused_listen(server, log, endpoint, token):
"""Start a supervisor that must NOT come up, and return (exit code, everything it said).
PH-7 (2)(3), ID-P7-3. Both listen refusals are asserted this way rather than through
SocketTransport::Listen's return code, because the property is not "the function returns
PROTOCOL_MISMATCH" - it is that the PROCESS dies, says which policy killed it, and does not
leave a half-configured server listening. A supervisor that degraded to loopback-only after a
misconfigured token would pass a return-code test and fail an operator.
"""
base = log.with_suffix('.library.log')
env = supervisor_environment(base, token)
with log.open('wb') as output:
child = subprocess.Popen([server, endpoint, '--serve'], env=env,
stdout=output, stderr=output, start_new_session=True)
try:
code = child.wait(timeout=30)
except subprocess.TimeoutExpired:
# THE RED SHAPE OF THIS CONTROL IS A SUPERVISOR THAT CAME UP, so it is still listening
# and would answer the next control's peers. Killed here rather than left for the
# assertion below, which is what makes the red a red and not a cascade.
os.killpg(child.pid, signal.SIGKILL)
child.wait(timeout=10)
code = None
text = log.read_text(errors='replace')
role = base.with_name(base.stem + '.server' + base.suffix)
if role.is_file():
text += role.read_text(errors='replace')
return code, text
@contextmanager
def supervisor(server, log, same_build=False, serve=True, extra_env=None, nofile=None):
"""A supervisor on its own free loopback port. `serve=False` is the single-session shape
(no fork; the process IS the session and exits with it), which `single_session_listener`
below needs and nothing else does. `extra_env` sets supervisor knobs (PH-7 (5)'s pre-auth
deadline, pending cap and backoff) for the controls that need non-default ones. `nofile`
lowers the supervisor's soft RLIMIT_NOFILE, for the descriptor-exhaustion control."""
port = free_loopback_port()
base = log.with_suffix('.library.log')
env = supervisor_environment(base, kToken, same_build=same_build)
env.update(extra_env or {})
def limit():
if nofile is not None:
resource.setrlimit(resource.RLIMIT_NOFILE, (nofile, resource.getrlimit(resource.RLIMIT_NOFILE)[1]))
with log.open('wb') as output:
child = subprocess.Popen([server, f'tcp://127.0.0.1:{port}'] + (['--serve'] if serve else []),
env=env, stdout=output, stderr=output, start_new_session=True,
preexec_fn=limit if nofile is not None else None)
try:
# Log.cpp's RoleLogPath: the base name is a BASE, and the server role writes
# `<stem>.server<ext>`. Yielded rather than recomputed at the call site, so the one
# place that knows the rule is the one place that sets MOBILEGL_LOG_FILE_PATH.
yield port, child, base.with_name(base.stem + '.server' + base.suffix)
finally:
try:
os.killpg(child.pid, signal.SIGTERM)
except ProcessLookupError:
pass
child.wait(timeout=10)
def connect_control(port, seconds=5, source=None):
"""A connection to the supervisor's port, retried on ECONNREFUSED for `seconds`: a supervisor
just started may not have reached listen() yet, and a refused connect before it has is
startup, not a finding. `source` binds the local end to another loopback address (127.0.0.2,
...), which is how the pre-auth controls stand in for a second host: the supervisor keys its
backoff and its share of the pending queue by peer address."""
deadline = time.monotonic() + seconds
while True:
try:
return socket.create_connection(('127.0.0.1', port), timeout=3,
source_address=(source, 0) if source else None)
except ConnectionRefusedError:
if time.monotonic() >= deadline:
raise
time.sleep(.02)
@contextmanager
def held_session(port, schema, flatbuffers, message):
"""A welcomed session whose sockets STAY OPEN, so its child is still alive to be killed.
`exchange` closes the connection before returning, which is what every other control in this
file wants and is exactly wrong for the reap case: a child that has already exited cannot be
made to fault. Busy is retried here for the same reason it is there - a previous child may
still be reaching _exit.
PH-7 (4): the data connection is opened AFTER the Welcome and presents its nonce, which is
what gets the child out of the handshake and into its control loop.
"""
control = None
data = None
reply = None
for _ in range(30):
control = connect_control(port)
control.sendall(message)
reply = receive(control, schema)
if reply.get('code') != 7:
break
control.close()
control = None
time.sleep(.05)
if control is None:
raise RuntimeError('supervisor remained Busy while a held session was wanted')
if reply.get('welcome', 0):
data = open_data(port, schema, flatbuffers, bytes.fromhex(reply['nonce']))
try:
yield control, reply
finally:
control.close()
if data is not None:
data.close()
def last_faulted(path):
"""The supervisor's lifetime fault count as of the last reap line, or 0 before the first."""
if not path.is_file():
return 0
values = re.findall(r'sessionsFaulted=(\d+)', path.read_text(errors='replace'))
return int(values[-1]) if values else 0
def wait_for_log(path, needle, seconds=15):
"""The supervisor reaps on its next accept timeout (250 ms), so this polls rather than sleeps."""
deadline = time.monotonic() + seconds
while True:
if path.is_file():
text = path.read_text(errors='replace')
if needle in text:
return text
if time.monotonic() >= deadline:
return None
time.sleep(.05)
@contextmanager
def pair(port):
"""A session's CONTROL connection. The name is historical: until PH-7 (4) a TCP session was
two connections paired by arrival order, and this opened both. The data connection is now
opened after Welcome with the nonce it carries (open_data), so a control that stops at the
handshake - every one but held_session's - never opens one."""
control = connect_control(port)
try:
yield control
finally:
control.close()
def exchange(port, schema, message):
# A prior rejected child may still be reaching _exit when the next pair
# arrives. Only Busy may be retried here, and never in the Busy assertion.
for _ in range(30):
with pair(port) as control:
control.sendall(message)
reply = receive(control, schema)
if reply.get('code') != 7:
return reply
time.sleep(.05)
raise RuntimeError('supervisor remained Busy after the previous peer closed')
# PH-7 (5): the refusal details MG_Remote/Server/PreAuthGate.h names. Mirrored, not parsed out of
# the header: a rename there must fail these controls rather than be followed by them silently.
kDetailNotAFrame = 'first frame is not a control frame'
kDetailTruncated = 'first frame ended before it was complete'
kDetailNoIdentifier = 'first frame carries no CtrlEnvelope identifier'
kDetailUnverifiable = 'first frame did not verify as a CtrlEnvelope'
kDetailNotAHello = 'first control frame is not a verifiable Hello'
kDetailBadDataBind = 'first frame is a DataBind without a 16-byte nonce'
kDetailDeadline = 'no authenticated first frame within the pre-auth deadline'
kDetailPendingFull = 'too many connections are awaiting authentication'
kDetailDisplaced = 'displaced from the pre-auth queue by a connection from another address'
kDetailBackoff = 'too many failed authentications from this address; retry later'
kDetailHandoff = 'data connection could not be handed to the live session'
def child_pids(pid):
"""Every process whose parent is `pid` - the supervisor's session children, including one that
has exited and is not reaped yet. Read from /proc directly, because the property PH-7 (5)
asserts is that NO child exists for a peer that has not authenticated, and the log only speaks
of a child once it is reaped."""
children = []
for entry in Path('/proc').iterdir():
if not entry.name.isdigit():
continue
try:
stat = (entry / 'stat').read_text()
except OSError:
continue
fields = stat.rsplit(')', 1)[1].split()
if int(fields[1]) == pid:
children.append(int(entry.name))
return children
def wait_for_no_session(pid, seconds=10):
"""Until the supervisor has no session child (a previous control's session reaped), so the
next control starts from "no live session" by construction rather than by timing."""
deadline = time.monotonic() + seconds
while child_pids(pid):
if time.monotonic() >= deadline:
raise RuntimeError(f'supervisor pid={pid} still has session children {child_pids(pid)}')
time.sleep(.05)
def load_schema(flatc, directory):
"""The protocol's Python bindings, generated by the pinned flatc into `directory`."""
subprocess.run([flatc, '--python', '-o', directory,
str(ROOT / 'MobileGL/MG_Remote/Protocol/protocol.fbs')], check=True)
sys.path[:0] = [directory, str(ROOT / '3rdparty/flatbuffers/python')]
import flatbuffers
schema = {name: importlib.import_module('MobileGL.Wire.' + name)
for name in ('CtrlEnvelope', 'CtrlMsg', 'DataBind', 'Hello', 'LinkTerms', 'LogFlush', 'Refuse',
'Welcome')}
return flatbuffers, schema
def silent_peer_control(port, process, schema):
"""PH-7 (5) (ph-f.md §6.4 (a)): A SILENT PEER COSTS NO FORK.
The supervisor forked a session child the moment it accepted, and that child waited up to
10 s for a Hello before exiting 67 without a word. Now nothing is forked for a peer that has
not authenticated: while the silent connection is pending the supervisor has NO child (read
from /proc, not from the log, which only names a child once it is reaped), and at the pre-auth
deadline (default 2000 ms) the peer is answered by name. RED before the fix: a child exists
at the first check, and the peer's read ends with EOF after 10 s instead of a frame. A function
of its own so the red-once probe can run exactly this control against an older server."""
wait_for_no_session(process.pid)
silent = connect_control(port)
try:
time.sleep(.5)
forked = child_pids(process.pid)
silent.settimeout(15)
began = time.monotonic()
try:
answer = receive(silent, schema)
except (RuntimeError, OSError) as error:
answer = {'error': repr(error)}
waited = time.monotonic() - began
finally:
silent.close()
assert not forked, f'the supervisor forked {forked} for a peer that has not sent a byte'
assert answer.get('code') == 4 and answer.get('detail') == kDetailDeadline, answer
assert waited < 5, f'the silent peer was answered after {waited:.1f} s'
assert process.poll() is None, 'a silent peer killed the supervisor'
return {'refusal': answer, 'childrenWhilePending': forked, 'answeredAfterSeconds': round(waited + .5, 2)}
def learn_fingerprint(port, schema, flatbuffers):
"""The supervisor's wire fingerprint, from the one peer allowed to learn it: an AUTHENTICATED
Hello with fingerprint 0 is answered Refuse{WireFingerprint} carrying ours (PH-7 (5): an
unauthenticated one is not)."""
layout = exchange(port, schema, hello(schema, flatbuffers))
assert layout.get('code') == 2 and layout.get('expected', 0), layout
return layout
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--server', required=True)
parser.add_argument('--flatc', default='')
parser.add_argument('--out', type=Path, required=True)
args = parser.parse_args()
args.out.mkdir(parents=True, exist_ok=True)
flatc = resolve_flatc(args.flatc)
if flatc is None:
print('tcp_supervisor_smoke: no flatc found (--flatc, $MOBILEGL_FLATC_EXECUTABLE, '
f'{ROOT.parent / "flatc-build" / "flatc"}); skipping. Build one once with '
'`python3 scripts/gen_protocol.py` and this lane runs.', flush=True)
return kSkipExit
if not os.path.isfile(args.server):
print(f'tcp_supervisor_smoke: no server image at {args.server}; skipping', flush=True)
return kSkipExit
with tempfile.TemporaryDirectory(prefix='mgl-tcp-schema-') as directory:
flatbuffers, schema = load_schema(flatc, directory)
evidence = {}
# ---- PH-7 (2)(3), ID-P7-3: THE TWO LISTEN REFUSALS -------------------------------
#
# Both are the SAME policy from two directions: a listener that can be reached from off
# this machine has to have a credential, and a credential has to be one.
#
# (a) No token, non-loopback bind. The mechanism has been in the tree since P6.5
# (SocketTransport.cpp's ListenTcp) and the plan's own gate row called it "done
# (mechanism)" with the caveat that nothing referenced it. This is the reference.
# (b) A token shorter than 16 bytes, on LOOPBACK, where a short token is otherwise
# harmless. It still fails, and that is the ruling: a configured token is either a
# credential or a refusal, never a warning. Before this package a one-byte token
# unlocked `tcp://0.0.0.0`.
code, said = refused_listen(args.server, args.out / 'wildcard-no-token.log',
'tcp://0.0.0.0:40699', None)
assert code == kListenRefusedExit, (code, said)
assert 'Refuse{Authentication}' in said, said
evidence['wildcard_listen_without_a_token'] = {'exit': code, 'line': next(
l.strip() for l in said.splitlines() if 'Refuse{Authentication}' in l)}
short = f'tcp://127.0.0.1:{free_loopback_port()}'
code, said = refused_listen(args.server, args.out / 'short-token.log', short, kShortToken)
assert code == kListenRefusedExit, (code, said)
assert 'Refuse{Authentication}' in said and 'minimum is 16' in said, said
assert kShortToken not in said, 'the refusal printed the token'
evidence['short_token_listen'] = {'exit': code, 'line': next(
l.strip() for l in said.splitlines() if 'Refuse{Authentication}' in l)}
with supervisor(args.server, args.out / 'supervisor.log') as (port, process, serverLog):
layout = learn_fingerprint(port, schema, flatbuffers)
fingerprint = layout['expected']
evidence['wrong_layout'] = layout
token = exchange(port, schema, hello(schema, flatbuffers, fingerprint=fingerprint, token='wrong-token'))
assert token.get('code') == 4, token
evidence['wrong_token'] = token
# ---- PH-7 (5) (ph-f.md §6.4 (b)): AN UNAUTHENTICATED PEER LEARNS NOTHING ABOUT US ----
#
# The same wrong layout as `wrong_layout` above, WITHOUT the token. The session child
# used to check the wire before the token, so this peer was answered
# Refuse{WireFingerprint} with our fingerprint in `expected` (and with major 99,
# Refuse{ProtocolVersion} with our version). The token is asked first now, by the
# supervisor, and the answer is Refuse{Authentication} carrying nothing of ours.
# RED before the fix: code 2 with expected == our fingerprint.
unauthenticated = exchange(port, schema, hello(schema, flatbuffers, token='wrong-token'))
assert unauthenticated.get('code') == 4 and unauthenticated.get('expected') == 0, (
f'an unauthenticated Hello with a wrong layout was answered {unauthenticated}; '
f'our fingerprint is {fingerprint}')
unauthenticated_version = exchange(port, schema, hello(schema, flatbuffers, major=99, token='wrong-token'))
assert unauthenticated_version.get('code') == 4 and unauthenticated_version.get('expected') == 0, (
unauthenticated_version)
evidence['unauthenticated_wrong_layout'] = unauthenticated
evidence['unauthenticated_wire_major_99'] = unauthenticated_version
version = exchange(port, schema, hello(schema, flatbuffers, major=99))
assert version.get('code') == 1, version
evidence['wire_major_99'] = version
accepted = exchange(port, schema, hello(schema, flatbuffers, fingerprint=fingerprint))
assert accepted.get('welcome', 0) and accepted['welcome'] != os.getpid(), accepted
assert accepted.get('fingerprint') == fingerprint, accepted
evidence['different_build_connect'] = accepted
assert process.poll() is None, 'a rejected session killed the supervisor'
# ---- Busy is the answer to an AUTHENTICATED second client (PH-7 (5)) ---------------
#
# This control used to hold a connection that never sent a Hello and read Busy on a
# second silent one: the supervisor forked for the first and refused the second after
# waiting 2 s for its frame. Neither is a client now - both are unauthenticated peers
# and are answered at the pre-auth deadline (`silent_peer` below). One session at a
# time is unchanged, so a WELCOMED session plus a second authenticated Hello is Busy.
with held_session(port, schema, flatbuffers,
hello(schema, flatbuffers, fingerprint=fingerprint)) as (control, welcome):
assert welcome.get('welcome', 0), welcome
with pair(port) as second:
second.sendall(hello(schema, flatbuffers, fingerprint=fingerprint))
busy = receive(second, schema)
assert busy.get('code') == 7, busy
evidence['busy'] = busy
evidence['silent_peer'] = silent_peer_control(port, process, schema)
# P7 wave 0: the steady-state control loop asks for the file identifier, which
# ServerSession::ParseEnvelope has asked since P5 and ServerMain's loop never did.
# flatbuffers verifies a table's offsets, not the four identifier bytes, so until now
# a frame of a DIFFERENT schema that happened to verify was read here as a
# CtrlEnvelope and its union tag believed. This is the cheap half of Ph's fuzz arm 1;
# the arm itself (five malformed shapes, its own ctest lane) is scheduled after P7.
with held_session(port, schema, flatbuffers,
hello(schema, flatbuffers, fingerprint=fingerprint)) as (control, welcome):
assert welcome.get('welcome', 0), welcome
control.sendall(hello(schema, flatbuffers, fingerprint=fingerprint,
identifier=b'XXXX'))
named = wait_for_log(serverLog, 'carries no CtrlEnvelope identifier')
assert named is not None, (
'the session read a frame with the wrong file identifier without saying so; '
f'see {serverLog}')
evidence['wrong_identifier_frame'] = next(
l.strip() for l in named.splitlines() if 'carries no CtrlEnvelope identifier' in l)
assert process.poll() is None, 'a malformed steady-state frame killed the supervisor'
# P7 wave 0, Ph slice (1) (ID-P7-1). THE SUPERVISOR NAMES A FAULTED SESSION, AND THE
# NEXT CONNECTION IS STILL SERVED.
#
# The fault is delivered as SIGABRT to the session child, which is the signal every
# death through SessionFail raises - so this exercises the reap path a real Fatal
# takes, without needing a scenario that can reach one. The child's pid comes from
# the Welcome it just sent (`serverPid`), which is the only honest way to name it:
# the supervisor's own pid is the parent's.
#
# Before this package the three `waitpid(active, nullptr, WNOHANG)` calls discarded
# the status, so the log carried no line at all for this event and the assertion
# below could not have been written.
held = hello(schema, flatbuffers, fingerprint=fingerprint)
with held_session(port, schema, flatbuffers, held) as (control, welcome):
faulted = welcome.get('welcome', 0)
assert faulted and faulted != os.getpid(), welcome
# The count is a supervisor LIFETIME figure, so what is asserted is the DELTA, not
# an absolute. (Until PH-7 (5) the controls above had already moved it: the old
# Busy probe held a pair that never sent a Hello, and the child forked for it exited
# 67. Nothing is forked for such a peer now.)
prior = last_faulted(serverLog)
os.kill(faulted, signal.SIGABRT)
reaped = wait_for_log(serverLog, f'pid={faulted} reaped signal={int(signal.SIGABRT)}')
assert reaped is not None, (
f'the supervisor did not name session pid={faulted} as killed by '
f'signal={int(signal.SIGABRT)} in {serverLog}; a faulted session is '
f'indistinguishable from a finished one again (ID-P7-1)')
line = next(l for l in reaped.splitlines() if f'pid={faulted} reaped' in l)
assert f'sessionsFaulted={prior + 1}' in line, (line, prior)
# A refused handshake is a normal outcome and its child exits 0, so the counter must
# NOT have moved for the three refusals above. Stated as an assertion rather than
# left to the delta: a counter that counted refusals would make the number useless.
refusals = sum(1 for l in reaped.splitlines() if 'reaped exit=0' in l)
assert refusals >= 3, f'expected the three refusal children to exit 0: {refusals}'
evidence['faulted_session'] = {'pid': faulted, 'line': line.strip(),
'priorFaults': prior, 'cleanExits': refusals}
assert process.poll() is None, 'the supervisor died with its session child'
# And the point of fork-per-session: the NEXT Hello is welcomed as usual.
after = exchange(port, schema, hello(schema, flatbuffers, fingerprint=fingerprint))
assert after.get('welcome', 0) and after['welcome'] != faulted, after
evidence['connect_after_fault'] = after
# ---- PH-7 (4), ID-P7-3: THE DATA CONNECTION IS BOUND BY NONCE ---------------------
#
# (a) THE FIELD FAILURE. Over a Windows `adb forward` the two connections of one
# session arrived reordered, the server's AcceptPair read the data connection as
# control, and every child exited 67. Reproduced here without adb: the data
# connection (a DataBind with a nonce no live session minted) is presented FIRST,
# the control connection second. It is refused by name, as a clean exit, and the
# control connection behind it is welcomed.
# "No live session" by construction: the previous control's child is reaped first.
wait_for_no_session(process.pid)
stray = open_data(port, schema, flatbuffers, os.urandom(16))
try:
refused = receive(stray, schema)
finally:
stray.close()
assert refused.get('code') == 4 and refused.get('detail') == 'data connection names no live session', refused
reordered = exchange(port, schema, hello(schema, flatbuffers, fingerprint=fingerprint))
assert reordered.get('welcome', 0), reordered
assert len(bytes.fromhex(reordered['nonce'])) == 16, reordered
evidence['data_connection_first'] = refused
# (b) A STALE NONCE while a session is live: a data connection from an earlier session
# (or raced in by anybody who can reach the port) is refused by name ON THAT
# CONNECTION, and the session it failed to join still binds its own and comes up.
held = hello(schema, flatbuffers, fingerprint=fingerprint)
for _ in range(30):
control = socket.create_connection(('127.0.0.1', port), timeout=3)
control.sendall(held)
welcome = receive(control, schema)
if welcome.get('code') != 7:
break
control.close()
time.sleep(.05)
try:
assert welcome.get('welcome', 0) and len(bytes.fromhex(welcome['nonce'])) == 16, welcome
nonce = bytes.fromhex(welcome['nonce'])
stale = bytes(b ^ 0xFF for b in nonce)
wrong = open_data(port, schema, flatbuffers, stale)
try:
mismatch = receive(wrong, schema)
finally:
wrong.close()
assert mismatch.get('code') == 4 and mismatch.get('detail') == 'data connection nonce mismatch', mismatch
right = open_data(port, schema, flatbuffers, nonce)
try:
ready = wait_for_log(serverLog, f'pid={welcome["welcome"]} transport=spawn role=server ready')
assert ready is not None, (
f'session pid={welcome["welcome"]} did not come up on its own data connection '
f'after a stale one was refused; see {serverLog}')
finally:
right.close()
finally:
control.close()
named = wait_for_log(serverLog, 'Refuse{Authentication} data connection nonce mismatch')
assert named is not None, f'the stale data connection was not refused by name in {serverLog}'
evidence['stale_nonce'] = {'refusal': mismatch, 'line': next(
l.strip() for l in named.splitlines() if 'data connection nonce mismatch' in l)}
assert process.poll() is None, 'a refused data connection killed the supervisor'
# ---- PH-8: THE SERVER SIZES THE SESSION; THE CLIENT ONLY ASKS -------------------
#
# A Hello that asks for 64 GiB in every window is welcomed with the server's own
# terms, and the session child that answered it - named by the Welcome's serverPid -
# never had 64 GiB of address space at any point (VmPeak, read after its data
# connection bound, i.e. after every segment of the session exists).
ask = 64 << 30
with held_session(port, schema, flatbuffers,
hello(schema, flatbuffers, fingerprint=fingerprint, ask=ask)) as (control, welcome):
assert welcome.get('welcome', 0), welcome
terms = welcome['terms']
assert terms and all(0 < value < (1 << 30) for value in terms.values()), (
f'the Welcome did not clamp a 64 GiB ask to the server\'s own terms: {terms}')
ready = wait_for_log(serverLog, f'pid={welcome["welcome"]} transport=spawn role=server ready')
assert ready is not None, f'the 64 GiB-asking session never came up; see {serverLog}'
status = Path(f'/proc/{welcome["welcome"]}/status').read_text()
peak = int(re.search(r'VmPeak:\s+(\d+) kB', status).group(1)) * 1024
assert peak < ask, f'session pid={welcome["welcome"]} reached VmPeak={peak} for a 64 GiB ask'
evidence['hello_asks_64_gib'] = {'asked': ask, 'terms': terms, 'childVmPeakBytes': peak}
# ---- F fix round: A BOUND SESSION'S SUPERVISOR REFUSES EVERY LATER DataBind BY NAME --
#
# The session child reads its hand-off socketpair only while binding; after that it
# never reads it again. Until this control the supervisor kept forwarding every later
# DataBind into it anyway: each descriptor sat in the child's receive queue with NO
# reply, its TCP connection was held open until the session ended, and once the queue
# reached net.unix.max_dgram_qlen (10 on the phone's kernel, 512 on this host's) the
# supervisor blocked in sendmsg for the rest of the session - no accepts, no Busy, no
# reaping. Now the child closes its end the moment Accept returns and the send is
# non-blocking, so each of these twelve is answered on its own connection with
# Refuse{Authentication} "data connection could not be handed to the live session",
# and a fresh control connection afterwards is still answered Busy. RED before the fix
# at the very first `receive`: a 3 s socket timeout, because nothing ever answered.
with held_session(port, schema, flatbuffers,
hello(schema, flatbuffers, fingerprint=fingerprint)) as (control, welcome):
assert welcome.get('welcome', 0), welcome
# Bound, not merely welcomed: an extra DataBind that arrived while the child was
# still in BindDataConnection would be forwarded and refused as a nonce mismatch,
# which is the other control's line, not this one's.
ready = wait_for_log(serverLog, f'pid={welcome["welcome"]} transport=spawn role=server ready')
assert ready is not None, f'the held session never came up; see {serverLog}'
answered = []
for _ in range(12):
extra = open_data(port, schema, flatbuffers, os.urandom(16))
try:
answered.append(receive(extra, schema))
finally:
extra.close()
for answer in answered:
assert answer.get('code') == 4 and answer.get('detail') == (
'data connection could not be handed to the live session'), answer
with pair(port) as fresh:
fresh.sendall(hello(schema, flatbuffers, fingerprint=fingerprint))
still_busy = receive(fresh, schema)
assert still_busy.get('code') == 7, still_busy
evidence['data_bind_after_bound'] = {'refused': len(answered), 'last': answered[-1],
'fresh_control': still_busy}
assert process.poll() is None, 'the refused DataBinds killed the supervisor'
# ---- f2-auth fix round: ROUTING A DataBind DOES NOT STALL THE SUPERVISOR -------------
#
# While a session was live the supervisor waited up to 50 ms for it to exit before
# routing EACH DataBind - and a DataBind is unauthenticated and never counted, so any
# peer could hold the one poll loop 50 ms per connection it opened. Now it takes one
# look. Twenty DataBinds into a bound session, opened four at a time (under the pending
# cap of 8 however the host schedules the supervisor), are all refused by name, and
# the last answer arrives well inside the 20 x 50 ms = 1 s the old wait alone cost.
# RED before the fix: the stalled loop let the connections pile up past the pending
# cap (Busy "too many connections are awaiting authentication"), or elapsed >= 1.0 s.
with held_session(port, schema, flatbuffers,
hello(schema, flatbuffers, fingerprint=fingerprint)) as (control, welcome):
assert welcome.get('welcome', 0), welcome
ready = wait_for_log(serverLog, f'pid={welcome["welcome"]} transport=spawn role=server ready')
assert ready is not None, f'the held session never came up; see {serverLog}'
stalled = []
began = time.monotonic()
for _ in range(5):
burst = [open_data(port, schema, flatbuffers, os.urandom(16)) for _ in range(4)]
try:
for extra in burst:
extra.settimeout(10)
try:
stalled.append(receive(extra, schema))
except (RuntimeError, OSError) as error:
stalled.append({'error': repr(error)})
finally:
for extra in burst:
extra.close()
elapsed = time.monotonic() - began
for answer in stalled:
assert answer.get('code') == 4 and answer.get('detail') == kDetailHandoff, answer
assert elapsed < .5, f'twenty DataBinds into a live session took {elapsed:.2f} s to be answered'
evidence['data_bind_routing_does_not_stall'] = {'refused': len(stalled), 'seconds': round(elapsed, 3)}
assert process.poll() is None, 'the DataBind burst killed the supervisor'
# ---- ID-P7-44: A DataBind QUEUED TO THE CHILD BEFORE IT LET GO IS REFUSED, NOT DROPPED
#
# The F fix round closed the child's end of the hand-off the moment Accept returned;
# between the bind and that close the supervisor could still queue a DataBind, and a
# close with descriptors queued drops them in the kernel - that peer's connection just
# ended, with no frame. The window is microseconds in the wild, so it is held open
# here: the session child is SIGSTOPped right after its Welcome, its own data
# connection and three extra DataBinds are then routed to it (the supervisor forwards
# to a stopped child as readily as to a running one), and it is continued. It binds
# its own connection and lets go of the hand-off with the three still queued. Each
# extra must be answered by name - "nonce mismatch" if the child happened to read it
# before its own, the supervisor's hand-off words if it was still queued when the
# child let go - and the session must come up. RED before the fix: the extras' reads
# end with EOF and no frame ("control channel closed before a complete frame").
held = hello(schema, flatbuffers, fingerprint=fingerprint)
for _ in range(30):
control = connect_control(port)
control.sendall(held)
welcome = receive(control, schema)
if welcome.get('code') != 7:
break
control.close()
time.sleep(.05)
extras = []
right = None
try:
assert welcome.get('welcome', 0), welcome
child = welcome['welcome']
os.kill(child, signal.SIGSTOP)
try:
right = open_data(port, schema, flatbuffers, bytes.fromhex(welcome['nonce']))
time.sleep(.3) # routed first, so it is first in the child's queue
for _ in range(3):
extras.append(open_data(port, schema, flatbuffers, os.urandom(16)))
time.sleep(.5) # every one of them routed into the stopped child's queue
finally:
os.kill(child, signal.SIGCONT)
queued = []
for extra in extras:
extra.settimeout(10)
try:
queued.append(receive(extra, schema))
except (RuntimeError, OSError) as error:
queued.append({'error': repr(error)})
ready = wait_for_log(serverLog, f'pid={child} transport=spawn role=server ready')
finally:
for extra in extras:
extra.close()
if right is not None:
right.close()
control.close()
for answer in queued:
assert answer.get('code') == 4 and answer.get('detail') in (
'data connection nonce mismatch', kDetailHandoff), (
f'a DataBind queued to session pid={child} before it let go of the hand-off was '
f'answered {answer}, not refused by name (ID-P7-44); all: {queued}')
assert ready is not None, f'session pid={child} did not come up; see {serverLog}'
evidence['data_bind_queued_before_bound'] = {'refused': queued}
assert process.poll() is None, 'the queued DataBinds killed the supervisor'
# ---- F fix round: A GARBAGE FIRST FRAME WHILE BUSY IS MalformedHello, NOT Busy ------
#
# While a session is live the supervisor reads a new connection's first frame to route
# it. A frame with the wrong magic (or a length over 1 MiB) was refused Busy, naming a
# condition the connection does not have: it is not a control frame at all, and it now
# gets the word ServerSession::Accept uses for a first frame that is not a verifiable
# Hello. Busy stays the answer for a well-formed frame that is not a DataBind (the
# `busy` control above). RED before the fix: code 7.
with held_session(port, schema, flatbuffers,
hello(schema, flatbuffers, fingerprint=fingerprint)) as (control, welcome):
assert welcome.get('welcome', 0), welcome
garbage = connect_control(port)
try:
garbage.sendall(b'XXXX' + bytes(60))
malformed = receive(garbage, schema)
finally:
garbage.close()
assert malformed.get('code') == 8 and malformed.get('detail') == (
'first frame is not a control frame'), malformed
evidence['garbage_first_frame_while_busy'] = malformed
assert process.poll() is None, 'a garbage first frame killed the supervisor'
with supervisor(args.server, args.out / 'same-build-supervisor.log', same_build=True) as (port, _, _log):
strict = exchange(port, schema, hello(schema, flatbuffers, fingerprint=fingerprint))
assert strict.get('code') == 3, strict
evidence['same_build_required'] = strict
# ---- PH-7 (5): THE PENDING CAP AND THE AUTHENTICATION BACKOFF ---------------------------
#
# A supervisor with small knobs, so both limits are reached in a few connections: at most
# two connections pending authentication, and an address backed off after two failures,
# for 1500 ms. Each control below speaks from its OWN loopback address (connect_control's
# `source`), because the failures one control provokes are counted against the address it
# used, and a silent connection held past the probe grace is one of them.
knobs = {'MOBILEGL_IPC_PREAUTH_MAX': '2', 'MOBILEGL_IPC_PREAUTH_MS': '3000',
'MOBILEGL_IPC_AUTH_BACKOFF_AFTER': '2', 'MOBILEGL_IPC_AUTH_BACKOFF_MS': '1500'}
with supervisor(args.server, args.out / 'preauth-knobs.log', extra_env=knobs) as (port, process, serverLog):
# (a) THE CAP. Two silent connections from 127.0.0.3 fill it; a third from the SAME
# address is refused Busy AT ACCEPT, at once, instead of joining them - its address
# already holds the whole queue. RED with the cap disabled: the third is not
# answered until its own deadline, and then as Authentication.
first, second = connect_control(port, source='127.0.0.3'), connect_control(port, source='127.0.0.3')
try:
time.sleep(.3) # both accepted and pending
third = connect_control(port, source='127.0.0.3')
try:
third.settimeout(10)
began = time.monotonic()
full = receive(third, schema)
waited = time.monotonic() - began
finally:
third.close()
finally:
first.close()
second.close()
assert full.get('code') == 7 and full.get('detail') == kDetailPendingFull, full
assert waited < 1, f'the connection over the pending cap waited {waited:.2f} s for its answer'
evidence['pending_cap'] = {'refusal': full, 'answeredAfterSeconds': round(waited, 3)}
time.sleep(.3)
# (a2) THE QUEUE IS SHARED (fix round). The first cut had one pool: one address that
# held every slot kept every other address out, for as long as it cared to. Now two
# silent connections from 127.0.0.2 fill the queue and a Hello from 127.0.0.1 is
# still JUDGED - it displaces 127.0.0.2's oldest connection, which is told so by
# name - and reaches a session child (an authenticated Hello with fingerprint 0 is
# answered Refuse{WireFingerprint} by the child, i.e. it was forked for). RED with a
# single pool: the Hello is refused Busy "too many connections are awaiting
# authentication".
holders = [connect_control(port, source='127.0.0.2') for _ in range(2)]
try:
time.sleep(.3) # both accepted and pending
# Not `exchange`: its Busy retry would turn the red shape into a timeout. No
# session is live on this supervisor, so a Busy here can only be the queue's.
with pair(port) as newcomer:
newcomer.settimeout(10)
newcomer.sendall(hello(schema, flatbuffers))
shared = receive(newcomer, schema)
holders[0].settimeout(5)
try:
displaced = receive(holders[0], schema)
except (RuntimeError, OSError) as error:
displaced = {'error': repr(error)}
finally:
for holder in holders:
holder.close()
assert shared.get('code') == 2 and shared.get('expected', 0), (
f'a Hello from 127.0.0.1 while 127.0.0.2 held the whole pre-auth queue was answered {shared}')
assert displaced.get('code') == 7 and displaced.get('detail') == kDetailDisplaced, displaced
named = wait_for_log(serverLog, 'pending connection from 127.0.0.2 displaced by one from 127.0.0.1')
assert named is not None, f'the displacement was not named in {serverLog}'
evidence['pending_queue_is_shared'] = {'newcomer': shared, 'displaced': displaced, 'line': next(
l.strip() for l in named.splitlines() if 'displaced by one from 127.0.0.1' in l)}
time.sleep(.3)
# (b) THE BACKOFF. Two wrong tokens from this address, and the THIRD connection - with
# the right token - is refused at accept, before its Hello is read, with the backoff's
# own words; once the window has passed the right token is welcomed again (and that
# clears the address). RED with the backoff disabled: the third is welcomed.
fingerprint = learn_fingerprint(port, schema, flatbuffers)['expected']
for _ in range(2):
wrong = exchange(port, schema, hello(schema, flatbuffers, fingerprint=fingerprint, token='wrong-token'))
assert wrong.get('code') == 4 and wrong.get('detail') == 'token mismatch', wrong
backed = exchange(port, schema, hello(schema, flatbuffers, fingerprint=fingerprint))
assert backed.get('code') == 4 and backed.get('detail') == kDetailBackoff, (
f'two failed authentications from one address and the next Hello was answered {backed}')
named = wait_for_log(serverLog, 'refused at accept: authentication backoff')
assert named is not None, f'the backoff refusal was not named in {serverLog}'
time.sleep(1.6)
after = exchange(port, schema, hello(schema, flatbuffers, fingerprint=fingerprint))
assert after.get('welcome', 0), f'the address was still refused after its window: {after}'
evidence['auth_backoff'] = {'refusal': backed, 'line': next(
l.strip() for l in named.splitlines() if 'refused at accept: authentication backoff' in l),
'afterWindow': after}
# (c) A SILENT SLOT HOLDER IS BACKED OFF (fix round). The critic's reproduction, with
# this supervisor's small knobs: 127.0.0.4 holds the whole queue with connections
# that never send a byte and closes them just before the deadline would have
# counted them. The first cut counted no close without a byte, so that address
# could repeat this for ever. A close after the probe grace (250 ms) is a failure
# now: after two, 127.0.0.4 is refused at accept - even with the right token.
# RED without the rule: that Hello is judged and forked for (Refuse{WireFingerprint}
# from the child, code 2).
holders = [connect_control(port, source='127.0.0.4') for _ in range(2)]
time.sleep(.6) # past the grace, well inside the 3000 ms deadline
for holder in holders:
holder.close()
time.sleep(.2) # the supervisor sees both closes before the next connection
with socket.create_connection(('127.0.0.1', port), timeout=3, source_address=('127.0.0.4', 0)) as held:
held.settimeout(10)
held.sendall(hello(schema, flatbuffers))
try:
silent = receive(held, schema)
except (RuntimeError, OSError) as error:
silent = {'error': repr(error)}
assert silent.get('code') == 4 and silent.get('detail') == kDetailBackoff, (
f'127.0.0.4 held the pre-auth queue twice without a byte and its next Hello was answered {silent}')
reason = '127.0.0.4 failed pre-auth 2 times (last: held a pre-auth slot and closed without a byte)'
named = wait_for_log(serverLog, reason)
assert named is not None, f'the silent holder\'s failures were not named ("{reason}") in {serverLog}'
evidence['silent_slot_holder_backed_off'] = {'refusal': silent, 'line': next(
l.strip() for l in named.splitlines() if reason in l)}
assert process.poll() is None, 'the pre-auth limits killed the supervisor'
# ---- f2-auth fix round: OUT OF DESCRIPTORS, THE SUPERVISOR PAUSES ACCEPTING, NOT EXITS ----
#
# accept(2) failing with EMFILE used to be "the listener failed": the supervisor logged
# its summary and exited 73, so a connection flood against a low RLIMIT_NOFILE (or a large
# MOBILEGL_IPC_PREAUTH_MAX - now clamped to 256) took the server down for everybody. Here
# the supervisor runs with a soft limit of 16 descriptors (five in use when idle) and a
# pending cap it cannot reach (64), and 48 silent connections are opened (the listen
# backlog is 8, so they arrive in waves - the first waves alone pass the limit): it must
# name the pause, stay up, and - once the flood is gone - still answer a Hello
# (fingerprint 0, so the answer is the child's Refuse{WireFingerprint}). The backoff is
# off: the flood's failures are not this control's subject. RED before the fix: the
# supervisor has exited.
exhaust = {'MOBILEGL_IPC_PREAUTH_MAX': '64', 'MOBILEGL_IPC_PREAUTH_MS': '3000',
'MOBILEGL_IPC_AUTH_BACKOFF_AFTER': '0'}
with supervisor(args.server, args.out / 'descriptors.log', extra_env=exhaust, nofile=16) as (
port, process, serverLog):
flood = []
try:
# The first connect waits for the supervisor to be listening (connect_control's
# retry); the rest are non-blocking: a blocking one stalls this loop for its whole
# timeout once the backlog is full, and the flood would never outrun the deadlines.
flood.append(connect_control(port))
for _ in range(47):
peer = socket.socket()
flood.append(peer)
peer.setblocking(False)
try:
peer.connect(('127.0.0.1', port))
except (BlockingIOError, ConnectionRefusedError):
pass # refused is the red shape: the supervisor is gone
paused = wait_for_log(serverLog, 'accept paused for', seconds=5)
alive = process.poll() is None
finally:
for peer in flood:
peer.close()
assert alive, f'the supervisor exited ({process.poll()}) when accept ran out of descriptors; see {serverLog}'
assert paused is not None, f'the supervisor never named its accept pause in {serverLog}'
time.sleep(1.0) # every flood connection has been read as closed (or is still in the backlog)
after_flood = exchange(port, schema, hello(schema, flatbuffers))
assert after_flood.get('code') == 2 and after_flood.get('expected', 0), (
f'after the descriptor flood a Hello was answered {after_flood}')
assert process.poll() is None, 'the supervisor died after the descriptor flood'
evidence['accept_out_of_descriptors'] = {'line': next(
l.strip() for l in paused.splitlines() if 'accept paused for' in l), 'afterFlood': after_flood}
# ---- ID-P7-44, f2-auth fix round: A CHILD THAT REFUSES ITS HELLO REFUSES WHAT WAS QUEUED --
#
# The supervisor forwards DataBinds to a child from the moment it forks it. A child that
# then refused its Hello (here: an authenticated Hello with fingerprint 0, refused at
# ValidatePeerHandshake) used to `_exit` with them still queued on the hand-off, and the
# exit dropped their descriptors - those peers' connections just ended. The window is
# microseconds, so it is held open with the server's test-only lever
# MOBILEGL_TEST_DELAY_SESSION_HANDSHAKE_MS: the child waits 1.5 s before its handshake, three
# DataBinds are routed into its queue meanwhile, and each must be refused by name when the
# child refuses its Hello - by the CHILD, after its wait (not at once by the supervisor).
# RED before the fix: "control channel closed before a complete frame" on all three.
delay = {'MOBILEGL_TEST_DELAY_SESSION_HANDSHAKE_MS': '1500'}
with supervisor(args.server, args.out / 'handshake-delay.log', extra_env=delay) as (port, process, serverLog):
control = connect_control(port)
extras = []
try:
control.settimeout(10)
control.sendall(hello(schema, flatbuffers))
deadline = time.monotonic() + 5
while not child_pids(process.pid):
assert time.monotonic() < deadline, 'the supervisor forked no child for an authenticated Hello'
time.sleep(.01)
began = time.monotonic()
extras = [open_data(port, schema, flatbuffers, os.urandom(16)) for _ in range(3)]
queued = []
for extra in extras:
extra.settimeout(10)
try:
answer = receive(extra, schema)
except (RuntimeError, OSError) as error:
answer = {'error': repr(error)}
answer['seconds'] = round(time.monotonic() - began, 3)
queued.append(answer)
refused_hello = receive(control, schema)
finally:
for extra in extras:
extra.close()
control.close()
assert refused_hello.get('code') == 2, refused_hello
for answer in queued:
assert answer.get('code') == 4 and answer.get('detail') == kDetailHandoff, (
f'a DataBind queued to a session child that then refused its Hello was answered {answer}, '
f'not refused by name (ID-P7-44); all: {queued}')
assert answer['seconds'] >= 1.0, (
f'the DataBind was answered after {answer["seconds"]} s - by the supervisor, not by the '
f'child it was queued to; all: {queued}')
evidence['data_bind_queued_before_refused_hello'] = {'hello': refused_hello, 'refused': queued}
assert process.poll() is None, 'the refused Hello killed the supervisor'
# ---- F fix round: THE SINGLE-SESSION SERVER CLOSES ITS LISTENER ONCE THE SESSION IS BOUND
#
# Without --serve the process is the session; it keeps the listener open only so the
# session's data connection can arrive on it (ListenerSource). Until this control nothing
# closed it afterwards, so the kernel went on completing TCP handshakes into a backlog no
# accept() would ever drain: a second client, or a retry, hung for its whole connect budget
# (kSpawnConnectTimeoutMs, 20 s) instead of the ECONNREFUSED it got before PH-7 (4). Now
# RunSession closes the listener the moment Accept returns, and a connect after `ready`
# is refused at once. RED before the fix: the connect below succeeds.
with supervisor(args.server, args.out / 'single-session.log', serve=False) as (port, process, serverLog):
with held_session(port, schema, flatbuffers,
hello(schema, flatbuffers, fingerprint=fingerprint)) as (control, welcome):
assert welcome.get('welcome', 0), welcome
ready = wait_for_log(serverLog, f'pid={welcome["welcome"]} transport=spawn role=server ready')
assert ready is not None, f'the single session never came up; see {serverLog}'
try:
second = socket.create_connection(('127.0.0.1', port), timeout=3)
except ConnectionRefusedError as refused:
second = None
at_connect = repr(refused)
else:
second.close()
assert second is None, (
'the single-session server still completed a TCP handshake after its data '
'connection bound; the listener was not closed')
evidence['single_session_listener'] = {'second_connect': at_connect}
(args.out / 'protocol-controls.json').write_text(json.dumps(evidence, indent=2))
print(json.dumps(evidence, indent=2))
return 0
if __name__ == '__main__':
sys.exit(main())