Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion backend/firmware.py
Original file line number Diff line number Diff line change
Expand Up @@ -808,7 +808,7 @@ def _upload_result_lines(rc, phase, port):
# didn't compile" sends the user to inspect a graph that is fine.
yield ("\n*** DID NOT RUN *** Another build was already using the build "
"directory. Nothing was compiled or sent to the board.\n")
elif toolchain._build_was_cancelled():
elif rc == toolchain._BUILD_CANCELLED:
pass # already said so, in _run_phase, before the exit code
elif rc != 0 and phase == "compile":
yield (f"\n*** BUILD FAILED (exit code {rc}) *** The sketch didn't compile, so "
Expand Down Expand Up @@ -1446,6 +1446,7 @@ def cancel_build():


@router.post("/api/compile-check")
@toolchain._background_build
def compile_check(payload: dict = Body(...)):
"""Compile-only capacity check for the live controller-capacity meter: runs
the same compile a real Upload would (no port, so nothing is flashed) and
Expand Down
151 changes: 151 additions & 0 deletions backend/tests/test_build_cancellation.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
"""Cancellation belongs to one upload, including across streaming worker hops."""
from concurrent.futures import ThreadPoolExecutor

import pytest

import firmware
import toolchain


def drain(generator):
lines = []
while True:
try:
lines.append(next(generator))
except StopIteration as result:
return "".join(lines), result.value


@pytest.fixture
def processes(monkeypatch):
created = []

class Process:
def __init__(self, args, **kwargs):
self.args = args
self.stdout = self
self.returncode = None
self.stopped = False
created.append(self)

def poll(self):
return self.returncode

def wait(self):
# Some tools can exit successfully while cancellation is in flight.
self.returncode = 0
return 0

def stop(proc):
proc.stopped = True
proc.returncode = 0

monkeypatch.setattr(toolchain.subprocess, "Popen", Process)
monkeypatch.setattr(toolchain, "_stop_build_process", stop)
monkeypatch.setattr(toolchain, "_iter_stream_lines", lambda stream: iter(["compiler alive\n"]))
yield created
assert toolchain._active_builds == []


def start(generator):
while next(generator) != "compiler alive\n":
pass


@pytest.mark.parametrize("finish_check_first", [True, False])
def test_overlapping_capacity_check_does_not_own_upload_cancellation(tmp_path, processes, finish_check_first):
upload_dir = tmp_path / "upload"
check_dir = tmp_path / "check"
for directory in (upload_dir, check_dir):
directory.mkdir()
(directory / "sketch.ino").write_text("void setup() {}")
upload = firmware._compile_upload("Upload", upload_dir, "arduino:avr:uno", "COM7")
check = firmware._compile_upload("Capacity check", check_dir, "arduino:avr:uno", "")
try:
start(upload)
toolchain._background_build(lambda: start(check))()
assert toolchain._cancel_active_build() is True
assert processes[0].stopped is True
assert processes[1].stopped is False
assert toolchain._build_was_cancelled() is False
# Resume on another worker, as Starlette does for streamed responses.
with ThreadPoolExecutor(max_workers=1) as worker:
if finish_check_first:
check_log, check_result = worker.submit(drain, check).result()
upload_log, upload_result = worker.submit(drain, upload).result()
else:
upload_log, upload_result = worker.submit(drain, upload).result()
check_log, check_result = worker.submit(drain, check).result()
assert check_result == (0, "compile")
assert "CANCELLED" not in check_log
assert upload_result == (toolchain._BUILD_CANCELLED, "compile")
assert "CANCELLED" in upload_log
assert len(processes) == 2 # neither run starts an upload process
finally:
upload.close()
check.close()


def test_cancel_between_compile_and_upload_never_starts_flash(tmp_path, processes):
(tmp_path / "sketch.ino").write_text("void setup() {}")
upload = firmware._compile_upload("Upload", tmp_path, "arduino:avr:uno", "COM7")
try:
for line in upload:
if "[compiled]" in line:
break
assert toolchain._cancel_active_build() is True
log, (rc, phase) = drain(upload)
assert rc == toolchain._BUILD_CANCELLED
assert phase == "upload"
assert "CANCELLED" in log
assert len(processes) == 1
assert "Upload complete" not in "".join(firmware._upload_result_lines(rc, phase, "COM7"))
finally:
upload.close()


def test_capacity_check_alone_cannot_be_cancelled(processes):
check = toolchain._run_phase("Capacity check", ["compiler"])
try:
toolchain._background_build(lambda: start(check))()
assert toolchain._cancel_active_build() is False
assert drain(check)[1] == 0
assert not processes[0].stopped
finally:
check.close()


def test_closing_abandoned_stream_stops_process_and_unregisters_run(processes):
build = toolchain._run_phase("Upload", ["compiler"])
start(build)
build.close()
assert processes[0].stopped
assert toolchain._cancel_active_build() is False


def test_cancel_during_process_creation_stops_new_process(monkeypatch, processes):
create = toolchain.subprocess.Popen

def create_and_cancel(*args, **kwargs):
proc = create(*args, **kwargs)
assert toolchain._cancel_active_build() is True
return proc

monkeypatch.setattr(toolchain.subprocess, "Popen", create_and_cancel)
log, rc = drain(toolchain._run_phase("Upload", ["compiler"]))
assert rc == toolchain._BUILD_CANCELLED
assert "CANCELLED" in log
assert processes[0].stopped


def test_cancel_after_phase_header_does_not_spawn_process(processes):
build = toolchain._run_phase("Upload", ["compiler"])
try:
assert "=== Upload ===" in next(build)
assert toolchain._cancel_active_build() is True
log, rc = drain(build)
assert rc == toolchain._BUILD_CANCELLED
assert "CANCELLED" in log
assert processes == []
finally:
build.close()
5 changes: 1 addition & 4 deletions backend/tests/test_engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -1124,7 +1124,6 @@ def test_flash_size_is_read_off_the_request_or_ignored():
def test_cancelling_with_nothing_running_is_not_an_error():
# The UI offers Cancel whenever an upload is busy, and a build that finished
# a moment earlier is not a failure worth reporting.
toolchain._register_build(None)
assert toolchain._cancel_active_build() is False


Expand All @@ -1135,13 +1134,11 @@ def test_a_cancelled_build_is_not_reported_as_a_failed_one(monkeypatch):
sending someone to hunt for a compile error in a graph they never doubted is
the worst possible answer.
"""
monkeypatch.setattr(toolchain, "_build_cancelled", True)
lines = "".join(firmware._upload_result_lines(130, "compile", "COM7"))
lines = "".join(firmware._upload_result_lines(toolchain._BUILD_CANCELLED, "compile", "COM7"))
assert "BUILD FAILED" not in lines


def test_a_real_failure_still_says_so(monkeypatch):
monkeypatch.setattr(toolchain, "_build_cancelled", False)
lines = "".join(firmware._upload_result_lines(1, "compile", "COM7"))
assert "BUILD FAILED" in lines

Expand Down
Loading
Loading