358 lines
16 KiB
Python
358 lines
16 KiB
Python
"""Real pipes/processes and real loopback HTTP; no native runtime activation."""
|
|
import concurrent.futures
|
|
import http.client
|
|
import json
|
|
import os
|
|
from pathlib import Path
|
|
import queue
|
|
import subprocess
|
|
import sys
|
|
import threading
|
|
import time
|
|
|
|
import native_speech_mux as muxmod
|
|
|
|
|
|
# Fake engine is a separate process speaking complete JSONL, not mocked Popen.
|
|
ENGINE = r'''
|
|
import json, os, sys, time
|
|
if '--probe' in sys.argv:
|
|
print(json.dumps({'argv':sys.argv[1:], 'cwd':os.getcwd(), 'marker':os.getenv('MUX_TEST_MARKER')}))
|
|
sys.stderr.write('original-stderr\n')
|
|
sys.exit(23)
|
|
for raw in sys.stdin.buffer:
|
|
try: m=json.loads(raw)
|
|
except ValueError:
|
|
sys.stdout.buffer.write(raw); sys.stdout.buffer.flush(); continue
|
|
method=m.get('method')
|
|
def emit(v):
|
|
sys.stdout.buffer.write(json.dumps(v).encode()+b'\n'); sys.stdout.buffer.flush()
|
|
if method=='thread/start':
|
|
emit({'id':m['id'],'result':{'thread':{'id':'thread-A'}}})
|
|
elif method=='thread/realtime/start':
|
|
emit({'method':'thread/realtime/started','params':{'threadId':'thread-A','realtimeSessionId':'rt'}})
|
|
elif method=='thread/realtime/stop':
|
|
emit({'method':'thread/realtime/closed','params':{'threadId':'thread-A','reason':None}})
|
|
elif method=='error-event':
|
|
emit({'method':'thread/realtime/error','params':{'threadId':'thread-A','message':'failed'}})
|
|
elif method=='thread/realtime/appendSpeech':
|
|
emit({'method':'item/commandExecution/requestApproval','id':m['id'],'params':{'command':'test'}})
|
|
emit({'method':'seen-speech','params':m['params']})
|
|
emit({'id':m['id'], 'error':{'code':-1,'message':'no'}} if m['params']['text']=='rpc-error' else {'id':m['id'],'result':{}})
|
|
elif method=='delay':
|
|
time.sleep(.25)
|
|
sys.stdout.buffer.write(raw); sys.stdout.buffer.flush()
|
|
elif method=='pause':
|
|
emit({'method':'paused'})
|
|
while not os.path.exists(m['params']['release']): time.sleep(.01)
|
|
emit({'method':'released'})
|
|
elif method=='exit':
|
|
sys.exit(17)
|
|
else:
|
|
sys.stdout.buffer.write(raw); sys.stdout.buffer.flush()
|
|
sys.stdout.buffer.write(b'{"method":"saw-eof"}\n'); sys.stdout.buffer.flush()
|
|
sys.stderr.write('engine-eof\n')
|
|
sys.exit(19)
|
|
'''
|
|
|
|
|
|
class Harness:
|
|
def __init__(self, tmp_path):
|
|
engine = tmp_path / 'engine.py'
|
|
engine.write_text(ENGINE)
|
|
self.mux = muxmod.NativeSpeechMux([sys.executable, '-u', str(engine), 'app-server'], observer=True)
|
|
ir, iw = os.pipe()
|
|
outr, outw = os.pipe()
|
|
self.source = os.fdopen(ir, 'rb', buffering=0)
|
|
self.input = os.fdopen(iw, 'wb', buffering=0)
|
|
self.target = os.fdopen(outw, 'wb', buffering=0)
|
|
self.output = os.fdopen(outr, 'rb')
|
|
self.lines = queue.Queue()
|
|
self.code = None
|
|
self.error = None
|
|
self.url, self.token = self.mux.start_inlet()
|
|
def run():
|
|
try:
|
|
self.code = self.mux.run(self.source, self.target)
|
|
except BaseException as e:
|
|
self.error = e
|
|
finally:
|
|
self.target.close()
|
|
self.thread = threading.Thread(target=run, daemon=True)
|
|
self.thread.start()
|
|
def collect():
|
|
for raw in self.output:
|
|
self.lines.put(raw)
|
|
self.collector = threading.Thread(target=collect, daemon=True)
|
|
self.collector.start()
|
|
|
|
def send(self, value):
|
|
raw = value if isinstance(value, bytes) else json.dumps(value).encode()+b'\n'
|
|
self.input.write(raw)
|
|
|
|
def recv(self):
|
|
return self.lines.get(timeout=8)
|
|
|
|
def http(self, path, body, token=None):
|
|
conn = http.client.HTTPConnection('127.0.0.1', int(self.url.rsplit(':', 1)[1]), timeout=5)
|
|
conn.request('POST', path, json.dumps(body), {'Authorization':'Bearer '+(token if token is not None else self.token)})
|
|
response = conn.getresponse()
|
|
data = response.read()
|
|
conn.close()
|
|
return response.status, json.loads(data) if response.status == 200 else data
|
|
|
|
def start(self):
|
|
self.send({'id':1, 'method':'thread/start','params':{}})
|
|
assert json.loads(self.recv())['result']['thread']['id'] == 'thread-A'
|
|
assert self.mux.snapshot()['threads']['thread-A']['active'] is False
|
|
self.send({'id':2,'method':'thread/realtime/start','params':{'threadId':'thread-A'}})
|
|
assert json.loads(self.recv())['method'] == 'thread/realtime/started'
|
|
return self.bind()
|
|
|
|
def bind(self):
|
|
generation = self.mux.snapshot()['threads']['thread-A']['generation']
|
|
b = muxmod.Binding('tenant','bekci','rev',self.mux.connection_id,'thread-A',generation)
|
|
assert self.mux.bind(b)
|
|
return self.notice(generation)
|
|
|
|
def notice(self, generation=1, **changes):
|
|
now = time.time()
|
|
return dict(notice_id='notice-1',tenant_id='tenant',bekci_session_id='bekci',history_revision='rev',
|
|
connection_id=self.mux.connection_id,thread_id='thread-A',expected_generation=generation,
|
|
issued_at=now,expires_at=now+30,text='düzeltme', **changes)
|
|
|
|
def close(self):
|
|
self.input.close()
|
|
self.thread.join(10)
|
|
if self.thread.is_alive():
|
|
self.mux._process.kill()
|
|
self.thread.join(5)
|
|
raise AssertionError('mux failed to exit after EOF')
|
|
self.collector.join(3)
|
|
self.source.close()
|
|
self.output.close()
|
|
assert self.error is None
|
|
|
|
|
|
def test_native_notice_rpc_and_approval_are_separate(tmp_path):
|
|
h = Harness(tmp_path)
|
|
try:
|
|
notice = h.start()
|
|
assert h.http('/notice', notice, 'wrong')[0] == 401
|
|
assert h.http('/notice', notice)[1]['status'] == 'queued'
|
|
approval = json.loads(h.recv())
|
|
assert approval['method'] == 'item/commandExecution/requestApproval'
|
|
assert approval['id'].startswith('bekci-speech-')
|
|
assert json.loads(h.recv()) == {'method':'seen-speech','params':{'threadId':'thread-A','text':'düzeltme'}}
|
|
answer = (json.dumps({'id':approval['id'],'result':{'decision':'decline'}}, separators=(', ', ': '))+'\r\n').encode()
|
|
h.send(answer)
|
|
assert h.recv() == answer
|
|
assert h.http('/notice', notice)[1]['status'] == 'rpc_accepted'
|
|
assert h.lines.empty() # native own-result never leaked to desktop
|
|
assert h.http('/rpc', {'method':'turn/start'})[0] == 404
|
|
assert h.http('/notice', {**notice,'notice_id':'err','text':'rpc-error'})[1]['status'] == 'queued'
|
|
h.recv(); h.recv()
|
|
h.send(b'{"method":"barrier"}\n')
|
|
assert h.recv() == b'{"method":"barrier"}\n'
|
|
assert h.http('/notice', {**notice,'notice_id':'err','text':'rpc-error'})[1]['status'] == 'rpc_error'
|
|
finally:
|
|
h.close()
|
|
assert h.code == 19
|
|
assert json.loads(h.recv())['method'] == 'saw-eof'
|
|
|
|
|
|
def test_unbound_schema_ttl_tenant_generation_and_restart(tmp_path):
|
|
h = Harness(tmp_path)
|
|
try:
|
|
n = h.notice()
|
|
assert h.http('/notice', n)[1]['status'] == 'binding_not_established'
|
|
n = h.start()
|
|
for delta, status in [({'method':'turn/start'},'invalid_schema'),
|
|
({'expected_generation':True},'invalid_schema'),
|
|
({'expires_at':time.time()-1},'expired_or_invalid_ttl'),
|
|
({'issued_at':float('nan')},'expired_or_invalid_ttl'),
|
|
({'tenant_id':'other'},'binding_not_established'),
|
|
({'history_revision':'other'},'binding_mismatch'),
|
|
({'expected_generation':2},'binding_mismatch'),
|
|
({'connection_id':'other'},'binding_mismatch')]:
|
|
assert h.http('/notice', {**n, **delta})[1]['status'] == status
|
|
h.send({'method':'thread/realtime/stop','params':{'threadId':'thread-A'}})
|
|
assert json.loads(h.recv())['method'] == 'thread/realtime/closed'
|
|
assert h.http('/notice', n)[1]['status'] == 'binding_not_established'
|
|
h.send({'method':'thread/realtime/start','params':{'threadId':'thread-A'}})
|
|
h.recv()
|
|
fresh = h.bind()
|
|
assert fresh['expected_generation'] == n['expected_generation']+1
|
|
assert h.http('/notice', n)[1]['status'] == 'binding_mismatch'
|
|
h.send({'method':'error-event'})
|
|
h.recv()
|
|
assert h.http('/notice', fresh)[1]['status'] == 'binding_not_established'
|
|
finally:
|
|
h.close()
|
|
|
|
|
|
def test_concurrent_notices_and_raw_backpressure_order(tmp_path):
|
|
h = Harness(tmp_path)
|
|
try:
|
|
n = h.start()
|
|
h.send({'method':'delay'})
|
|
records = [(json.dumps({'method':'echo','i':i,'padding':'x'*16000})+'\r\n').encode() for i in range(90)]
|
|
producer = threading.Thread(target=lambda: [h.send(r) for r in records], daemon=True)
|
|
producer.start()
|
|
with concurrent.futures.ThreadPoolExecutor(max_workers=4) as pool:
|
|
results = list(pool.map(lambda i: h.http('/notice',{**n,'notice_id':str(i)})[1]['status'], range(12)))
|
|
assert set(results) <= {'queued','busy'}
|
|
accepted = results.count('queued')
|
|
echoes = []
|
|
speech = 0
|
|
approvals = 0
|
|
for _ in range(1 + len(records) + accepted*2):
|
|
raw = h.recv()
|
|
method = json.loads(raw).get('method')
|
|
if method == 'echo': echoes.append(raw)
|
|
elif method == 'seen-speech': speech += 1
|
|
elif method == 'item/commandExecution/requestApproval': approvals += 1
|
|
producer.join(5)
|
|
assert not producer.is_alive()
|
|
assert echoes == records
|
|
assert speech == approvals == accepted
|
|
assert h.mux._queue.maxsize == 32
|
|
finally:
|
|
h.close()
|
|
|
|
|
|
def test_fragmented_oversized_jsonl_and_partial_eof_preserved(tmp_path):
|
|
h = Harness(tmp_path)
|
|
try:
|
|
h.start()
|
|
raw = b'{ "method":"huge", "value":"' + b'x'*200000 + b'"}\r\n'
|
|
producer = threading.Thread(target=lambda: h.send(raw),daemon=True)
|
|
producer.start()
|
|
assert h.recv() == raw
|
|
producer.join(5)
|
|
assert h.mux.snapshot()['injection_disabled']
|
|
h.send(b'{"partial":true}')
|
|
finally:
|
|
h.close()
|
|
# Engine echoes an unterminated record, then its EOF notification: byte exact.
|
|
assert h.recv() == b'{"partial":true}{"method":"saw-eof"}\n'
|
|
assert h.code == 19
|
|
|
|
|
|
def test_non_appserver_passthrough_argv_env_cwd_stderr_exit(tmp_path, monkeypatch):
|
|
engine = tmp_path / 'engine.py'
|
|
engine.write_text(ENGINE)
|
|
module = Path(muxmod.__file__).resolve()
|
|
monkeypatch.setenv('MUX_TEST_MARKER','inherited')
|
|
args = [sys.executable,str(module),sys.executable,'--','-u',str(engine),'--probe','space arg','app-server']
|
|
p = subprocess.run(args,cwd=tmp_path,capture_output=True,timeout=10)
|
|
assert p.returncode == 23
|
|
result = json.loads(p.stdout)
|
|
assert result == {'argv':['--probe','space arg','app-server'],'cwd':str(tmp_path),'marker':'inherited'}
|
|
direct = subprocess.run([sys.executable, '-u', str(engine), '--probe', 'space arg', 'app-server'],
|
|
cwd=tmp_path, capture_output=True, timeout=10)
|
|
assert (p.stdout, p.stderr, p.returncode) == (direct.stdout, direct.stderr, direct.returncode)
|
|
|
|
|
|
def test_engine_exit_without_desktop_eof(tmp_path):
|
|
h = Harness(tmp_path)
|
|
try:
|
|
h.send({'method':'exit'})
|
|
h.thread.join(5)
|
|
assert not h.thread.is_alive()
|
|
assert h.code == 17
|
|
assert h.mux.snapshot()['closed']
|
|
finally:
|
|
h.close()
|
|
|
|
|
|
def test_owned_namespace_collision_disables_injection_but_forwards(tmp_path):
|
|
h = Harness(tmp_path)
|
|
try:
|
|
n = h.start()
|
|
raw = json.dumps({'id':h.mux._prefix+'collision','method':'echo'}).encode()+b'\n'
|
|
h.send(raw)
|
|
assert h.recv() == raw
|
|
assert h.http('/notice', n)[1]['status'] == 'injection_disabled'
|
|
finally:
|
|
h.close()
|
|
|
|
|
|
def test_queued_notice_revalidated_after_observed_stop(tmp_path):
|
|
h = Harness(tmp_path)
|
|
release = tmp_path / 'release'
|
|
try:
|
|
n = h.start()
|
|
h.send({'method':'pause','params':{'release':str(release)}})
|
|
assert json.loads(h.recv())['method'] == 'paused'
|
|
# Fill the OS pipe while the real engine is paused; leave bounded queue
|
|
# capacity for the notice and stop record behind it.
|
|
for i in range(8):
|
|
h.send({'method':'echo','i':i,'padding':'x'*32000})
|
|
assert h.http('/notice', n)[1]['status'] == 'queued'
|
|
h.send({'method':'thread/realtime/stop','params':{'threadId':'thread-A'}})
|
|
deadline = time.monotonic()+5
|
|
while h.mux.snapshot()['threads']['thread-A']['active'] and time.monotonic() < deadline:
|
|
time.sleep(.01)
|
|
assert not h.mux.snapshot()['threads']['thread-A']['active']
|
|
release.touch()
|
|
methods = [json.loads(h.recv())['method'] for _ in range(10)]
|
|
assert methods == ['released'] + ['echo']*8 + ['thread/realtime/closed']
|
|
assert h.mux._receipts[('tenant','bekci','notice-1')]['status'] == 'binding_not_established'
|
|
assert h.lines.empty()
|
|
finally:
|
|
release.touch()
|
|
h.close()
|
|
|
|
def test_real_request_correlation_and_opaque_receipt_contract(tmp_path):
|
|
h = Harness(tmp_path)
|
|
try:
|
|
n = h.start()
|
|
adapter = muxmod.WatchReceiptAdapter(h.mux)
|
|
headers = {'Session-Id':'thread-A','Thread-Id':'thread-A'}
|
|
correlation = adapter.capture(headers, {'prompt_cache_key':'thread-A'})
|
|
assert correlation.thread_id == 'thread-A'
|
|
assert adapter.capture({**headers,'Thread-Id':'other'}, {'prompt_cache_key':'thread-A'}) is None
|
|
assert adapter.capture(headers, {}) is None
|
|
assert adapter.accept('bekci.watch', {}, correlation) == 'backend_contract_not_established'
|
|
# Internal contract fixture, explicitly NOT a claim about backend wire format.
|
|
receipt = muxmod.WatchReceipt('opaque','tenant','bekci','rev','thread-A',time.time()+60)
|
|
adapter.decode = lambda payload: payload
|
|
adapter.resolve = lambda handle: receipt
|
|
assert adapter.accept('bekci.watch','opaque',correlation) == 'bound'
|
|
assert adapter.accept('_bekci_watch','opaque',correlation) == 'bound'
|
|
assert adapter.accept('other','opaque',correlation) == 'unsupported_metadata_transport'
|
|
assert adapter.notice('unbound',n)['status'] == 'binding_not_established'
|
|
assert adapter.notice('opaque',{**n,'tenant_id':'other'})['status'] == 'binding_mismatch'
|
|
assert adapter.notice('opaque',n)['status'] == 'queued'
|
|
assert json.loads(h.recv())['method'] == 'item/commandExecution/requestApproval'
|
|
assert json.loads(h.recv())['method'] == 'seen-speech'
|
|
h.send({'method':'thread/realtime/stop','params':{'threadId':'thread-A'}}); h.recv()
|
|
h.send({'method':'thread/realtime/start','params':{'threadId':'thread-A'}}); h.recv()
|
|
assert adapter.accept('bekci.watch','opaque',correlation) == 'stale_generation'
|
|
fresh = adapter.capture(headers, {'prompt_cache_key':'thread-A'})
|
|
adapter.resolve = lambda handle: muxmod.WatchReceipt('opaque','tenant','bekci','rev','wrong',time.time()+60)
|
|
assert adapter.accept('bekci.watch','opaque',fresh) == 'receipt_mismatch_or_expired'
|
|
adapter.resolve = lambda handle: muxmod.WatchReceipt('opaque','tenant','bekci','rev','thread-A',time.time()-1)
|
|
assert adapter.accept('bekci.watch','opaque',fresh) == 'receipt_mismatch_or_expired'
|
|
finally:
|
|
h.close()
|
|
|
|
|
|
def test_verified_original_launcher_fallback_actual_process(tmp_path):
|
|
import hashlib
|
|
executable = str(Path(sys.executable).resolve())
|
|
with open(executable,'rb') as f:
|
|
digest = hashlib.file_digest(f,'sha256').hexdigest()
|
|
assert muxmod.verified_original(executable,digest) == executable
|
|
import pytest
|
|
with pytest.raises(ValueError): muxmod.verified_original(executable,'0'*64)
|
|
with pytest.raises(ValueError): muxmod.verified_original('python.exe',digest)
|
|
# A value that happens to say app-server must not trigger observer/attachment.
|
|
marker = tmp_path/'original-ran'
|
|
code = 'from pathlib import Path; import sys; Path(sys.argv[1]).write_text("ok"); sys.exit(21)'
|
|
def forbidden(adapter):
|
|
raise AssertionError('not an app-server invocation')
|
|
assert muxmod.launch_original(executable,digest,['-c',code,str(marker),'app-server'],attach=forbidden) == 21
|
|
assert marker.read_text() == 'ok'
|