55``AF_UNIX`` listening socket. The owning :class:`~biofuse.formats.FormatSpec`
66selects:
77
8- - which static sidecar files to precompute and cache (``.bim``/``.fam``
9- for PLINK; ``.sample``/``.bgen.bgi`` for BGEN), via
10- :meth:`FormatSpec.build_static_files`;
8+ - which static sidecar files to build on demand for the metadata
9+ handshake (``.bim``/``.fam`` for PLINK; ``.sample``/``.bgen.bgi``
10+ for BGEN), via :meth:`FormatSpec.build_static_files`;
1111- which encoder class to construct per accepted connection, via
1212 :meth:`FormatSpec.encoder_factory`.
1313
3737class _ServerSession :
3838 """Server-side state shared across all connection threads.
3939
40- Holds the ``VczReader``, the precomputed static-file bodies (in
41- the format spec's declared order), and the streaming-file size.
42- Immutable after construction; safe to read concurrently from any
43- thread without locking.
40+ Holds the ``VczReader`` and the active format spec. The static
41+ sidecar bytes and the streaming-file size are both built on demand
42+ inside the metadata handshake (see ``_make_metadata_reply``)
43+ rather than cached here — the parent fuse handler is the canonical
44+ owner of that metadata after the handshake completes. Immutable
45+ after construction; safe to read concurrently from any thread
46+ without locking.
4447 """
4548
4649 def __init__ (
@@ -50,19 +53,6 @@ def __init__(
5053 ) -> None :
5154 self .reader = reader
5255 self .spec = spec
53- self .static_files : dict [str , bytes ] = spec .build_static_files (reader )
54- missing = set (spec .static_suffixes ) - set (self .static_files )
55- extra = set (self .static_files ) - set (spec .static_suffixes )
56- if missing or extra :
57- raise ValueError (
58- f"{ spec .name } : build_static_files returned keys "
59- f"{ sorted (self .static_files )} ; expected { list (spec .static_suffixes )} "
60- )
61- # Open one throwaway encoder to read ``total_size`` — encoder
62- # construction is I/O-free, so this is cheap. Per-connection
63- # encoders are constructed fresh in ``_handle_connection``.
64- with spec .encoder_factory (reader ) as encoder :
65- self .stream_size : int = int (encoder .total_size )
6656
6757
6858def _recv_exact_sync (sock : socket .socket , n : int ) -> bytes :
@@ -85,6 +75,113 @@ def _recv_exact_sync(sock: socket.socket, n: int) -> bytes:
8575 return bytes (buf )
8676
8777
78+ def _make_error_reply (exc : BaseException , context : str ) -> bytes :
79+ """Pack an errno reply for ``exc`` and log the cause.
80+
81+ Non-``OSError`` causes log a one-line ERROR with the errno and
82+ the exception text, plus a DEBUG-only traceback. Used by both
83+ the encoder-construction failure path and the per-dispatch
84+ exception handler.
85+ """
86+ err = encoder_protocol .errno_for_exception (exc )
87+ if not isinstance (exc , OSError ):
88+ logger .error ("encoder-server %s; replying with errno %d: %s" , context , err , exc )
89+ logger .debug ("encoder-server %s traceback" , context , exc_info = True )
90+ return encoder_protocol .pack_error_reply (err )
91+
92+
93+ def _make_metadata_reply (session : "_ServerSession" ) -> bytes :
94+ """Build the reply for a ``TAG_GET_METADATA`` request.
95+
96+ The static sidecars and the streaming-file size are built on
97+ demand from the format spec, serialised into the reply, then
98+ dropped: the parent fuse handler owns the canonical copy after
99+ the handshake. Encoder construction is I/O-free so reading
100+ ``total_size`` here is cheap.
101+ """
102+ static_files = session .spec .build_static_files (session .reader )
103+ missing = set (session .spec .static_suffixes ) - set (static_files )
104+ extra = set (static_files ) - set (session .spec .static_suffixes )
105+ if missing or extra :
106+ raise ValueError (
107+ f"{ session .spec .name } : build_static_files returned keys "
108+ f"{ sorted (static_files )} ; expected { list (session .spec .static_suffixes )} "
109+ )
110+ with session .spec .encoder_factory (session .reader ) as encoder :
111+ stream_size = int (encoder .total_size )
112+ ordered_bodies = [static_files [suffix ] for suffix in session .spec .static_suffixes ]
113+ return encoder_protocol .pack_metadata_reply (ordered_bodies , stream_size )
114+
115+
116+ def _make_read_reply (conn_sock : socket .socket , encoder , tname : str ) -> bytes | None :
117+ """Build the reply for a ``TAG_READ`` request, or ``None`` on
118+ truncated payload (caller terminates the connection)."""
119+ payload = _recv_exact_sync (conn_sock , encoder_protocol .REQ_READ_PAYLOAD_SIZE )
120+ if len (payload ) < encoder_protocol .REQ_READ_PAYLOAD_SIZE :
121+ return None
122+ off , size = encoder_protocol .parse_read_payload (payload )
123+ t_read = time .monotonic ()
124+ data = encoder .read (off , size )
125+ logger .debug (
126+ "%s: encoder.read off=%d size=%d in %.3fs" ,
127+ tname ,
128+ off ,
129+ size ,
130+ time .monotonic () - t_read ,
131+ )
132+ return encoder_protocol .pack_read_reply (data )
133+
134+
135+ def _send_reply (conn_sock : socket .socket , reply : bytes ) -> bool :
136+ """Send ``reply`` on the socket; return ``True`` on success and
137+ ``False`` if the send raised ``OSError`` (already logged)."""
138+ try :
139+ conn_sock .sendall (reply )
140+ return True
141+ except OSError as exc :
142+ logger .warning ("encoder-server send failed: %s" , exc )
143+ return False
144+
145+
146+ def _serve_connection (
147+ conn_sock : socket .socket ,
148+ session : "_ServerSession" ,
149+ encoder ,
150+ tname : str ,
151+ ) -> None :
152+ """Run the tag-dispatch loop for one connected client.
153+
154+ Exits cleanly on EOF, unknown tag, truncated payload, or a send
155+ failure. Exceptions raised inside a dispatch case are converted
156+ to errno replies and the loop continues to the next request.
157+ """
158+ while True :
159+ try :
160+ tag_buf = _recv_exact_sync (conn_sock , 1 )
161+ except EOFError as exc :
162+ logger .warning ("encoder-server frame error: %s" , exc )
163+ return
164+ if len (tag_buf ) == 0 :
165+ return
166+ tag = bytes (tag_buf )
167+ try :
168+ if tag == encoder_protocol .TAG_GET_METADATA :
169+ reply = _make_metadata_reply (session )
170+ elif tag == encoder_protocol .TAG_READ :
171+ reply = _make_read_reply (conn_sock , encoder , tname )
172+ else :
173+ logger .warning (
174+ "encoder-server: unknown tag %r; closing connection" , tag
175+ )
176+ return
177+ except Exception as exc : # noqa: BLE001 - any error becomes errno reply
178+ reply = _make_error_reply (exc , "dispatch raised" )
179+ if reply is None :
180+ return
181+ if not _send_reply (conn_sock , reply ):
182+ return
183+
184+
88185def _handle_connection (conn_sock : socket .socket , session : _ServerSession ) -> None :
89186 """Run one client connection synchronously. Returns on EOF.
90187
@@ -102,73 +199,15 @@ def _handle_connection(conn_sock: socket.socket, session: _ServerSession) -> Non
102199 t_enc = time .monotonic ()
103200 encoder_cm = session .spec .encoder_factory (session .reader )
104201 except Exception as exc : # noqa: BLE001 - any error becomes errno reply
105- err = encoder_protocol .errno_for_exception (exc )
106- if not isinstance (exc , OSError ):
107- logger .exception (
108- "encoder-server encoder construction failed; replying with errno"
109- )
110- try :
111- conn_sock .sendall (encoder_protocol .pack_error_reply (err ))
112- except OSError as send_exc :
113- logger .warning ("encoder-server send failed: %s" , send_exc )
202+ _send_reply (
203+ conn_sock , _make_error_reply (exc , "encoder construction failed" )
204+ )
114205 return
115206 with encoder_cm as encoder :
116207 logger .debug (
117208 "%s: encoder created in %.3fs" , tname , time .monotonic () - t_enc
118209 )
119- while True :
120- try :
121- tag_buf = _recv_exact_sync (conn_sock , 1 )
122- except EOFError as exc :
123- logger .warning ("encoder-server frame error: %s" , exc )
124- return
125- if len (tag_buf ) == 0 :
126- return
127- tag = bytes (tag_buf )
128- try :
129- if tag == encoder_protocol .TAG_GET_METADATA :
130- ordered_bodies = [
131- session .static_files [suffix ]
132- for suffix in session .spec .static_suffixes
133- ]
134- reply = encoder_protocol .pack_metadata_reply (
135- ordered_bodies , session .stream_size
136- )
137- elif tag == encoder_protocol .TAG_READ :
138- payload = _recv_exact_sync (
139- conn_sock , encoder_protocol .REQ_READ_PAYLOAD_SIZE
140- )
141- if len (payload ) < encoder_protocol .REQ_READ_PAYLOAD_SIZE :
142- return
143- off , size = encoder_protocol .parse_read_payload (payload )
144- t_read = time .monotonic ()
145- data = encoder .read (off , size )
146- logger .debug (
147- "%s: encoder.read off=%d size=%d in %.3fs" ,
148- tname ,
149- off ,
150- size ,
151- time .monotonic () - t_read ,
152- )
153- reply = encoder_protocol .pack_read_reply (data )
154- else :
155- logger .warning (
156- "encoder-server: unknown tag %r; closing connection" ,
157- tag ,
158- )
159- return
160- except Exception as exc : # noqa: BLE001 - any error becomes errno reply
161- err = encoder_protocol .errno_for_exception (exc )
162- if not isinstance (exc , OSError ):
163- logger .exception (
164- "encoder-server dispatch raised; replying with EIO"
165- )
166- reply = encoder_protocol .pack_error_reply (err )
167- try :
168- conn_sock .sendall (reply )
169- except OSError as exc :
170- logger .warning ("encoder-server send failed: %s" , exc )
171- return
210+ _serve_connection (conn_sock , session , encoder , tname )
172211 logger .debug ("%s: conn thread exit" , tname )
173212
174213
@@ -253,12 +292,15 @@ def _server_main(
253292 ``make_reader`` consumes.
254293
255294 Any exception raised before ``serve_forever`` starts (reader
256- construction, ``_ServerSession`` construction — the latter eagerly
257- walks the variants to build the static sidecars and may reject e.g.
258- multi-allelic input) is caught here so multiprocessing's default
259- handler does not print a traceback. The cause is logged at ERROR
260- (visible at default verbosity); the traceback only surfaces at
261- DEBUG.
295+ construction, ``_ServerSession`` construction) is caught here so
296+ multiprocessing's default handler does not print a traceback. The
297+ cause is logged at ERROR (visible at default verbosity); the
298+ traceback only surfaces at DEBUG. Static-sidecar build errors
299+ (e.g. multi-allelic input rejection) now surface inside
300+ ``_handle_connection`` on the first metadata request rather than
301+ at session construction; the handler converts them to errno
302+ replies so the parent sees a clean ``OSError`` from
303+ ``_handshake``.
262304 """
263305 log_config .apply ()
264306 try :
0 commit comments