Bonsai: diagnose IfcTester webapp startup failures instead of a dead tab

Starting the IfcTester webapp spawned the Flask server subprocess without
capturing its output, slept a blind 1.5 seconds, and opened the browser
unconditionally. When the server died on startup (missing dependency,
incomplete ifctester build, firewall blocking the bind) the user got an
"Unable to connect" browser tab and zero diagnostics; the panel still
claimed the webapp was running. Reported in the wild on Windows.

Harden the launcher:
- capture the subprocess stdout/stderr;
- replace the blind sleep with a readiness poll (up to 10s): open the
  browser only once a TCP connect to the webapp port succeeds; if the
  process exits first, print its exit code and captured output to the
  console with a hint about ifctester/Flask/firewall; if it stays alive
  but never listens, print a timeout diagnostic; in both failure cases do
  not open the browser and reset webapp_is_running;
- Stop operator: wait after kill on the timeout path and close the
  captured pipe in cleanup paths to avoid leaks.

Verified live in headless Blender via the real operators: happy path
(port connectable, browser-open recorder called once with the right URL,
clean stop), instant-death path (recorder not called, console shows
"exited with code 3. Output: SIMULATED STARTUP FAILURE" plus the hint,
webapp_is_running False), and never-listens path (timeout diagnostic
after ~10s, process still alive, Stop kills it cleanly).

Generated with the assistance of an AI coding tool.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Petru Conduraru
2026-07-12 15:07:47 +03:00
parent 0b7e25a3ef
commit a65a3014f5
@@ -351,9 +351,15 @@ class StartIfcTesterWebapp(bpy.types.Operator):
env["BONSAI_LIB_PATH"] = str(bonsai_lib_path)
env["BONSAI_VERSION"] = tool.Blender.get_bonsai_version()
# Start the Flask server as subprocess
# Start the Flask server as subprocess. Capture stdout/stderr so that if the
# server fails to start (missing dependency, bad build, port/firewall issue)
# we have something to show the user instead of a silent failure.
webapp_process = subprocess.Popen(
[sys.executable, webapp_serve_path, "--host", "127.0.0.1", "--port", str(webapp_port)], env=env
[sys.executable, webapp_serve_path, "--host", "127.0.0.1", "--port", str(webapp_port)],
env=env,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
)
# Update properties
@@ -361,14 +367,54 @@ class StartIfcTesterWebapp(bpy.types.Operator):
props.websocket_server_port = websocket_port
props.webapp_is_running = True
# Wait a moment for servers to start, then open browser
def delayed_open_browser():
import time
# Poll until the webapp is actually listening (or has died / timed out) before
# opening a browser tab. Opening the browser on a dead server just produces a
# confusing "Unable to connect" tab with no diagnostics.
def wait_for_webapp_and_open_browser():
process = webapp_process
deadline = time.monotonic() + 10.0
connected = False
time.sleep(1.5)
webbrowser.open(f"http://127.0.0.1:{webapp_port}?bonsai_server={websocket_port}")
while time.monotonic() < deadline:
if process.poll() is not None:
# Process exited before it ever started listening.
break
try:
with socket.create_connection(("127.0.0.1", webapp_port), timeout=0.25):
connected = True
break
except OSError:
pass
time.sleep(0.25)
browser_thread = threading.Thread(target=delayed_open_browser, daemon=True)
if connected:
webbrowser.open(f"http://127.0.0.1:{webapp_port}?bonsai_server={websocket_port}")
return
# Something went wrong: do NOT open a dead browser tab. Print diagnostics
# to the console, since this runs in a daemon thread after execute() has
# already returned and self.report() is no longer usable.
exit_code = process.poll()
if exit_code is not None:
try:
output, _ = process.communicate(timeout=2)
except Exception:
output = ""
print(
f"IfcTester webapp process exited with code {exit_code}. Output: {output}\n"
"Ensure ifctester is up to date and Flask is available; check firewall/antivirus "
"if the port is blocked."
)
else:
print(
f"IfcTester webapp did not start listening on port {webapp_port} within 10s.\n"
"Ensure ifctester is up to date and Flask is available; check firewall/antivirus "
"if the port is blocked."
)
props.webapp_is_running = False
browser_thread = threading.Thread(target=wait_for_webapp_and_open_browser, daemon=True)
browser_thread.start()
self.report(
@@ -380,6 +426,8 @@ class StartIfcTesterWebapp(bpy.types.Operator):
# Clean up on error
if webapp_process:
webapp_process.terminate()
if webapp_process.stdout:
webapp_process.stdout.close()
webapp_process = None
if websocket_server_thread and websocket_app:
# The websocket server will be cleaned up when the thread ends
@@ -420,9 +468,12 @@ class StopIfcTesterWebapp(bpy.types.Operator):
webapp_process.wait(timeout=5)
except subprocess.TimeoutExpired:
webapp_process.kill()
webapp_process.wait(timeout=5)
except Exception as e:
errors.append(f"Error stopping webapp server: {str(e)}")
finally:
if webapp_process.stdout:
webapp_process.stdout.close()
webapp_process = None
# Stop websocket server