v3.30.3: the Windows console encoding guard

Under start.bat the server's stdout/stderr are pipes into PowerShell's
Tee-Object, so Python encodes with the ANSI code page instead of the
wide console API.  cp1251 (Russian Windows) cannot encode the banner's
box-drawing rule (U+2500), and that print killed the server with
UnicodeEncodeError before it could listen.

- _guard_console_streams() (the first statement of main()) sets
  errors="replace" on both streams: an unencodable character prints as
  '?' and can never raise.
- _console_rule() picks the widest banner rule its stream can encode -
  U+2500 where the code page has it (utf-8, cp437), ASCII dashes
  otherwise (cp1251, ascii).
- The seven text-layer trace strings are ASCII now: the APDU arrows
  (U+2192) and the INIT/CAP-COMPAT em dashes (U+2014 - cp437 cannot
  encode them either), so the traces stay readable on any code page.
  The raw os.write paths (_log_stdout, the APDU tracer) bypass the text
  layer, cannot crash, and were left as-is.

Tests: tests/test_console_encoding.py - the strict-cp1251 control, the
rule's fallback/pretty cases, the guarded banner+traces, a stream
without reconfigure, the main() source pin, and an AST pin that no
print/stdout/stderr literal in the package is non-ASCII.
tests/test_startup_cardless.py's CARDLESS_LINE follows the new ASCII
line.

Verified with PYTHONUNBUFFERED=1 PYTHONIOENCODING=cp1251: the pre-fix
code died at the banner, the fixed code reaches the listening banner
and serves a command (CMD: -> OK, no encode errors).

