WillTools 2026-09-09 -- clean snapshot of the development repo for review
ci / test (push) Successful in 6m14s

History-free snapshot of the private development repository at 77a82eb.
Code, docs and the public catalogue only: no plant or job knowledge
layers, no client, job or producer names (tools/scrub.py clean against
the widened term list; a second word-level grep clean). The intro deck is
left out because its slide images cannot be checked for job drawings.
This commit is contained in:
2026-09-09 08:35:15 -05:00
commit 25592fa6c2
406 changed files with 223435 additions and 0 deletions
@@ -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')