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:
+1
-1
@@ -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
@@ -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
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
|
|||||||
Reference in New Issue
Block a user