930 frontend + 952 Python; sw cache simple-v453.
This commit is contained in:
2026-10-15 21:36:49 +03:00
parent 3db6f59e69
commit ea1d943432
7 changed files with 162 additions and 17 deletions
+37 -5
View File
@@ -28,6 +28,37 @@ def _log_stdout(msg):
os.write(1, ('[%8.3f] %s\n' % (elapsed, msg)).encode())
def _guard_console_streams():
"""Never let a non-encodable character kill the server.
On Windows the launcher pipes stdout/stderr into PowerShell (see start.bat),
so Python encodes with the ANSI code page instead of the wide console API -
cp1251 cannot encode the banner's box-drawing rule (U+2500) or the APDU
trace arrows (U+2192), and one such character raised UnicodeEncodeError and
killed the process. errors="replace" keeps every message, whatever the code
page (a character the code page lacks prints as '?')."""
for stream in (sys.stdout, sys.stderr):
try:
stream.reconfigure(errors="replace")
except Exception:
pass
def _console_rule(n=70):
"""The banner rule in the widest character the output stream can encode.
Under start.bat stdout is a pipe (not a console), so Python encodes with the
ANSI code page - cp1251 (Russian Windows) cannot encode the box-drawing
U+2500 and falls back to a plain ASCII rule (cp437 has the box-drawing
characters and keeps the pretty one)."""
enc = getattr(sys.stdout, "encoding", None) or "ascii"
try:
"\u2500".encode(enc)
return "\u2500" * n
except (UnicodeEncodeError, LookupError):
return "-" * n
def _default_web_dir():
# <repo>/frontend, whether run from source or an editable install.
return os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), 'frontend')
@@ -56,6 +87,7 @@ def _build_http_server(host, port, handler):
def main():
global _server_start
_guard_console_streams()
_server_start = time.time()
mod = load_pysim_app()
parser = mod.option_parser
@@ -167,7 +199,7 @@ def main():
# Normal cardless start: there was no card in the reader (the
# 3s wait timed out). The presence monitor auto-equips once a
# card appears; nothing to recover, no traceback.
sys.stderr.write('INIT: no card in the reader — server ready; insert a card or press Equip\n')
sys.stderr.write('INIT: no card in the reader - server ready; insert a card or press Equip\n')
except Exception:
print("Warning: fast card initialization failed, falling back to pysim init:", file=sys.stderr)
traceback.print_exc()
@@ -176,13 +208,13 @@ def main():
except NoCardError:
# The fallback retried the cardless wait; still a normal
# cardless start, not an initialization failure.
sys.stderr.write('INIT: no card in the reader — server ready; insert a card or press Equip\n')
sys.stderr.write('INIT: no card in the reader - server ready; insert a card or press Equip\n')
else:
try:
sl.wait_for_card(3)
rs, card = mod.init_card(sl, opts.skip_card_init)
except NoCardError:
sys.stderr.write('INIT: no card in the reader — server ready; insert a card or press Equip\n')
sys.stderr.write('INIT: no card in the reader - server ready; insert a card or press Equip\n')
_tlog('card_init: %.0fms' % ((time.time() - t_phase) * 1000))
if card is not None:
# Fast init builds the card on its own SimCardCommands instance;
@@ -361,11 +393,11 @@ def main():
# that event triggers auto-equip instead — the desired retry.
if sl is not None and getattr(sl, '_reader', None) is not None:
start_card_monitor(str(sl._reader))
print("─" * 70)
print(_console_rule())
print(" pysim-simple-server v%s listening on http://%s:%s" % (VERSION, opts.http_host, opts.http_port))
print(" Open http://%s:%s in your browser for the SIMple UI (served by this server)."
% (opts.http_host, opts.http_port))
print("─" * 70)
print(_console_rule())
try:
server.serve_forever()
except KeyboardInterrupt:
+8 -8
View File
@@ -37,7 +37,7 @@ from osmocom.tlv import BER_TLV_IE
from osmocom.utils import rpad
VERSION = '3.30.2'
VERSION = '3.30.3'
MAX_ENVELOPE_SEGMENTS = 5 # max SMS segments for outgoing C-APDU in ENVELOPE
@@ -9203,7 +9203,7 @@ class PysimHandler(BaseHTTPRequestHandler):
elif is_equip:
output += ('EQUIP: the shell did not register its command sets - '
'see the output above\n')
sys.stderr.write("CMD: %s → %s (%dms)\n" % (cmd, status, elapsed))
sys.stderr.write("CMD: %s -> %s (%dms)\n" % (cmd, status, elapsed))
resp = {'output': output, 'stop': bool(stop)}
self._send_json(resp)
self._log_resp(resp)
@@ -9221,13 +9221,13 @@ class PysimHandler(BaseHTTPRequestHandler):
data, sw = scc.send_apdu_checksw(apdu_hex)
elapsed = int((time.time() - t0) * 1000)
resp = {'response': data, 'sw': sw}
sys.stderr.write("APDU: %s → SW: %s (%dms)\n" % (apdu_hex, sw, elapsed))
sys.stderr.write("APDU: %s -> SW: %s (%dms)\n" % (apdu_hex, sw, elapsed))
self._send_json(resp)
self._log_resp(resp)
except Exception as e:
elapsed = int((time.time() - t0) * 1000)
err = {'error': str(e)}
sys.stderr.write("APDU: %s → ERROR: %s (%dms)\n" % (apdu_hex, str(e), elapsed))
sys.stderr.write("APDU: %s -> ERROR: %s (%dms)\n" % (apdu_hex, str(e), elapsed))
self._send_json(err, 500)
self._log_resp(err)
elif self.path == '/api/verify-adm':
@@ -9253,12 +9253,12 @@ class PysimHandler(BaseHTTPRequestHandler):
# the ADM is verified for the current card session
self.server.adm_session = getattr(self.server, 'card_session', 0)
self.server.adm_failed_session = None
sys.stderr.write('VERIFY ADM → SW: %s\n' % resp.get('sw'))
sys.stderr.write('VERIFY ADM -> SW: %s\n' % resp.get('sw'))
self._send_json(resp)
self._log_resp(resp)
except Exception as e:
resp = {'ok': False, 'error': str(e)}
sys.stderr.write('VERIFY ADM → ERROR: %s\n' % e)
sys.stderr.write('VERIFY ADM -> ERROR: %s\n' % e)
self._send_json(resp, 500)
self._log_resp(resp)
elif self.path == '/api/esim/profile':
@@ -10357,7 +10357,7 @@ class PysimHandler(BaseHTTPRequestHandler):
return
block_size = block_size_req or 240
boundary, total_blocks = _cap_compat_blocks(loadfile_data, block_size)
sys.stderr.write('CAP-COMPAT: %s — the import list ends in LOAD %d/%d '
sys.stderr.write('CAP-COMPAT: %s - the import list ends in LOAD %d/%d '
'(block size %d)\n' % (loadfile_aid, boundary,
total_blocks, block_size))
@@ -10430,7 +10430,7 @@ class PysimHandler(BaseHTTPRequestHandler):
resp['failed_step'] = len(steps)
failed_rec = steps[-1] if steps else {}
resp.update(_cntr_low_fields(failed_rec.get('por_status')))
sys.stderr.write('CAP-COMPAT: %s — imports %s (LOADS to %d/%d)\n' % (
sys.stderr.write('CAP-COMPAT: %s - imports %s (LOADS to %d/%d)\n' % (
loadfile_aid, 'OK' if ok else 'REJECTED', boundary, total_blocks))
self._send_json(resp)
self._log_resp(resp)