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()