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.
This commit is contained in:
2026-09-29 01:40:28 +03:00
parent 0027c405c1
commit f942056d21
7 changed files with 91 additions and 9 deletions
+1 -1
View File
@@ -1732,7 +1732,7 @@
// ===== Version ===== // ===== Version =====
// Single source of truth for the PWA version: shown in the header and used // Single source of truth for the PWA version: shown in the header and used
// by the server version check in pysimConnect(). // 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; document.getElementById('app-version').textContent = 'v' + SIMPLE_VERSION;
// ===== Tab switching ===== // ===== Tab switching =====
+1 -1
View File
@@ -1,4 +1,4 @@
const CACHE = 'simple-v311'; const CACHE = 'simple-v312';
const URLS = [ const URLS = [
'index.html', 'index.html',
'help.html', 'help.html',
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "pysim-simple-server" name = "pysim-simple-server"
version = "3.6.38" version = "3.6.39"
description = "HTTP REST server wrapping pysim for the SIMple PWA" description = "HTTP REST server wrapping pysim for the SIMple PWA"
requires-python = ">=3.8" requires-python = ">=3.8"
# pysim is a git-only dependency installed explicitly by setup.bat/setup.sh. # pysim is a git-only dependency installed explicitly by setup.bat/setup.sh.
+15 -2
View File
@@ -4,7 +4,7 @@ import os
import sys import sys
import time import time
import traceback import traceback
from http.server import HTTPServer from http.server import ThreadingHTTPServer
from pySim.card_handler import CardHandler from pySim.card_handler import CardHandler
from pySim.commands import SimCardCommands from pySim.commands import SimCardCommands
from pySim.exceptions import NoCardError from pySim.exceptions import NoCardError
@@ -37,6 +37,19 @@ def _default_mcc_mnc_list():
return os.path.join(pkg, 'data', 'mcc-mnc-list.json') 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(): def main():
global _server_start global _server_start
_server_start = time.time() _server_start = time.time()
@@ -247,7 +260,7 @@ def main():
orig_onchange(param_name, old, new) orig_onchange(param_name, old, new)
_reattach_tracer() _reattach_tracer()
app._onchange_apdu_trace = _onchange_apdu_trace 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.sl = sl
server.scc = scc server.scc = scc
server.card = card server.card = card
+6 -2
View File
@@ -31,7 +31,7 @@ from osmocom.tlv import BER_TLV_IE
from osmocom.utils import rpad 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 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 # 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 # long equip (or a test script step) holds the card lock. Result-shaping
# masks everything card-derived when the session is not connected. # 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() self._do_GET()
return return
# Serialize all card access: the background STATUS poll runs in its own # Serialize all card access: the background STATUS poll runs in its own
+65
View File
@@ -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()
+2 -2
View File
@@ -77,7 +77,7 @@ class StartupNoCardTest(unittest.TestCase):
mock.patch.object(srv_main, '_read_iccid', return_value=None), \ 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_terminal_profile', return_value=(None, None)), \
mock.patch.object(srv_main, '_send_status', return_value=('', '9000')), \ 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.object(srv_main, 'CardHandler'), \
mock.patch('sys.stderr', err), mock.patch('sys.stdout', out): mock.patch('sys.stderr', err), mock.patch('sys.stdout', out):
fake_fastinit.init_card_fast.side_effect = init_side_effect 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), \ 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, 'fastinit') as fake_fastinit, \
mock.patch.object(srv_main, 'start_card_monitor'), \ 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.object(srv_main, 'CardHandler'), \
mock.patch('sys.stderr', err), mock.patch('sys.stdout', out): mock.patch('sys.stderr', err), mock.patch('sys.stdout', out):
fake_fastinit.init_card_fast.side_effect = NoCardError() fake_fastinit.init_card_fast.side_effect = NoCardError()