File size: 3,627 Bytes
e545bf5
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
"""Crash-isolated deterministic OpenSWMM simulation core.

The OpenSWMM engine is a native extension.  It is never loaded into the
long-lived Streamlit process.  Each simulation runs in a short-lived worker
process and returns only serialisable Python data.
"""
from __future__ import annotations

import os
import pickle
import subprocess
import sys
import tempfile
from pathlib import Path
from typing import Any


class SwmmWorkerError(RuntimeError):
    """Raised when the isolated OpenSWMM worker cannot complete."""



def run_swmm(
    inp_path: str | Path,
    rpt_path: str | Path | None = None,
    out_path: str | Path | None = None,
    timeout_s: int = 900,
) -> dict[str, Any]:
    """Run OpenSWMM in an isolated subprocess.

    Isolation prevents a native OpenSWMM segmentation fault from terminating
    Streamlit.  The worker deliberately uses ``os._exit`` after serialising its
    result so native-library finalisers cannot crash the parent application.
    """
    inp = Path(inp_path).resolve()
    if not inp.is_file():
        raise FileNotFoundError(f"SWMM input file not found: {inp}")

    rpt = Path(rpt_path).resolve() if rpt_path else inp.with_suffix(".rpt")
    out = Path(out_path).resolve() if out_path else inp.with_suffix(".out")
    worker = Path(__file__).with_name("swmm_worker.py")
    if not worker.is_file():
        raise FileNotFoundError(f"OpenSWMM worker not found: {worker}")

    fd, result_name = tempfile.mkstemp(prefix="swmm_result_", suffix=".pkl")
    os.close(fd)
    result_file = Path(result_name)

    worker_python = os.environ.get("SWMM_WORKER_PYTHON", "/opt/swmm-venv/bin/python")
    if not Path(worker_python).is_file():
        raise FileNotFoundError(
            f"Isolated OpenSWMM interpreter not found: {worker_python}"
        )

    cmd = [
        worker_python,
        "-u",
        str(worker),
        "--inp", str(inp),
        "--rpt", str(rpt),
        "--out", str(out),
        "--result", str(result_file),
    ]

    try:
        completed = subprocess.run(
            cmd,
            stdout=subprocess.PIPE,
            stderr=subprocess.PIPE,
            text=True,
            timeout=timeout_s,
            check=False,
            env={**os.environ, "PYTHONUNBUFFERED": "1"},
        )

        payload = None
        if result_file.exists() and result_file.stat().st_size:
            with result_file.open("rb") as f:
                payload = pickle.load(f)

        if isinstance(payload, dict) and payload.get("ok"):
            results = payload["results"]
            results.setdefault("metadata", {})["worker_stdout"] = completed.stdout[-4000:]
            results["metadata"]["worker_stderr"] = completed.stderr[-4000:]
            results["metadata"]["worker_exit_code"] = completed.returncode
            return results

        detail = ""
        if isinstance(payload, dict):
            detail = payload.get("error", "")
        if not detail:
            detail = completed.stderr.strip() or completed.stdout.strip()
        if completed.returncode in (-11, 139):
            detail = (
                "The OpenSWMM worker encountered a native segmentation fault. "
                "The Streamlit process remained protected. " + detail
            ).strip()
        raise SwmmWorkerError(
            f"OpenSWMM worker failed with exit code {completed.returncode}. {detail}".strip()
        )
    except subprocess.TimeoutExpired as exc:
        raise SwmmWorkerError(
            f"OpenSWMM simulation exceeded the {timeout_s}-second timeout."
        ) from exc
    finally:
        result_file.unlink(missing_ok=True)