Files
MobileGL/scripts/ci/tcp_supervisor_smoke.py
T
swung0x48 8c8fc31952 [MG_Remote, MG_Test, ci] (P7): the token compare runs a fixed 64-byte width, an ignored window ask is logged once, a nonce that could not be minted is refused by name, and a garbage first frame while busy is MalformedHello rather than Busy
- AuthToken.h: ConstantTimeTokenMatch loops kAuthTokenCompareBytes (64) iterations with both operands read as zero past their ends - its trip count no longer tells a peer the configured token's length; a token longer than 64 is still compared to its last byte (SocketTransportTest pins the 70-byte case)
- ServerSession.cpp: an ask in Hello.linkTerms above the granted terms logs one MGLOG_W naming both sides ("the ask is ignored and the session is server-sized"); the production client asks for nothing, so a session that was never mis-configured never logs it; SessionHandshakeTest's 64 GiB case requires the line (red before: absent)
- ServerSession.cpp: a MintNonce failure sends Refuse{LinkTerms} "stream data plane unavailable: the server could not mint a data nonce" before returning the fault it is (still exit 67, still counted) instead of a bare close
- ServerMain.cpp: a first frame with the wrong magic or over 1 MiB while a session is live is Refuse{MalformedHello} "first frame is not a control frame", drained before the close so the refusal is not RST away; Busy stays the answer for a well-formed frame that is not a DataBind
- tcp_supervisor_smoke.py: garbage_first_frame_while_busy (magic XXXX on a live session -> code 8; red before: code 7)
- fatal census unchanged: 79 abort sites, 3 refusal words (the new refusals go through the enum, not a literal)
2026-09-22 21:54:46 -04:00

654 lines
35 KiB
Python

#!/usr/bin/env python3
"""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.
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 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):
"""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."""
port = free_loopback_port()
base = log.with_suffix('.library.log')
env = supervisor_environment(base, kToken, same_build=same_build)
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)
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):
"""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."""
deadline = time.monotonic() + seconds
while True:
try:
return socket.create_connection(('127.0.0.1', port), timeout=3)
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')
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:
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', 'Refuse', 'Welcome')}
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):
with pair(port) as held:
with pair(port) as second:
busy = receive(second, schema)
assert busy.get('code') == 7, busy
evidence['busy'] = busy
layout = exchange(port, schema, hello(schema, flatbuffers))
assert layout.get('code') == 2 and layout.get('expected', 0), layout
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
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'
# 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 and the controls above have already
# moved it (the Busy probe holds a pair that never sends a Hello, and that child
# exits 67), so what is asserted is the DELTA, not an absolute.
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.
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'
# ---- 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
# ---- 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())