WillTools 2026-09-09 -- clean snapshot of the development repo for review
ci / test (push) Successful in 6m18s
ci / test (push) Successful in 6m18s
History-free snapshot of the private development repository at cbe45c5 (2026-09-09). Code, docs and public catalogue only; no plant or job knowledge layers, no client or job names (tools/scrub.py clean). The intro deck is left out of this snapshot because its slide images could not be checked for job drawings.
This commit is contained in:
@@ -0,0 +1,578 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""`tools/rvt.py` must never hand back another request's answer.
|
||||
|
||||
THE INCIDENT (measured 2026-09-01, twice, during a 282-piece sweep)
|
||||
-------------------------------------------------------------------
|
||||
One request timed out client-side::
|
||||
|
||||
[260/282] no response from Revit within 300s.
|
||||
|
||||
and from then on the pairing was off by one. `tools/sweep.py`'s
|
||||
`open_model_title()` asked for `doc.Title` and was handed the SWEEP's progress
|
||||
string, which it printed as the document title; an agent polling `doc.Title` as
|
||||
a drift guard read the same stolen answer as "the model changed underneath me"
|
||||
and raised a false alarm that cost real time. Every file on disk was correct.
|
||||
Only the attribution was wrong -- and **a response from the wrong request is
|
||||
indistinguishable from a valid answer**, which is the exact shape of silence
|
||||
this tool is built against.
|
||||
|
||||
WHERE THE DESYNC LIVES -- not in this client
|
||||
--------------------------------------------
|
||||
`rvt.py` is stateless per request: `urlopen` opens a new connection each call
|
||||
and urllib sends `Connection: close`. There is no client socket to go out of
|
||||
step, so "drain the socket" would have been a fix for a mechanism that is not
|
||||
there. The shared state is inside pyRevit, in `routes/server/server.py`: ONE
|
||||
module-level `REQUEST_HNDLR` / `EVENT_HNDLR` pair, whose single `_response` /
|
||||
`_done` slot every connection thread of the ThreadingMixIn server resets, waits
|
||||
on, and reads. A client giving up cancels nothing -- the server thread keeps
|
||||
spinning and Revit still owes it an answer -- so the next request's thread can
|
||||
be released by, and read, the abandoned request's response.
|
||||
|
||||
These tests reproduce that against a stub server (no Revit, no licence, host
|
||||
CPython) and show it refused.
|
||||
|
||||
WHAT THIS SUITE CANNOT DO is prove the in-Revit half echoes anything: `startup.py`
|
||||
does `from pyrevit import routes` at module scope and cannot be imported here
|
||||
(same category as `vibe.ticketqc`; see `tests/conftest.py`). Its half is checked
|
||||
by source/AST, exactly as `test_bridge_query_channel.py` checks the JSON-body
|
||||
fix, and it does not take effect until Revit is RESTARTED (never a pyRevit
|
||||
reload). Until then the client meets an in-Revit half that echoes no id, which
|
||||
is the case `test_no_id_*` pins down.
|
||||
"""
|
||||
import ast
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import threading
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import pytest
|
||||
|
||||
_EXT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
TOOLS = os.path.join(os.path.dirname(_EXT), 'tools')
|
||||
if TOOLS not in sys.path:
|
||||
sys.path.insert(0, TOOLS)
|
||||
|
||||
import rvt # noqa: E402
|
||||
|
||||
STARTUP = os.path.join(_EXT, 'startup.py')
|
||||
|
||||
|
||||
# --------------------------------------------------------------- the stub bridge
|
||||
|
||||
class StubBridge(object):
|
||||
"""A fake bridge whose answers are decided by a policy callable.
|
||||
|
||||
`policy(path, method, body)` returns the JSON dict to answer with, or None
|
||||
to hang until the client gives up -- which is how the 300s timeout is
|
||||
reproduced in a second. Every request is recorded in `.requests`.
|
||||
"""
|
||||
|
||||
def __init__(self, policy, hang=2.0):
|
||||
self.policy = policy
|
||||
self.hang = hang
|
||||
self.requests = []
|
||||
self._stop = threading.Event()
|
||||
bridge = self
|
||||
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
protocol_version = 'HTTP/1.0'
|
||||
|
||||
def log_message(self, *args):
|
||||
pass
|
||||
|
||||
def handle_one_request(self):
|
||||
try:
|
||||
BaseHTTPRequestHandler.handle_one_request(self)
|
||||
except Exception:
|
||||
self.close_connection = True
|
||||
|
||||
def _serve(self):
|
||||
length = int(self.headers.get('content-length') or 0)
|
||||
raw = self.rfile.read(length) if length else b''
|
||||
try:
|
||||
body = json.loads(raw.decode('utf-8')) if raw else {}
|
||||
except ValueError:
|
||||
body = {}
|
||||
path = urlparse(self.path).path
|
||||
bridge.requests.append((path, self.command, body))
|
||||
answer = bridge.policy(path, self.command, body)
|
||||
if answer is None:
|
||||
# The abandoned request: Revit is still working, the client
|
||||
# will time out, and the answer never reaches this socket.
|
||||
# Where it DOES go is the next test's problem, which is the
|
||||
# whole point.
|
||||
bridge._stop.wait(bridge.hang)
|
||||
self.close_connection = True
|
||||
return
|
||||
data = json.dumps(answer).encode('utf-8')
|
||||
self.send_response(200)
|
||||
self.send_header('Content-Type', 'application/json')
|
||||
self.send_header('Content-Length', str(len(data)))
|
||||
self.end_headers()
|
||||
self.wfile.write(data)
|
||||
|
||||
do_GET = _serve
|
||||
do_POST = _serve
|
||||
|
||||
self.server = ThreadingHTTPServer(('127.0.0.1', 0), Handler)
|
||||
self.server.daemon_threads = True
|
||||
# Do not join the connection threads at close: one of them is
|
||||
# deliberately sitting on an abandoned request, which is the whole
|
||||
# point of the fixture and not something to wait out per test.
|
||||
self.server.block_on_close = False
|
||||
self.port = self.server.server_address[1]
|
||||
# 0.05s, not the 0.5s default: shutdown() polls at this interval and
|
||||
# half a second of teardown per test is most of this suite's runtime.
|
||||
self.thread = threading.Thread(
|
||||
target=self.server.serve_forever, kwargs={'poll_interval': 0.05})
|
||||
self.thread.daemon = True
|
||||
self.thread.start()
|
||||
|
||||
def close(self):
|
||||
self._stop.set()
|
||||
self.server.shutdown()
|
||||
self.server.server_close()
|
||||
|
||||
def bodies(self):
|
||||
return [body for _path, _method, body in self.requests]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def serve(tmp_path, monkeypatch):
|
||||
"""Start a stub bridge and point every file/port `rvt.py` reads at it."""
|
||||
log = tmp_path / 'sessions.log'
|
||||
log.write_text('2026-09-01T09:00:00|4242|2026-09-01T08:00:00.0000000\n',
|
||||
encoding='utf-8')
|
||||
monkeypatch.setattr(rvt, 'SESSION_LOG', log)
|
||||
monkeypatch.setattr(rvt, 'LATCH_DIR', tmp_path)
|
||||
monkeypatch.setattr(rvt, 'TIMEOUT', 0.6)
|
||||
monkeypatch.setattr(rvt, 'DRAIN_TIMEOUTS', (0.6, 0.6))
|
||||
monkeypatch.setattr(rvt, '_STRICT', False)
|
||||
monkeypatch.delenv(rvt.STRICT_ENV, raising=False)
|
||||
|
||||
made = []
|
||||
|
||||
def _serve(policy, hang=2.0):
|
||||
bridge = StubBridge(policy, hang=hang)
|
||||
made.append(bridge)
|
||||
monkeypatch.setenv(rvt.PORT_ENV, str(bridge.port))
|
||||
return bridge
|
||||
|
||||
yield _serve
|
||||
|
||||
for bridge in made:
|
||||
bridge.close()
|
||||
|
||||
|
||||
def echo(answer):
|
||||
"""A policy that answers `answer`, stamped with the caller's own id."""
|
||||
def policy(path, method, body):
|
||||
out = dict(answer)
|
||||
if body.get(rvt.CORRELATION_KEY) is not None:
|
||||
out[rvt.CORRELATION_KEY] = body[rvt.CORRELATION_KEY]
|
||||
return out
|
||||
return policy
|
||||
|
||||
|
||||
def like_exec(body, default):
|
||||
"""What a healthy `/exec` would answer.
|
||||
|
||||
Enough of it to be honest about the one snippet shape that matters here:
|
||||
`result = '<literal>'`, which is what the drain probe sends. A stub that
|
||||
ignored the code would let a probe "identify itself" without the bridge
|
||||
having run anything, which is the opposite of what the probe proves.
|
||||
"""
|
||||
code = (body.get('code') or '').strip()
|
||||
match = re.match(r"^result = '([^']*)'$", code)
|
||||
if match:
|
||||
return {'ok': True, 'stdout': '', 'result': match.group(1)}
|
||||
return dict(default)
|
||||
|
||||
|
||||
# ------------------------------------------------------------- the normal path
|
||||
|
||||
def test_a_matching_id_returns_the_answer_unchanged(serve, capsys):
|
||||
"""The shape callers see must not move: id in, id checked, id stripped."""
|
||||
serve(echo({'ok': True, 'doc_title': 'sample_job', 'pid': 4242}))
|
||||
res = rvt.call('/status')
|
||||
assert res == {'ok': True, 'doc_title': 'sample_job', 'pid': 4242}
|
||||
assert rvt.CORRELATION_KEY not in res, (
|
||||
'the correlation id leaked into the value handed to callers -- '
|
||||
'sweep.py and snapshot_cycle.py parse these dicts')
|
||||
assert rvt.UNVERIFIED_KEY not in res
|
||||
assert capsys.readouterr().err == '', (
|
||||
'a verified answer must be silent; a warning on every good call is a '
|
||||
'warning nobody reads')
|
||||
|
||||
|
||||
def test_every_request_carries_an_id_including_the_GET_routes(serve):
|
||||
"""The id travels in the JSON body, on GET too.
|
||||
|
||||
Not a header: pyRevit's `base.Request` sets `_headers = {}` and
|
||||
`HttpRequestHandler._prepare_request` never fills it, so a header would be
|
||||
discarded exactly as the query string is. The server parses a body whenever
|
||||
Content-Length is set, whatever the method -- so this is the one channel
|
||||
that reaches `/status` and `/selection` as well as the POST routes.
|
||||
"""
|
||||
bridge = serve(echo({'ok': True}))
|
||||
rvt.call('/status')
|
||||
rvt.call('/params', 'POST', {'id': 123456})
|
||||
|
||||
path, method, body = bridge.requests[0]
|
||||
assert (path, method) == ('/vibe/status', 'GET')
|
||||
assert body.get(rvt.CORRELATION_KEY), (
|
||||
'the GET route sent no correlation id -- a GET with no body is exactly '
|
||||
'the request whose answer cannot be attributed')
|
||||
|
||||
_path, _method, body2 = bridge.requests[1]
|
||||
assert body2['id'] == 123456, 'the payload must survive the stamping'
|
||||
assert body2[rvt.CORRELATION_KEY] != body[rvt.CORRELATION_KEY], (
|
||||
'two requests got the same id -- then a match proves nothing')
|
||||
|
||||
|
||||
# ------------------------------------------------------------- a stolen answer
|
||||
|
||||
def test_a_mismatched_id_is_refused_and_names_both_ids(serve, tmp_path, capsys):
|
||||
"""The refusal is loud, and it is a refusal -- never a returned value."""
|
||||
serve(lambda p, m, b: {'ok': True, 'result': 'did 10 of 282',
|
||||
rvt.CORRELATION_KEY: 'somebody-elses-id'})
|
||||
with pytest.raises(SystemExit) as exc:
|
||||
rvt.call('/exec', 'POST', {'code': 'result = doc.Title'})
|
||||
assert exc.value.code == 1
|
||||
err = capsys.readouterr().err
|
||||
assert 'somebody-elses-id' in err, 'the id that answered must be named'
|
||||
assert 'REFUSING' in err
|
||||
assert 'did 10 of 282' not in err, (
|
||||
"the stolen body must not be printed as if it were an answer")
|
||||
assert list(tmp_path.glob('.bridge-timeout-*.json')), (
|
||||
'a proven mispairing must latch the channel: it is proof the bridge '
|
||||
'is serving somebody else\'s answers, and the next call must drain')
|
||||
|
||||
|
||||
# --------------------------------------------------- an in-Revit half with no id
|
||||
|
||||
def test_no_id_warns_and_flags_but_still_answers(serve, capsys):
|
||||
"""The default against an OLD in-Revit half: warn, flag, return.
|
||||
|
||||
Refusing here would brick the bridge for every tool until the owner
|
||||
restarts Revit -- and the in-Revit half cannot be loaded any other way,
|
||||
because reloading pyRevit kills Revit outright. A third state (`_unverified`
|
||||
plus a warning naming the risk) keeps "could not judge" distinct from
|
||||
"judged" without turning a data-attribution hazard into an outage.
|
||||
"""
|
||||
serve(lambda p, m, b: {'ok': True, 'doc_title': 'sample_job'})
|
||||
res = rvt.call('/status')
|
||||
assert res['ok'] is True
|
||||
assert res[rvt.UNVERIFIED_KEY] is True, (
|
||||
'an unattributable answer must carry its own doubt into the value; a '
|
||||
'warning on a stream somebody captured is not enough')
|
||||
err = capsys.readouterr().err
|
||||
assert 'could not be attributed' in err
|
||||
assert 'RESTART Revit' in err, 'the warning must say how to fix it'
|
||||
|
||||
|
||||
def test_no_id_is_refused_under_strict(serve, monkeypatch):
|
||||
serve(lambda p, m, b: {'ok': True, 'doc_title': 'sample_job'})
|
||||
monkeypatch.setattr(rvt, '_STRICT', True)
|
||||
with pytest.raises(SystemExit):
|
||||
rvt.call('/status')
|
||||
|
||||
|
||||
def test_no_id_is_refused_under_the_strict_env_var(serve, monkeypatch):
|
||||
"""`sweep.py` and `snapshot_cycle.py` shell out; the env is how they opt in."""
|
||||
serve(lambda p, m, b: {'ok': True, 'doc_title': 'sample_job'})
|
||||
monkeypatch.setenv(rvt.STRICT_ENV, '1')
|
||||
with pytest.raises(SystemExit):
|
||||
rvt.call('/status')
|
||||
|
||||
|
||||
def test_strict_flag_reaches_the_client(serve, monkeypatch):
|
||||
"""`rvt.py --strict status` -- the flag sits before the subcommand."""
|
||||
serve(echo({'ok': True, 'doc_title': 'sample_job'}))
|
||||
monkeypatch.setattr(rvt, '_STRICT', False)
|
||||
assert rvt.main(['--strict', 'status']) == 0
|
||||
assert rvt._STRICT is True
|
||||
|
||||
|
||||
# ------------------------------------------------------------- the timeout
|
||||
|
||||
def test_a_timeout_is_an_explicit_could_not_judge(serve, tmp_path, capsys):
|
||||
"""A timed-out call may not return anything a caller can read as data."""
|
||||
serve(lambda p, m, b: None)
|
||||
with pytest.raises(SystemExit) as exc:
|
||||
rvt.call('/exec', 'POST', {'code': 'result = doc.Title'})
|
||||
assert exc.value.code == 1, (
|
||||
'a timeout must be a non-zero exit: sweep.py reads the exit code, and '
|
||||
'a zero would make an empty stdout look like an empty answer')
|
||||
err = capsys.readouterr().err
|
||||
assert 'COULD NOT JUDGE' in err
|
||||
assert 'may still be running inside' in err
|
||||
latches = list(tmp_path.glob('.bridge-timeout-*.json'))
|
||||
assert len(latches) == 1, 'the timeout must latch the channel'
|
||||
latch = json.loads(latches[0].read_text(encoding='utf-8'))
|
||||
assert latch['path'] == '/exec'
|
||||
assert latch['port'] == rvt._port(), (
|
||||
'the latch is per port -- which Revit owns which port is decided by '
|
||||
'start order, so one session\'s timeout must not gate another\'s call')
|
||||
|
||||
|
||||
def test_the_latch_is_per_port(serve, tmp_path, monkeypatch):
|
||||
serve(lambda p, m, b: None)
|
||||
with pytest.raises(SystemExit):
|
||||
rvt.call('/status')
|
||||
monkeypatch.setenv(rvt.PORT_ENV, str(rvt._port() + 1))
|
||||
assert rvt._read_latch() is None, (
|
||||
'a latch written for one port blocked a call to another')
|
||||
|
||||
|
||||
# --------------------------------------------- the desync, reproduced and fixed
|
||||
|
||||
def _desync_policy(state, stale_body, good_body, stamp=True):
|
||||
"""Hang once, then serve the abandoned request's answer to the next caller.
|
||||
|
||||
That is what pyRevit's single shared response slot does after a client-side
|
||||
timeout: the late answer is still sitting there when the next connection
|
||||
thread reads it. It is served exactly once, because reading it is what
|
||||
takes it out of the slot.
|
||||
"""
|
||||
def policy(path, method, body):
|
||||
rid = body.get(rvt.CORRELATION_KEY)
|
||||
if state['phase'] == 'hang':
|
||||
state['phase'] = 'stale'
|
||||
state['rid_a'] = rid
|
||||
return None
|
||||
if state['phase'] == 'stale':
|
||||
state['phase'] = 'normal'
|
||||
out = dict(stale_body)
|
||||
if stamp and state['rid_a'] is not None:
|
||||
out[rvt.CORRELATION_KEY] = state['rid_a']
|
||||
return out
|
||||
out = like_exec(body, good_body)
|
||||
if stamp and rid is not None:
|
||||
out[rvt.CORRELATION_KEY] = rid
|
||||
return out
|
||||
return policy
|
||||
|
||||
|
||||
def test_the_2026_09_01_desync_is_drained_instead_of_returned(serve, tmp_path,
|
||||
capsys):
|
||||
"""The whole incident, end to end, against the stub.
|
||||
|
||||
Call A times out. Call B asks for `doc.Title` and, on a bridge with a stale
|
||||
answer in the slot, would be handed "did 10 of 282" -- which is exactly
|
||||
what `open_model_title()` printed as a document title on 2026-09-01. Here
|
||||
the drain consumes the stale answer before B is sent, and B gets its own.
|
||||
"""
|
||||
state = {'phase': 'hang'}
|
||||
stale = {'ok': True, 'stdout': 'did 10 of 282\n', 'result': 'did 10 of 282'}
|
||||
bridge = serve(_desync_policy(state, stale,
|
||||
{'ok': True, 'result': 'sample_job'}))
|
||||
|
||||
with pytest.raises(SystemExit):
|
||||
rvt.call('/exec', 'POST', {'code': 'measure()'})
|
||||
capsys.readouterr()
|
||||
|
||||
res = rvt.call('/exec', 'POST', {'code': 'result = doc.Title'})
|
||||
assert res == {'ok': True, 'result': 'sample_job'}, (
|
||||
'the second call was served the first call\'s body -- this is the '
|
||||
'defect, not a variation of it')
|
||||
assert res['result'] != 'did 10 of 282'
|
||||
|
||||
err = capsys.readouterr().err
|
||||
assert 'Draining' in err and 'channel clear' in err, (
|
||||
'the drain must say it happened: a channel silently repaired is a '
|
||||
'channel nobody knows was broken')
|
||||
assert not list(tmp_path.glob('.bridge-timeout-*.json')), (
|
||||
'a drained channel must clear its latch, or every later call pays for '
|
||||
'one timeout forever')
|
||||
|
||||
# The stale answer went to a drain probe, not to the caller. The probe is
|
||||
# an /exec whose snippet returns a nonce, so it is self-identifying.
|
||||
codes = [b.get('code', '') for b in bridge.bodies()]
|
||||
assert any('vibe-drain-' in c for c in codes), (
|
||||
'nothing was actually sent to consume the stale reply')
|
||||
|
||||
|
||||
def test_the_desync_is_drained_against_an_old_in_revit_half(serve, capsys):
|
||||
"""The same recovery, with an in-Revit half that echoes no ids at all.
|
||||
|
||||
This is the half that is running until the owner restarts Revit, so it is
|
||||
the case that matters most today. The drain does not depend on the echo:
|
||||
its probe asks Revit to return a nonce this process just invented, and an
|
||||
answer whose `result` is not that nonce is somebody else's, id or no id.
|
||||
"""
|
||||
state = {'phase': 'hang'}
|
||||
serve(_desync_policy(state,
|
||||
{'ok': True, 'result': 'did 10 of 282'},
|
||||
{'ok': True, 'result': 'sample_job'},
|
||||
stamp=False))
|
||||
|
||||
with pytest.raises(SystemExit):
|
||||
rvt.call('/exec', 'POST', {'code': 'measure()'})
|
||||
capsys.readouterr()
|
||||
|
||||
res = rvt.call('/exec', 'POST', {'code': 'result = doc.Title'})
|
||||
assert res['result'] == 'sample_job'
|
||||
assert res[rvt.UNVERIFIED_KEY] is True, (
|
||||
'an old half still cannot attribute its answer, and the drain does not '
|
||||
'change that -- it only removes the stale one from the channel')
|
||||
|
||||
|
||||
def test_a_channel_that_stays_dirty_refuses_rather_than_guesses(serve, capsys):
|
||||
"""When the drain cannot identify itself, nothing real is sent."""
|
||||
def policy(path, method, body):
|
||||
if not policy.hung:
|
||||
policy.hung = True
|
||||
return None
|
||||
return {'ok': True, 'result': 'did 10 of 282'} # forever somebody else's
|
||||
policy.hung = False
|
||||
serve(policy)
|
||||
|
||||
with pytest.raises(SystemExit):
|
||||
rvt.call('/status')
|
||||
capsys.readouterr()
|
||||
|
||||
with pytest.raises(SystemExit) as exc:
|
||||
rvt.call('/exec', 'POST', {'code': 'result = doc.Title'})
|
||||
assert exc.value.code == 1
|
||||
err = capsys.readouterr().err
|
||||
assert 'REFUSING to send' in err
|
||||
assert 'cannot be proven clean' in err
|
||||
|
||||
|
||||
def test_a_latched_channel_whose_bridge_refuses_does_not_pretend_to_be_clean(
|
||||
serve, capsys):
|
||||
"""A refusal answers "which session am I on", not "is the channel clean"."""
|
||||
def policy(path, method, body):
|
||||
if not policy.hung:
|
||||
policy.hung = True
|
||||
return None
|
||||
return {'ok': False, 'refused': True, 'doc_title': 'sample_job',
|
||||
'error': 'REFUSED: bridge.refuse_titles'}
|
||||
policy.hung = False
|
||||
serve(policy)
|
||||
|
||||
with pytest.raises(SystemExit):
|
||||
rvt.call('/status')
|
||||
capsys.readouterr()
|
||||
with pytest.raises(SystemExit):
|
||||
rvt.call('/status')
|
||||
assert 'refusing every route' in capsys.readouterr().err
|
||||
|
||||
|
||||
# ------------------------------------------------- the in-Revit half, by source
|
||||
|
||||
def _route_functions(tree):
|
||||
"""Every function carrying an `@api.route(...)` decorator."""
|
||||
out = []
|
||||
for node in ast.walk(tree):
|
||||
if not isinstance(node, ast.FunctionDef):
|
||||
continue
|
||||
for dec in node.decorator_list:
|
||||
if (isinstance(dec, ast.Call)
|
||||
and isinstance(dec.func, ast.Attribute)
|
||||
and dec.func.attr == 'route'):
|
||||
path = (dec.args[0].value
|
||||
if dec.args and isinstance(dec.args[0], ast.Constant)
|
||||
else None)
|
||||
out.append((path, node))
|
||||
return out
|
||||
|
||||
|
||||
def _startup_tree():
|
||||
with open(STARTUP, 'r', encoding='utf-8') as fh:
|
||||
return ast.parse(fh.read())
|
||||
|
||||
|
||||
def test_every_route_takes_request_and_replies_through_reply():
|
||||
"""A new route that forgets to echo is a new silent desync.
|
||||
|
||||
Enforced here rather than trusted, because the failure is invisible from
|
||||
inside Revit: an unstamped answer looks exactly like a correct one, and the
|
||||
client would only report it as unverifiable long after the route shipped.
|
||||
"""
|
||||
tree = _startup_tree()
|
||||
routes = _route_functions(tree)
|
||||
assert routes, 'no @api.route handlers found in startup.py -- did it move?'
|
||||
for path, node in routes:
|
||||
args = [a.arg for a in node.args.args]
|
||||
assert 'request' in args, (
|
||||
'{0} ({1}) does not take `request`, so it cannot echo the caller\'s '
|
||||
'correlation id and every answer it gives is unattributable.'
|
||||
.format(path, node.name))
|
||||
calls = [n.func.id for n in ast.walk(node)
|
||||
if isinstance(n, ast.Call) and isinstance(n.func, ast.Name)]
|
||||
assert '_reply' in calls, (
|
||||
'{0} ({1}) does not return through _reply(request, ...) -- its '
|
||||
'answers carry no correlation id, and rvt.py cannot tell one of '
|
||||
'them from an abandoned request\'s answer.'.format(path, node.name))
|
||||
|
||||
|
||||
def test_the_in_revit_half_reads_the_id_from_the_body_only():
|
||||
"""Not from a header. `Request._headers` is never populated by the server."""
|
||||
tree = _startup_tree()
|
||||
funcs = {n.name: n for n in ast.walk(tree) if isinstance(n, ast.FunctionDef)}
|
||||
assert '_rid_of' in funcs and '_reply' in funcs
|
||||
assert '_body' in [n.func.id for n in ast.walk(funcs['_rid_of'])
|
||||
if isinstance(n, ast.Call) and isinstance(n.func, ast.Name)]
|
||||
hits = [n for n in ast.walk(tree)
|
||||
if isinstance(n, ast.Attribute) and n.attr == 'headers']
|
||||
assert not hits, (
|
||||
'startup.py reads request headers at line(s) {0} -- pyRevit\'s routes '
|
||||
'server builds its Request without them (`_headers = {{}}`, never '
|
||||
'filled), so that channel is as silently empty as the query string was.'
|
||||
.format([getattr(n, 'lineno', '?') for n in hits]))
|
||||
|
||||
|
||||
def test_client_and_server_agree_on_the_key():
|
||||
"""One string, two files. A typo here is a bridge that never correlates."""
|
||||
tree = _startup_tree()
|
||||
keys = [n.value.value for n in ast.walk(tree)
|
||||
if isinstance(n, ast.Assign)
|
||||
and any(isinstance(t, ast.Name) and t.id == 'RID_KEY'
|
||||
for t in n.targets)
|
||||
and isinstance(n.value, ast.Constant)]
|
||||
assert keys == [rvt.CORRELATION_KEY], (
|
||||
'startup.py stamps {0!r} but tools/rvt.py checks {1!r}'
|
||||
.format(keys, rvt.CORRELATION_KEY))
|
||||
|
||||
|
||||
def test_the_drain_probe_snippet_is_a_valid_ironpython_statement():
|
||||
"""The probe is code that runs inside Revit; it has to be legal there.
|
||||
|
||||
IronPython 2.7: no f-strings, and `result = <literal>` is a statement, so
|
||||
`/exec` falls through to its exec branch and reads `result` back out.
|
||||
"""
|
||||
nonce = 'vibe-drain-deadbeef'
|
||||
snippet = 'result = {0!r}'.format(nonce)
|
||||
ns = {}
|
||||
exec(compile(snippet, '<probe>', 'exec'), ns)
|
||||
assert ns['result'] == nonce
|
||||
|
||||
|
||||
def test_call_still_refuses_a_bridge_that_survived_a_reload(serve, tmp_path,
|
||||
capsys):
|
||||
"""The never-reload tripwire runs BEFORE any of this. Order matters.
|
||||
|
||||
Correlation is about which answer you got; the reload guard is about
|
||||
whether asking at all kills Revit. A drain probe sent into a post-reload
|
||||
bridge would be the fatal request.
|
||||
"""
|
||||
serve(echo({'ok': True}))
|
||||
log = tmp_path / 'sessions.log'
|
||||
log.write_text('2026-09-01T09:00:00|4242|2026-09-01T08:00:00.0000000\n'
|
||||
'2026-09-01T09:30:00|4242|2026-09-01T08:00:00.0000000\n',
|
||||
encoding='utf-8')
|
||||
(tmp_path / '.bridge-timeout-{0}.json'.format(rvt._port())).write_text(
|
||||
'{"path": "/exec", "reason": "no answer", "port": %d}' % rvt._port(),
|
||||
encoding='utf-8')
|
||||
with pytest.raises(SystemExit):
|
||||
rvt.call('/status')
|
||||
err = capsys.readouterr().err
|
||||
assert 'reloaded' in err
|
||||
assert 'Draining' not in err, (
|
||||
'the drain probe was sent to a bridge that has survived a pyRevit '
|
||||
'reload -- that request is the one that kills Revit')
|
||||
Reference in New Issue
Block a user