Spaces:
Running
Running
Edward J. Schwartz commited on
Commit ·
16f323d
1
Parent(s): b2b5f1c
Update run defaults and make verification workers cancellable
Browse files- core +1 -1
- core-revision.json +1 -1
- demo/app.py +15 -11
- demo/execution.py +68 -0
- demo/session.py +10 -3
- tests/test_execution.py +53 -0
- tests/test_execution_cancel.py +105 -0
- tests/test_faultless.py +9 -8
core
CHANGED
|
@@ -1 +1 @@
|
|
| 1 |
-
Subproject commit
|
|
|
|
| 1 |
+
Subproject commit 7258a0c4fe81545b0de6baf4256d6cd6ee60e01d
|
core-revision.json
CHANGED
|
@@ -1,5 +1,5 @@
|
|
| 1 |
{
|
| 2 |
"repository": "https://github.com/edmcman/decompile-verify.git",
|
| 3 |
-
"commit": "
|
| 4 |
"version": "0.0.0"
|
| 5 |
}
|
|
|
|
| 1 |
{
|
| 2 |
"repository": "https://github.com/edmcman/decompile-verify.git",
|
| 3 |
+
"commit": "7258a0c4fe81545b0de6baf4256d6cd6ee60e01d",
|
| 4 |
"version": "0.0.0"
|
| 5 |
}
|
demo/app.py
CHANGED
|
@@ -20,6 +20,7 @@ from demo.presentation import banner, format_bit_ranges, json_detail, mismatch_s
|
|
| 20 |
from demo.session import (UISession, begin_ui, cancel_session, controls, edit_inputs, idle_controls,
|
| 21 |
reset_session, stream_ui)
|
| 22 |
import verifier as api
|
|
|
|
| 23 |
from verifier import Task, integer_text, parse_bindings, parse_function, yaml_mapping
|
| 24 |
|
| 25 |
HEADERS = ["Seed", "Verdict", "Location", "Differing bits", "Original", "Candidate", "Reason"]
|
|
@@ -81,18 +82,20 @@ def show_assembly(binary, function, candidate, bindings, candidate_symbol,
|
|
| 81 |
|
| 82 |
|
| 83 |
def run_verification(binary, function, candidate, bindings, candidate_symbol,
|
| 84 |
-
seed_count, seed_start, instruction_budget, compiler, flags, interfaces="", *, report_directory=None):
|
| 85 |
"""Translate UI inputs, stream engine results, then supply the JSON download."""
|
| 86 |
try:
|
| 87 |
task = task_from_inputs(binary, function, candidate, bindings, candidate_symbol,
|
| 88 |
seed_count, seed_start, instruction_budget, compiler, flags, interfaces)
|
| 89 |
-
rows, detail = [], None
|
| 90 |
-
with closing(
|
| 91 |
for report in iterator:
|
| 92 |
rows.append(result_row(report.results[-1]))
|
| 93 |
if detail is None and report.first_mismatch is not None:
|
| 94 |
detail = asdict(report.first_mismatch)
|
| 95 |
yield banner(report), list(rows), detail, None, report.original_assembly, report.candidate_assembly
|
|
|
|
|
|
|
| 96 |
output = report.to_json()
|
| 97 |
directory = Path(report_directory) if report_directory else Path(tempfile.gettempdir()) / "decompile-verify-reports"
|
| 98 |
directory.mkdir(parents=True, exist_ok=True)
|
|
@@ -177,7 +180,7 @@ aid; verification runs the binary.
|
|
| 177 |
value="globals: {}\ncallees: {}\n", render=False)
|
| 178 |
seed_count = gr.Number(label="Seed count", value=100, precision=0, minimum=1, render=False)
|
| 179 |
seed_start = gr.Textbox(label="First seed", value="0", info="Nonnegative decimal or hex integer", render=False)
|
| 180 |
-
instruction_budget = gr.Number(label="Instruction budget per run", value=
|
| 181 |
minimum=1, render=False)
|
| 182 |
compiler = gr.Dropdown(label="Candidate compiler", choices=["gcc", "clang"], value="gcc", render=False)
|
| 183 |
flags = gr.Textbox(label="Candidate flags", value="-O2",
|
|
@@ -245,9 +248,11 @@ aid; verification runs the binary.
|
|
| 245 |
|
| 246 |
def verify_ui(binary, function, candidate, bindings, candidate_symbol,
|
| 247 |
seed_count, seed_start, instruction_budget, compiler, flags, interfaces, session, ticket):
|
| 248 |
-
|
| 249 |
-
seed_count, seed_start, instruction_budget, compiler, flags, interfaces),
|
| 250 |
-
|
|
|
|
|
|
|
| 251 |
|
| 252 |
def reset_ui(session):
|
| 253 |
reset_session(session)
|
|
@@ -275,10 +280,9 @@ aid; verification runs the binary.
|
|
| 275 |
verify_start = run.click(begin_ui, [session, *inputs], [verdict, *buttons, ticket],
|
| 276 |
queue=False, api_visibility="private", show_progress="hidden")
|
| 277 |
preview_event = gr.on(triggers=[], fn=show_assembly, inputs=inputs, outputs=outputs,
|
| 278 |
-
api_name="assembly", concurrency_limit=
|
| 279 |
-
|
| 280 |
-
|
| 281 |
-
concurrency_id="workbench", show_progress="hidden")
|
| 282 |
event.then(idle_controls, session, buttons, queue=False,
|
| 283 |
api_visibility="private", show_progress="hidden")
|
| 284 |
gr.on([binary.upload, binary.delete, binary.clear, *[component.input for component in inputs[1:]]],
|
|
|
|
| 20 |
from demo.session import (UISession, begin_ui, cancel_session, controls, edit_inputs, idle_controls,
|
| 21 |
reset_session, stream_ui)
|
| 22 |
import verifier as api
|
| 23 |
+
from demo.execution import verification_reports
|
| 24 |
from verifier import Task, integer_text, parse_bindings, parse_function, yaml_mapping
|
| 25 |
|
| 26 |
HEADERS = ["Seed", "Verdict", "Location", "Differing bits", "Original", "Candidate", "Reason"]
|
|
|
|
| 82 |
|
| 83 |
|
| 84 |
def run_verification(binary, function, candidate, bindings, candidate_symbol,
|
| 85 |
+
seed_count, seed_start, instruction_budget, compiler, flags, interfaces="", *, report_directory=None, cancelled=None):
|
| 86 |
"""Translate UI inputs, stream engine results, then supply the JSON download."""
|
| 87 |
try:
|
| 88 |
task = task_from_inputs(binary, function, candidate, bindings, candidate_symbol,
|
| 89 |
seed_count, seed_start, instruction_budget, compiler, flags, interfaces)
|
| 90 |
+
rows, detail, report = [], None, None
|
| 91 |
+
with closing(verification_reports(task, cancelled=cancelled)) as iterator:
|
| 92 |
for report in iterator:
|
| 93 |
rows.append(result_row(report.results[-1]))
|
| 94 |
if detail is None and report.first_mismatch is not None:
|
| 95 |
detail = asdict(report.first_mismatch)
|
| 96 |
yield banner(report), list(rows), detail, None, report.original_assembly, report.candidate_assembly
|
| 97 |
+
if cancelled is not None and cancelled.is_set():
|
| 98 |
+
return
|
| 99 |
output = report.to_json()
|
| 100 |
directory = Path(report_directory) if report_directory else Path(tempfile.gettempdir()) / "decompile-verify-reports"
|
| 101 |
directory.mkdir(parents=True, exist_ok=True)
|
|
|
|
| 180 |
value="globals: {}\ncallees: {}\n", render=False)
|
| 181 |
seed_count = gr.Number(label="Seed count", value=100, precision=0, minimum=1, render=False)
|
| 182 |
seed_start = gr.Textbox(label="First seed", value="0", info="Nonnegative decimal or hex integer", render=False)
|
| 183 |
+
instruction_budget = gr.Number(label="Instruction budget per run", value=1000, precision=0,
|
| 184 |
minimum=1, render=False)
|
| 185 |
compiler = gr.Dropdown(label="Candidate compiler", choices=["gcc", "clang"], value="gcc", render=False)
|
| 186 |
flags = gr.Textbox(label="Candidate flags", value="-O2",
|
|
|
|
| 248 |
|
| 249 |
def verify_ui(binary, function, candidate, bindings, candidate_symbol,
|
| 250 |
seed_count, seed_start, instruction_budget, compiler, flags, interfaces, session, ticket):
|
| 251 |
+
with closing(stream_ui(run_verification, (binary, function, candidate, bindings, candidate_symbol,
|
| 252 |
+
seed_count, seed_start, instruction_budget, compiler, flags, interfaces),
|
| 253 |
+
session, ticket, cancellable=True)) as updates:
|
| 254 |
+
for update in updates:
|
| 255 |
+
yield update[0], update[1], json_detail(update[2]), *update[3:]
|
| 256 |
|
| 257 |
def reset_ui(session):
|
| 258 |
reset_session(session)
|
|
|
|
| 280 |
verify_start = run.click(begin_ui, [session, *inputs], [verdict, *buttons, ticket],
|
| 281 |
queue=False, api_visibility="private", show_progress="hidden")
|
| 282 |
preview_event = gr.on(triggers=[], fn=show_assembly, inputs=inputs, outputs=outputs,
|
| 283 |
+
api_name="assembly", concurrency_limit=None, show_progress="hidden")
|
| 284 |
+
event = verify_start.then(verify_ui, [*inputs, session, ticket], outputs, api_name="verify",
|
| 285 |
+
concurrency_limit=None, show_progress="hidden")
|
|
|
|
| 286 |
event.then(idle_controls, session, buttons, queue=False,
|
| 287 |
api_visibility="private", show_progress="hidden")
|
| 288 |
gr.on([binary.upload, binary.delete, binary.clear, *[component.input for component in inputs[1:]]],
|
demo/execution.py
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Run verification in an interruptible process."""
|
| 2 |
+
|
| 3 |
+
from contextlib import closing
|
| 4 |
+
import multiprocessing
|
| 5 |
+
import os
|
| 6 |
+
import signal
|
| 7 |
+
import tempfile
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
def _verify_worker(task, connection, directory):
|
| 11 |
+
# Compilers inherit this group so cancellation also stops child processes.
|
| 12 |
+
os.setsid()
|
| 13 |
+
tempfile.tempdir = directory
|
| 14 |
+
from verifier import verify
|
| 15 |
+
|
| 16 |
+
try:
|
| 17 |
+
with closing(verify(task, stream=True)) as reports:
|
| 18 |
+
for report in reports:
|
| 19 |
+
connection.send(("report", report))
|
| 20 |
+
connection.send(("done", None))
|
| 21 |
+
except Exception as error:
|
| 22 |
+
connection.send(("error", str(error)))
|
| 23 |
+
finally:
|
| 24 |
+
connection.close()
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def verification_reports(task, *, cancelled=None, _worker=_verify_worker):
|
| 28 |
+
"""Stream results until completion or cancellation."""
|
| 29 |
+
if cancelled is not None and cancelled.is_set():
|
| 30 |
+
return
|
| 31 |
+
context = multiprocessing.get_context("spawn")
|
| 32 |
+
receiver, sender = context.Pipe(duplex=False)
|
| 33 |
+
with tempfile.TemporaryDirectory(prefix="verify-request-") as directory:
|
| 34 |
+
process = context.Process(target=_worker, args=(task, sender, directory))
|
| 35 |
+
started = False
|
| 36 |
+
try:
|
| 37 |
+
process.start()
|
| 38 |
+
started = True
|
| 39 |
+
sender.close()
|
| 40 |
+
while True:
|
| 41 |
+
if cancelled is not None and cancelled.is_set():
|
| 42 |
+
return
|
| 43 |
+
# Poll briefly so cancellation can stop a blocked worker.
|
| 44 |
+
if not receiver.poll(0.1):
|
| 45 |
+
continue
|
| 46 |
+
if cancelled is not None and cancelled.is_set():
|
| 47 |
+
return
|
| 48 |
+
try:
|
| 49 |
+
kind, value = receiver.recv()
|
| 50 |
+
except EOFError as error:
|
| 51 |
+
raise ValueError("Verification worker exited without a result") from error
|
| 52 |
+
if kind == "done":
|
| 53 |
+
return
|
| 54 |
+
if kind == "error":
|
| 55 |
+
raise ValueError(value)
|
| 56 |
+
yield value
|
| 57 |
+
finally:
|
| 58 |
+
sender.close()
|
| 59 |
+
receiver.close()
|
| 60 |
+
if started:
|
| 61 |
+
# Kill the group even when the worker has exited but a child remains.
|
| 62 |
+
try:
|
| 63 |
+
os.killpg(process.pid, signal.SIGKILL)
|
| 64 |
+
except ProcessLookupError:
|
| 65 |
+
if process.is_alive():
|
| 66 |
+
process.kill()
|
| 67 |
+
process.join()
|
| 68 |
+
process.close()
|
demo/session.py
CHANGED
|
@@ -1,7 +1,7 @@
|
|
| 1 |
"""Private, per-browser state for displayed inputs and cancellable result streams."""
|
| 2 |
|
| 3 |
from contextlib import closing
|
| 4 |
-
from threading import RLock
|
| 5 |
|
| 6 |
from demo.presentation import ABI_SLOT, status, with_stale
|
| 7 |
|
|
@@ -13,6 +13,7 @@ class UISession:
|
|
| 13 |
self.busy = False
|
| 14 |
self.display = ""
|
| 15 |
self.lock = RLock()
|
|
|
|
| 16 |
|
| 17 |
def __deepcopy__(self, memo):
|
| 18 |
# Each Gradio session gets its own state and synchronization primitive.
|
|
@@ -33,6 +34,7 @@ def edit_inputs(session, *values):
|
|
| 33 |
|
| 34 |
def reset_session(session):
|
| 35 |
with session.lock:
|
|
|
|
| 36 |
session.generation += 1
|
| 37 |
session.snapshot = session.current = None
|
| 38 |
session.busy = False
|
|
@@ -41,6 +43,7 @@ def reset_session(session):
|
|
| 41 |
|
| 42 |
def cancel_session(session):
|
| 43 |
with session.lock:
|
|
|
|
| 44 |
session.generation += 1
|
| 45 |
session.busy = False
|
| 46 |
_, separator, abi = session.display.partition(ABI_SLOT)
|
|
@@ -61,14 +64,16 @@ def idle_controls(session):
|
|
| 61 |
|
| 62 |
def begin_ui(session, *values):
|
| 63 |
with session.lock:
|
|
|
|
| 64 |
session.generation += 1
|
|
|
|
| 65 |
session.snapshot = session.current = tuple(values)
|
| 66 |
session.busy = True
|
| 67 |
session.display = status("Building")
|
| 68 |
return session.rendered(), *controls(True), session.generation
|
| 69 |
|
| 70 |
|
| 71 |
-
def stream_ui(handler, values, session, ticket=None):
|
| 72 |
"""Keep the six API outputs while protecting this session from obsolete streams."""
|
| 73 |
with session.lock:
|
| 74 |
if ticket is not None:
|
|
@@ -78,10 +83,12 @@ def stream_ui(handler, values, session, ticket=None):
|
|
| 78 |
else:
|
| 79 |
begin_ui(session, *values)
|
| 80 |
generation = session.generation
|
|
|
|
| 81 |
initial = session.rendered()
|
| 82 |
try:
|
| 83 |
yield initial, [], None, None, "", ""
|
| 84 |
-
|
|
|
|
| 85 |
for update in iterator:
|
| 86 |
with session.lock:
|
| 87 |
if session.generation != generation:
|
|
|
|
| 1 |
"""Private, per-browser state for displayed inputs and cancellable result streams."""
|
| 2 |
|
| 3 |
from contextlib import closing
|
| 4 |
+
from threading import Event, RLock
|
| 5 |
|
| 6 |
from demo.presentation import ABI_SLOT, status, with_stale
|
| 7 |
|
|
|
|
| 13 |
self.busy = False
|
| 14 |
self.display = ""
|
| 15 |
self.lock = RLock()
|
| 16 |
+
self.cancelled = Event()
|
| 17 |
|
| 18 |
def __deepcopy__(self, memo):
|
| 19 |
# Each Gradio session gets its own state and synchronization primitive.
|
|
|
|
| 34 |
|
| 35 |
def reset_session(session):
|
| 36 |
with session.lock:
|
| 37 |
+
session.cancelled.set()
|
| 38 |
session.generation += 1
|
| 39 |
session.snapshot = session.current = None
|
| 40 |
session.busy = False
|
|
|
|
| 43 |
|
| 44 |
def cancel_session(session):
|
| 45 |
with session.lock:
|
| 46 |
+
session.cancelled.set()
|
| 47 |
session.generation += 1
|
| 48 |
session.busy = False
|
| 49 |
_, separator, abi = session.display.partition(ABI_SLOT)
|
|
|
|
| 64 |
|
| 65 |
def begin_ui(session, *values):
|
| 66 |
with session.lock:
|
| 67 |
+
session.cancelled.set()
|
| 68 |
session.generation += 1
|
| 69 |
+
session.cancelled = Event()
|
| 70 |
session.snapshot = session.current = tuple(values)
|
| 71 |
session.busy = True
|
| 72 |
session.display = status("Building")
|
| 73 |
return session.rendered(), *controls(True), session.generation
|
| 74 |
|
| 75 |
|
| 76 |
+
def stream_ui(handler, values, session, ticket=None, *, cancellable=False):
|
| 77 |
"""Keep the six API outputs while protecting this session from obsolete streams."""
|
| 78 |
with session.lock:
|
| 79 |
if ticket is not None:
|
|
|
|
| 83 |
else:
|
| 84 |
begin_ui(session, *values)
|
| 85 |
generation = session.generation
|
| 86 |
+
cancelled = session.cancelled
|
| 87 |
initial = session.rendered()
|
| 88 |
try:
|
| 89 |
yield initial, [], None, None, "", ""
|
| 90 |
+
options = {"cancelled": cancelled} if cancellable else {}
|
| 91 |
+
with closing(handler(*values, **options)) as iterator:
|
| 92 |
for update in iterator:
|
| 93 |
with session.lock:
|
| 94 |
if session.generation != generation:
|
tests/test_execution.py
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Exercise worker cleanup without requiring Linux ELF compilers."""
|
| 2 |
+
|
| 3 |
+
import os
|
| 4 |
+
from time import sleep
|
| 5 |
+
|
| 6 |
+
import pytest
|
| 7 |
+
|
| 8 |
+
from demo.execution import verification_reports
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
def successful_worker(task, connection, directory):
|
| 12 |
+
os.setsid()
|
| 13 |
+
connection.send(("report", task))
|
| 14 |
+
connection.send(("done", None))
|
| 15 |
+
connection.close()
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
def failing_worker(task, connection, directory):
|
| 19 |
+
os.setsid()
|
| 20 |
+
connection.send(("error", "invalid candidate"))
|
| 21 |
+
connection.close()
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def stalled_worker(task, connection, directory):
|
| 25 |
+
os.setsid()
|
| 26 |
+
if task:
|
| 27 |
+
connection.send(("report", os.getpid()))
|
| 28 |
+
sleep(60)
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def test_reports_and_errors():
|
| 32 |
+
assert list(verification_reports("report", _worker=successful_worker)) == ["report"]
|
| 33 |
+
with pytest.raises(ValueError, match="invalid candidate"):
|
| 34 |
+
list(verification_reports(None, _worker=failing_worker))
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
def test_closing_stream_stops_worker():
|
| 40 |
+
reports = verification_reports(True, _worker=stalled_worker)
|
| 41 |
+
pid = next(reports)
|
| 42 |
+
reports.close()
|
| 43 |
+
with pytest.raises(ProcessLookupError):
|
| 44 |
+
os.kill(pid, 0)
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
def test_verifier_worker_reports_build_errors(tmp_path):
|
| 48 |
+
from verifier import Task
|
| 49 |
+
|
| 50 |
+
task = Task(binary=tmp_path / "missing.elf", function=0x401000,
|
| 51 |
+
candidate="unsigned target(void) { return 0; }")
|
| 52 |
+
with pytest.raises(ValueError, match="missing.elf"):
|
| 53 |
+
list(verification_reports(task))
|
tests/test_execution_cancel.py
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Exercise cancellation while a real spawned worker is blocked before a result."""
|
| 2 |
+
from concurrent.futures import ThreadPoolExecutor
|
| 3 |
+
import os
|
| 4 |
+
from pathlib import Path
|
| 5 |
+
import subprocess
|
| 6 |
+
from threading import Event
|
| 7 |
+
import time
|
| 8 |
+
|
| 9 |
+
import pytest
|
| 10 |
+
|
| 11 |
+
from demo.execution import verification_reports
|
| 12 |
+
from demo.session import UISession, begin_ui, cancel_session, reset_session, stream_ui
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
def blocked_worker(task, connection, directory):
|
| 16 |
+
os.setsid()
|
| 17 |
+
child = subprocess.Popen(["sleep", "30"])
|
| 18 |
+
Path(directory, "unfinished").write_text("temporary build")
|
| 19 |
+
Path(task).write_text(f"{os.getpid()} {child.pid}\n{directory}")
|
| 20 |
+
if not Path(task).name.startswith("build"):
|
| 21 |
+
connection.send(("report", "ready"))
|
| 22 |
+
time.sleep(30)
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def completed_worker(task, connection, directory):
|
| 26 |
+
os.setsid()
|
| 27 |
+
connection.send(("report", "completed"))
|
| 28 |
+
connection.send(("done", None))
|
| 29 |
+
connection.close()
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
def assert_stopped(pid):
|
| 33 |
+
# A killed compiler child may remain briefly as a zombie until reaped by init.
|
| 34 |
+
result = subprocess.run(["ps", "-o", "stat=", "-p", str(pid)], capture_output=True, text=True)
|
| 35 |
+
assert not result.stdout.strip() or result.stdout.strip().startswith("Z")
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
@pytest.mark.parametrize("stop", [cancel_session, reset_session])
|
| 39 |
+
def test_cancel_blocked_build_releases_worker_and_allows_next_run(tmp_path, stop):
|
| 40 |
+
session = UISession()
|
| 41 |
+
marker = tmp_path / "worker"
|
| 42 |
+
|
| 43 |
+
def handler(*values, cancelled):
|
| 44 |
+
for value in verification_reports(marker, cancelled=cancelled, _worker=blocked_worker):
|
| 45 |
+
yield value, [], None, None, "", ""
|
| 46 |
+
|
| 47 |
+
ticket = begin_ui(session, "source")[-1]
|
| 48 |
+
old_signal = session.cancelled
|
| 49 |
+
updates = stream_ui(handler, (), session, ticket, cancellable=True)
|
| 50 |
+
next(updates)
|
| 51 |
+
assert next(updates)[0] == "ready"
|
| 52 |
+
pids, directory = marker.read_text().splitlines()
|
| 53 |
+
with ThreadPoolExecutor(max_workers=1) as executor:
|
| 54 |
+
waiting = executor.submit(lambda: list(updates))
|
| 55 |
+
time.sleep(0.2)
|
| 56 |
+
assert not waiting.done()
|
| 57 |
+
stop(session)
|
| 58 |
+
new_ticket = begin_ui(session, "new source")[-1]
|
| 59 |
+
assert old_signal.is_set() and not session.cancelled.is_set()
|
| 60 |
+
assert waiting.result(timeout=3) == []
|
| 61 |
+
assert session.busy and session.generation == new_ticket
|
| 62 |
+
assert not Path(directory).exists()
|
| 63 |
+
for pid in map(int, pids.split()):
|
| 64 |
+
assert_stopped(pid)
|
| 65 |
+
assert list(verification_reports(None, _worker=completed_worker)) == ["completed"]
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
def test_close_stream_cleans_up_worker(tmp_path):
|
| 71 |
+
marker = tmp_path / "worker"
|
| 72 |
+
reports = verification_reports(marker, _worker=blocked_worker)
|
| 73 |
+
assert next(reports) == "ready"
|
| 74 |
+
reports.close()
|
| 75 |
+
pids, directory = marker.read_text().splitlines()
|
| 76 |
+
assert not Path(directory).exists()
|
| 77 |
+
for pid in map(int, pids.split()):
|
| 78 |
+
assert_stopped(pid)
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
def test_already_cancelled_does_not_start_worker(tmp_path):
|
| 82 |
+
cancelled = Event()
|
| 83 |
+
cancelled.set()
|
| 84 |
+
marker = tmp_path / "worker"
|
| 85 |
+
assert list(verification_reports(marker, cancelled=cancelled, _worker=blocked_worker)) == []
|
| 86 |
+
assert not marker.exists()
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
def test_cancel_before_first_build_result(tmp_path):
|
| 90 |
+
marker = tmp_path / "build-worker"
|
| 91 |
+
cancelled = Event()
|
| 92 |
+
with ThreadPoolExecutor(max_workers=1) as executor:
|
| 93 |
+
waiting = executor.submit(lambda: list(verification_reports(
|
| 94 |
+
marker, cancelled=cancelled, _worker=blocked_worker)))
|
| 95 |
+
deadline = time.monotonic() + 10
|
| 96 |
+
while not marker.exists():
|
| 97 |
+
assert time.monotonic() < deadline
|
| 98 |
+
time.sleep(0.02)
|
| 99 |
+
assert not waiting.done()
|
| 100 |
+
cancelled.set()
|
| 101 |
+
assert waiting.result(timeout=3) == []
|
| 102 |
+
pids, directory = marker.read_text().splitlines()
|
| 103 |
+
assert not Path(directory).exists()
|
| 104 |
+
for pid in map(int, pids.split()):
|
| 105 |
+
assert_stopped(pid)
|
tests/test_faultless.py
CHANGED
|
@@ -13,29 +13,30 @@ def test_faultless_frontend_stream(tmp_path, compiler, name, example_rows):
|
|
| 13 |
rows = example_rows
|
| 14 |
args = rows[EXAMPLE_NAMES.index(name)].copy()
|
| 15 |
args[8] = compiler
|
|
|
|
| 16 |
updates = list(run_verification(*args, report_directory=tmp_path / 'reports'))
|
| 17 |
report = json.loads(Path(updates[-1][3]).read_text())
|
| 18 |
-
assert len(updates) ==
|
| 19 |
-
assert report['completed'] ==
|
| 20 |
expected = example_metadata(name)['expected_verdict']
|
| 21 |
if name == 'faultless-byte-reinterpretation' and compiler == 'clang':
|
| 22 |
# The original is built with gcc, so the escaped frames differ in layout.
|
| 23 |
expected = 'inconclusive'
|
| 24 |
assert all(r['reason'] == 'escaped_frame_differs' for r in report['results'])
|
| 25 |
assert report['verdict'] == expected
|
| 26 |
-
assert [r['seed'] for r in report['results']] == list(range(
|
| 27 |
if name == 'faultless-linked-list':
|
| 28 |
-
assert report['counts']['inconclusive'] ==
|
| 29 |
elif name == 'faultless-deep-pointer':
|
| 30 |
-
assert report['counts']['mismatch'] ==
|
| 31 |
assert all(r['divergence'] == 'call[1]' for r in report['results'])
|
| 32 |
elif name == 'faultless-heap-loop':
|
| 33 |
assert report['counts']['agree'] > 0
|
| 34 |
assert report['counts']['mismatch'] > 0
|
| 35 |
elif name == 'faultless-memory-dependency':
|
| 36 |
-
assert report['counts']['agree'] ==
|
| 37 |
elif name == 'faultless-byte-reinterpretation':
|
| 38 |
-
assert report['counts'][expected] ==
|
| 39 |
else:
|
| 40 |
-
assert report['counts']['mismatch'] ==
|
| 41 |
assert all(r['divergence'] == 'call[0]' for r in report['results'])
|
|
|
|
| 13 |
rows = example_rows
|
| 14 |
args = rows[EXAMPLE_NAMES.index(name)].copy()
|
| 15 |
args[8] = compiler
|
| 16 |
+
seed_count = args[5]
|
| 17 |
updates = list(run_verification(*args, report_directory=tmp_path / 'reports'))
|
| 18 |
report = json.loads(Path(updates[-1][3]).read_text())
|
| 19 |
+
assert len(updates) == seed_count + 1
|
| 20 |
+
assert report['completed'] == seed_count
|
| 21 |
expected = example_metadata(name)['expected_verdict']
|
| 22 |
if name == 'faultless-byte-reinterpretation' and compiler == 'clang':
|
| 23 |
# The original is built with gcc, so the escaped frames differ in layout.
|
| 24 |
expected = 'inconclusive'
|
| 25 |
assert all(r['reason'] == 'escaped_frame_differs' for r in report['results'])
|
| 26 |
assert report['verdict'] == expected
|
| 27 |
+
assert [r['seed'] for r in report['results']] == list(range(seed_count))
|
| 28 |
if name == 'faultless-linked-list':
|
| 29 |
+
assert report['counts']['inconclusive'] == seed_count
|
| 30 |
elif name == 'faultless-deep-pointer':
|
| 31 |
+
assert report['counts']['mismatch'] == seed_count
|
| 32 |
assert all(r['divergence'] == 'call[1]' for r in report['results'])
|
| 33 |
elif name == 'faultless-heap-loop':
|
| 34 |
assert report['counts']['agree'] > 0
|
| 35 |
assert report['counts']['mismatch'] > 0
|
| 36 |
elif name == 'faultless-memory-dependency':
|
| 37 |
+
assert report['counts']['agree'] == seed_count
|
| 38 |
elif name == 'faultless-byte-reinterpretation':
|
| 39 |
+
assert report['counts'][expected] == seed_count
|
| 40 |
else:
|
| 41 |
+
assert report['counts']['mismatch'] == seed_count
|
| 42 |
assert all(r['divergence'] == 'call[0]' for r in report['results'])
|