From f942056d21d633f0ecceff4a87b2fac43950e168 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=90=D0=BD=D1=82=D0=BE=D0=BD=20=D0=A2=D1=80=D0=BE=D1=88?= =?UTF-8?q?=D0=B8=D0=BD?= Date: Tue, 29 Sep 2026 01:40:28 +0300 Subject: [PATCH] fix: threaded HTTP server so the RAM modal progress is live (v3.6.39) __main__ built a plain HTTPServer (single-threaded): during a RAM install POST every other request waited in the accept queue, including the 2 s /api/status poll that feeds the operation modal - its bar and step headline never moved and the whole result appeared only at the end (live 2026-09-29). _build_http_server() now returns a ThreadingHTTPServer (daemon threads). Card access is unchanged: it is serialized by _CARD_LOCK, not by the server - every POST is blanket-wrapped in do_POST, every /api/ GET except /api/status and /api/test/status is wrapped in do_GET, and the background threads (STATUS poll, _bip_data_available, test runner) take the lock; the audit found no card-access holes. do_GET also lets non-/api (static PWA) paths bypass the lock, so page assets stay answerable during long operations. Regression test tests/test_http_concurrency.py: a fast request must complete while a slow one is in flight (fails on HTTPServer with exactly the reported symptom: 1.01 s vs 0.20 s); test_startup_cardless patches the new factory. sw cache simple-v312; tests: 519 Python / 674 Node. --- frontend/index.html | 2 +- frontend/sw.js | 2 +- pyproject.toml | 2 +- pysim_simple_server/__main__.py | 17 ++++++++- pysim_simple_server/server.py | 8 +++- tests/test_http_concurrency.py | 65 +++++++++++++++++++++++++++++++++ tests/test_startup_cardless.py | 4 +- 7 files changed, 91 insertions(+), 9 deletions(-) create mode 100644 tests/test_http_concurrency.py diff --git a/frontend/index.html b/frontend/index.html index 721a206..067bd0a 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -1732,7 +1732,7 @@ // ===== Version ===== // Single source of truth for the PWA version: shown in the header and used // by the server version check in pysimConnect(). -const SIMPLE_VERSION = '3.6.38'; +const SIMPLE_VERSION = '3.6.39'; document.getElementById('app-version').textContent = 'v' + SIMPLE_VERSION; // ===== Tab switching ===== diff --git a/frontend/sw.js b/frontend/sw.js index 4da6f35..c3bde1d 100644 --- a/frontend/sw.js +++ b/frontend/sw.js @@ -1,4 +1,4 @@ -const CACHE = 'simple-v311'; +const CACHE = 'simple-v312'; const URLS = [ 'index.html', 'help.html', diff --git a/pyproject.toml b/pyproject.toml index 7614737..5006dd0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "pysim-simple-server" -version = "3.6.38" +version = "3.6.39" description = "HTTP REST server wrapping pysim for the SIMple PWA" requires-python = ">=3.8" # pysim is a git-only dependency installed explicitly by setup.bat/setup.sh. diff --git a/pysim_simple_server/__main__.py b/pysim_simple_server/__main__.py index 9941939..a5ea41d 100644 --- a/pysim_simple_server/__main__.py +++ b/pysim_simple_server/__main__.py @@ -4,7 +4,7 @@ import os import sys import time import traceback -from http.server import HTTPServer +from http.server import ThreadingHTTPServer from pySim.card_handler import CardHandler from pySim.commands import SimCardCommands from pySim.exceptions import NoCardError @@ -37,6 +37,19 @@ def _default_mcc_mnc_list(): return os.path.join(pkg, 'data', 'mcc-mnc-list.json') +def _build_http_server(host, port, handler): + """HTTP server for the PWA + API. + + Threaded on purpose: card access is serialized by ``_CARD_LOCK`` (all + POSTs, the card-touching GETs and the background threads take it), so + while a long operation holds the lock the cached endpoints and static + files must still be answerable - a single-threaded server starved the + RAM modal's live progress poll (`/api/status.ram_progress`) and showed + the whole install result only at the end. Daemon threads keep a stuck + request from blocking shutdown.""" + return ThreadingHTTPServer((host, port), handler) + + def main(): global _server_start _server_start = time.time() @@ -247,7 +260,7 @@ def main(): orig_onchange(param_name, old, new) _reattach_tracer() app._onchange_apdu_trace = _onchange_apdu_trace - server = HTTPServer((opts.http_host, opts.http_port), PysimHandler) + server = _build_http_server(opts.http_host, opts.http_port, PysimHandler) server.sl = sl server.scc = scc server.card = card diff --git a/pysim_simple_server/server.py b/pysim_simple_server/server.py index b81442b..21e6215 100644 --- a/pysim_simple_server/server.py +++ b/pysim_simple_server/server.py @@ -31,7 +31,7 @@ from osmocom.tlv import BER_TLV_IE from osmocom.utils import rpad -VERSION = '3.6.38' +VERSION = '3.6.39' MAX_ENVELOPE_SEGMENTS = 5 # max SMS segments for outgoing C-APDU in ENVELOPE @@ -5328,7 +5328,11 @@ class PysimHandler(BaseHTTPRequestHandler): # keeping them out of the lock lets the UI report 'initializing' while a # long equip (or a test script step) holds the card lock. Result-shaping # masks everything card-derived when the session is not connected. - if self.path in ('/api/status', '/api/test/status'): + # Static PWA files are card-free too: they must stay answerable while a + # long card operation holds the lock (the server is threaded, so this + # bypass is what actually serves the modal's progress poll and page + # assets during a RAM install). + if self.path in ('/api/status', '/api/test/status') or not self.path.startswith('/api/'): self._do_GET() return # Serialize all card access: the background STATUS poll runs in its own diff --git a/tests/test_http_concurrency.py b/tests/test_http_concurrency.py new file mode 100644 index 0000000..1b9991c --- /dev/null +++ b/tests/test_http_concurrency.py @@ -0,0 +1,65 @@ +"""The HTTP server must stay answerable while a card operation holds the lock. + +The RAM modal's live progress comes from /api/status.ram_progress, and the +static PWA files are card-free: both are served outside _CARD_LOCK by design +(server.do_GET), which only works when the server dispatches requests +concurrently. A single-threaded HTTPServer starved every other request until +the install POST finished, so the progress bar never grew (live 2026-09-29). +""" + +import threading +import time +import unittest +import urllib.request +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer + +from pysim_simple_server.__main__ import _build_http_server + + +class _StubHandler(BaseHTTPRequestHandler): + def do_GET(self): + if self.path == '/slow': + time.sleep(1.0) + body = b'ok' + self.send_response(200) + self.send_header('Content-Length', str(len(body))) + self.end_headers() + self.wfile.write(body) + + def log_message(self, *args): + pass + + +class TestThreadedHttpServer(unittest.TestCase): + def setUp(self): + self.server = _build_http_server('127.0.0.1', 0, _StubHandler) + self.thread = threading.Thread(target=self.server.serve_forever, daemon=True) + self.thread.start() + self.port = self.server.server_address[1] + + def tearDown(self): + self.server.shutdown() + self.server.server_close() + + def test_built_server_is_threaded(self): + self.assertIsInstance(self.server, ThreadingHTTPServer) + # a stuck request must not block shutdown + self.assertTrue(self.server.daemon_threads) + + def test_cached_request_is_served_while_a_slow_one_runs(self): + started = time.monotonic() + slow = threading.Thread( + target=lambda: urllib.request.urlopen( + 'http://127.0.0.1:%d/slow' % self.port, timeout=5).read()) + slow.start() + time.sleep(0.15) # let /slow enter its handler + urllib.request.urlopen('http://127.0.0.1:%d/fast' % self.port, timeout=5).read() + fast_elapsed = time.monotonic() - started + slow.join(timeout=5) + # a single-threaded server would answer only after /slow finished + self.assertLess(fast_elapsed, 0.6, + 'fast request waited for the slow one: server not threaded') + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_startup_cardless.py b/tests/test_startup_cardless.py index 67dbed9..1e06008 100644 --- a/tests/test_startup_cardless.py +++ b/tests/test_startup_cardless.py @@ -77,7 +77,7 @@ class StartupNoCardTest(unittest.TestCase): mock.patch.object(srv_main, '_read_iccid', return_value=None), \ mock.patch.object(srv_main, '_send_terminal_profile', return_value=(None, None)), \ mock.patch.object(srv_main, '_send_status', return_value=('', '9000')), \ - mock.patch.object(srv_main, 'HTTPServer') as fake_http, \ + mock.patch.object(srv_main, '_build_http_server') as fake_http, \ mock.patch.object(srv_main, 'CardHandler'), \ mock.patch('sys.stderr', err), mock.patch('sys.stdout', out): fake_fastinit.init_card_fast.side_effect = init_side_effect @@ -128,7 +128,7 @@ class StartupNoCardTest(unittest.TestCase): with mock.patch.object(srv_main, 'load_pysim_app', return_value=fake_mod), \ mock.patch.object(srv_main, 'fastinit') as fake_fastinit, \ mock.patch.object(srv_main, 'start_card_monitor'), \ - mock.patch.object(srv_main, 'HTTPServer') as fake_http, \ + mock.patch.object(srv_main, '_build_http_server') as fake_http, \ mock.patch.object(srv_main, 'CardHandler'), \ mock.patch('sys.stderr', err), mock.patch('sys.stdout', out): fake_fastinit.init_card_fast.side_effect = NoCardError()