fixcore-engine 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,146 @@
1
+ """SessionSettings — parse QuickFIX-style INI configuration."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import configparser
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ from fixcore.session.session_id import SessionID
10
+
11
+ _DEFAULT_SECTION = "DEFAULT"
12
+ _SESSION_SECTION_PREFIX = "SESSION"
13
+
14
+
15
+ class SessionSettings:
16
+ """Holds per-session and default configuration values.
17
+
18
+ Parses a QuickFIX-compatible INI file of the form::
19
+
20
+ [DEFAULT]
21
+ ConnectionType=initiator
22
+ HeartBtInt=30
23
+ ResetOnLogon=N
24
+
25
+ [SESSION]
26
+ BeginString=FIX.4.2
27
+ SenderCompID=CLIENT
28
+ TargetCompID=SERVER
29
+ SocketConnectHost=127.0.0.1
30
+ SocketConnectPort=9878
31
+
32
+ Multiple ``[SESSION]`` blocks are allowed; each must define
33
+ ``BeginString``, ``SenderCompID``, and ``TargetCompID``.
34
+ """
35
+
36
+ def __init__(self) -> None:
37
+ self._defaults: dict[str, str] = {}
38
+ # session_id → settings dict
39
+ self._sessions: dict[SessionID, dict[str, str]] = {}
40
+
41
+ # ------------------------------------------------------------------
42
+ # Parsing
43
+ # ------------------------------------------------------------------
44
+
45
+ @classmethod
46
+ def from_file(cls, path: str | Path) -> "SessionSettings":
47
+ settings = cls()
48
+ text = Path(path).read_text()
49
+ parser = configparser.RawConfigParser()
50
+ parser.optionxform = str # preserve case
51
+ import io
52
+ parser.read_file(io.StringIO(cls._preprocess(text)))
53
+
54
+ if parser.has_section(_DEFAULT_SECTION) or parser.defaults():
55
+ settings._defaults = dict(parser.defaults())
56
+
57
+ for section in parser.sections():
58
+ if section.upper().startswith(_SESSION_SECTION_PREFIX):
59
+ items = dict(parser.items(section))
60
+ session_id = SessionID(
61
+ begin_string=items["BeginString"],
62
+ sender_comp_id=items["SenderCompID"],
63
+ target_comp_id=items["TargetCompID"],
64
+ qualifier=items.get("SessionQualifier", ""),
65
+ )
66
+ settings._sessions[session_id] = items
67
+
68
+ return settings
69
+
70
+ @staticmethod
71
+ def _preprocess(text: str) -> str:
72
+ """Rename duplicate [SESSION] headers to [SESSION_0], [SESSION_1], ...
73
+
74
+ configparser rejects duplicate section names; QuickFIX configs routinely
75
+ have multiple [SESSION] blocks.
76
+ """
77
+ lines = text.splitlines(keepends=True)
78
+ result: list[str] = []
79
+ session_count = 0
80
+ for line in lines:
81
+ if line.strip().upper() == "[SESSION]":
82
+ line = f"[SESSION_{session_count}]\n"
83
+ session_count += 1
84
+ result.append(line)
85
+ return "".join(result)
86
+
87
+ @classmethod
88
+ def from_string(cls, text: str) -> "SessionSettings":
89
+ """Parse from a configuration string (useful in tests)."""
90
+ import io
91
+
92
+ settings = cls()
93
+ parser = configparser.RawConfigParser()
94
+ parser.optionxform = str
95
+ parser.read_file(io.StringIO(cls._preprocess(text)))
96
+
97
+ settings._defaults = dict(parser.defaults())
98
+
99
+ for section in parser.sections():
100
+ if section.upper().startswith(_SESSION_SECTION_PREFIX):
101
+ items = dict(parser.items(section))
102
+ session_id = SessionID(
103
+ begin_string=items["BeginString"],
104
+ sender_comp_id=items["SenderCompID"],
105
+ target_comp_id=items["TargetCompID"],
106
+ qualifier=items.get("SessionQualifier", ""),
107
+ )
108
+ settings._sessions[session_id] = items
109
+
110
+ return settings
111
+
112
+ # ------------------------------------------------------------------
113
+ # Access
114
+ # ------------------------------------------------------------------
115
+
116
+ def get(self, session_id: SessionID, key: str) -> str:
117
+ """Return the value for *key* in *session_id*'s section.
118
+
119
+ Falls back to the DEFAULT section. Raises KeyError if not found.
120
+ """
121
+ session = self._sessions.get(session_id, {})
122
+ if key in session:
123
+ return session[key]
124
+ if key in self._defaults:
125
+ return self._defaults[key]
126
+ raise KeyError(f"Setting {key!r} not found for {session_id}")
127
+
128
+ def get_or(self, session_id: SessionID, key: str, default: Any = None) -> Any:
129
+ try:
130
+ return self.get(session_id, key)
131
+ except KeyError:
132
+ return default
133
+
134
+ def get_bool(self, session_id: SessionID, key: str, default: bool = False) -> bool:
135
+ val = self.get_or(session_id, key)
136
+ if val is None:
137
+ return default
138
+ return val.strip().upper() in ("Y", "YES", "TRUE", "1")
139
+
140
+ def get_int(self, session_id: SessionID, key: str, default: int = 0) -> int:
141
+ val = self.get_or(session_id, key)
142
+ return int(val) if val is not None else default
143
+
144
+ @property
145
+ def session_ids(self) -> list[SessionID]:
146
+ return list(self._sessions.keys())
@@ -0,0 +1,60 @@
1
+ """Session state enum and FIX session-layer constants."""
2
+
3
+ from enum import Enum, auto
4
+
5
+
6
+ class SessionState(Enum):
7
+ DISCONNECTED = auto()
8
+ LOGON_TIMEOUT = auto() # connected, Logon sent (initiator) or awaited (acceptor)
9
+ LOGGED_ON = auto()
10
+ LOGOUT_TIMEOUT = auto() # Logout sent, waiting for counterparty Logout
11
+
12
+
13
+ # ---------------------------------------------------------------------------
14
+ # Tag constants used by the session layer
15
+ # ---------------------------------------------------------------------------
16
+
17
+ TAG_BEGIN_STRING = 8
18
+ TAG_BODY_LENGTH = 9
19
+ TAG_MSG_TYPE = 35
20
+ TAG_CHECKSUM = 10
21
+
22
+ TAG_MSG_SEQ_NUM = 34
23
+ TAG_SENDER_COMP_ID = 49
24
+ TAG_TARGET_COMP_ID = 56
25
+ TAG_SENDING_TIME = 52
26
+ TAG_POSS_DUP_FLAG = 43
27
+ TAG_ORIG_SENDING_TIME = 122
28
+
29
+ TAG_ENCRYPT_METHOD = 98
30
+ TAG_HEART_BT_INT = 108
31
+ TAG_RESET_SEQ_NUM_FLAG = 141
32
+ TAG_TEST_REQ_ID = 112
33
+
34
+ TAG_BEGIN_SEQ_NO = 7
35
+ TAG_END_SEQ_NO = 16
36
+ TAG_NEW_SEQ_NO = 36
37
+ TAG_GAP_FILL_FLAG = 123
38
+
39
+ TAG_REF_SEQ_NUM = 45
40
+ TAG_REF_TAG_ID = 371
41
+ TAG_REF_MSG_TYPE = 372
42
+ TAG_SESSION_REJECT_REASON = 373
43
+ TAG_TEXT = 58
44
+
45
+ # ---------------------------------------------------------------------------
46
+ # Message type constants
47
+ # ---------------------------------------------------------------------------
48
+
49
+ MSG_HEARTBEAT = "0"
50
+ MSG_TEST_REQUEST = "1"
51
+ MSG_RESEND_REQUEST = "2"
52
+ MSG_REJECT = "3"
53
+ MSG_SEQUENCE_RESET = "4"
54
+ MSG_LOGOUT = "5"
55
+ MSG_LOGON = "A"
56
+
57
+ ADMIN_MSG_TYPES: frozenset[str] = frozenset({
58
+ MSG_HEARTBEAT, MSG_TEST_REQUEST, MSG_RESEND_REQUEST,
59
+ MSG_REJECT, MSG_SEQUENCE_RESET, MSG_LOGOUT, MSG_LOGON,
60
+ })
@@ -0,0 +1,11 @@
1
+ """Message store — persistence of sent/received messages and sequence numbers."""
2
+
3
+ from fixcore.store.base import MessageStore
4
+ from fixcore.store.factory import FileStoreFactory, MemoryStoreFactory, StoreFactory
5
+ from fixcore.store.file_store import FileStore
6
+ from fixcore.store.memory import MemoryStore
7
+
8
+ __all__ = [
9
+ "MessageStore", "StoreFactory", "MemoryStoreFactory", "FileStoreFactory",
10
+ "FileStore", "MemoryStore",
11
+ ]
fixcore/store/base.py ADDED
@@ -0,0 +1,49 @@
1
+ """MessageStore abstract base class."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from abc import ABC, abstractmethod
6
+
7
+
8
+ class MessageStore(ABC):
9
+ """Persists sent messages and tracks sequence numbers for a single session."""
10
+
11
+ @abstractmethod
12
+ def get(self, begin_seq: int, end_seq: int) -> list[bytes]:
13
+ """Return stored messages with sequence numbers in [begin_seq, end_seq]."""
14
+
15
+ @abstractmethod
16
+ def set(self, seq_num: int, message: bytes) -> None:
17
+ """Store *message* at *seq_num*."""
18
+
19
+ @abstractmethod
20
+ def next_sender_msg_seq_num(self) -> int:
21
+ """Return the next outbound sequence number."""
22
+
23
+ @abstractmethod
24
+ def next_target_msg_seq_num(self) -> int:
25
+ """Return the next expected inbound sequence number."""
26
+
27
+ @abstractmethod
28
+ def incr_next_sender_msg_seq_num(self) -> None:
29
+ """Increment the outbound sequence number by 1."""
30
+
31
+ @abstractmethod
32
+ def incr_next_target_msg_seq_num(self) -> None:
33
+ """Increment the expected inbound sequence number by 1."""
34
+
35
+ @abstractmethod
36
+ def set_next_sender_msg_seq_num(self, seq_num: int) -> None:
37
+ """Override the outbound sequence number."""
38
+
39
+ @abstractmethod
40
+ def set_next_target_msg_seq_num(self, seq_num: int) -> None:
41
+ """Override the expected inbound sequence number."""
42
+
43
+ @abstractmethod
44
+ def reset(self) -> None:
45
+ """Clear all stored messages and reset sequence numbers to 1."""
46
+
47
+ @abstractmethod
48
+ def refresh(self) -> None:
49
+ """Reload state from the underlying store (no-op for in-memory)."""
@@ -0,0 +1,33 @@
1
+ """MessageStore factory — creates a store per session."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from abc import ABC, abstractmethod
6
+ from pathlib import Path
7
+
8
+ from fixcore.session.session_id import SessionID
9
+ from fixcore.store.base import MessageStore
10
+
11
+
12
+ class StoreFactory(ABC):
13
+ @abstractmethod
14
+ def create(self, session_id: SessionID) -> MessageStore: ...
15
+
16
+
17
+ class MemoryStoreFactory(StoreFactory):
18
+ """Creates a fresh in-memory store for each session (non-persistent)."""
19
+
20
+ def create(self, session_id: SessionID) -> MessageStore:
21
+ from fixcore.store.memory import MemoryStore
22
+ return MemoryStore()
23
+
24
+
25
+ class FileStoreFactory(StoreFactory):
26
+ """Creates a FileStore for each session under *store_dir*."""
27
+
28
+ def __init__(self, store_dir: str | Path) -> None:
29
+ self._dir = Path(store_dir)
30
+
31
+ def create(self, session_id: SessionID) -> MessageStore:
32
+ from fixcore.store.file_store import FileStore
33
+ return FileStore(self._dir, session_id)
@@ -0,0 +1,162 @@
1
+ """File-backed MessageStore — persists messages and sequence numbers to disk.
2
+
3
+ Layout (one set of files per session, under *store_dir*)::
4
+
5
+ FIX.4.2-CLIENT-SERVER.body — raw FIX messages, newline-separated
6
+ FIX.4.2-CLIENT-SERVER.index — "seq_num=offset,length\\n" per stored message
7
+ FIX.4.2-CLIENT-SERVER.seqnums — "sender=N\\ntarget=M\\n"
8
+
9
+ The index is held in memory after init for O(1) random access. The body and
10
+ index files are append-only; ``reset()`` truncates them.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import os
16
+ from pathlib import Path
17
+
18
+ from fixcore.session.session_id import SessionID
19
+ from fixcore.store.base import MessageStore
20
+
21
+
22
+ def _session_prefix(session_id: SessionID) -> str:
23
+ """Convert a SessionID to a safe filesystem name."""
24
+ return (
25
+ f"{session_id.begin_string}-"
26
+ f"{session_id.sender_comp_id}-"
27
+ f"{session_id.target_comp_id}"
28
+ + (f"-{session_id.qualifier}" if session_id.qualifier else "")
29
+ )
30
+
31
+
32
+ class FileStore(MessageStore):
33
+ """Durable MessageStore backed by three flat files per session.
34
+
35
+ Parameters
36
+ ----------
37
+ store_dir:
38
+ Directory where session files are written. Created if absent.
39
+ session_id:
40
+ Identifies the session; used to derive file names.
41
+ """
42
+
43
+ def __init__(self, store_dir: str | Path, session_id: SessionID) -> None:
44
+ self._dir = Path(store_dir)
45
+ self._dir.mkdir(parents=True, exist_ok=True)
46
+
47
+ prefix = self._dir / _session_prefix(session_id)
48
+ self._body_path = Path(f"{prefix}.body")
49
+ self._index_path = Path(f"{prefix}.index")
50
+ self._seqnums_path = Path(f"{prefix}.seqnums")
51
+
52
+ # In-memory index: seq_num → (byte_offset, length)
53
+ self._index: dict[int, tuple[int, int]] = {}
54
+
55
+ self._next_sender: int = 1
56
+ self._next_target: int = 1
57
+
58
+ self._init_files()
59
+
60
+ # ------------------------------------------------------------------
61
+ # Initialisation
62
+ # ------------------------------------------------------------------
63
+
64
+ def _init_files(self) -> None:
65
+ """Create files if absent, then load persisted state."""
66
+ for path in (self._body_path, self._index_path, self._seqnums_path):
67
+ if not path.exists():
68
+ path.touch()
69
+
70
+ self._load_seqnums()
71
+ self._load_index()
72
+
73
+ def _load_seqnums(self) -> None:
74
+ text = self._seqnums_path.read_text()
75
+ self._next_sender = 1
76
+ self._next_target = 1
77
+ for line in text.splitlines():
78
+ if line.startswith("sender="):
79
+ self._next_sender = int(line[7:])
80
+ elif line.startswith("target="):
81
+ self._next_target = int(line[7:])
82
+
83
+ def _load_index(self) -> None:
84
+ self._index.clear()
85
+ for line in self._index_path.read_text().splitlines():
86
+ if not line:
87
+ continue
88
+ # format: seq_num=offset,length
89
+ eq = line.index("=")
90
+ seq = int(line[:eq])
91
+ offset_str, length_str = line[eq + 1:].split(",")
92
+ self._index[seq] = (int(offset_str), int(length_str))
93
+
94
+ def _flush_seqnums(self) -> None:
95
+ self._seqnums_path.write_text(
96
+ f"sender={self._next_sender}\ntarget={self._next_target}\n"
97
+ )
98
+
99
+ # ------------------------------------------------------------------
100
+ # MessageStore interface
101
+ # ------------------------------------------------------------------
102
+
103
+ def get(self, begin_seq: int, end_seq: int) -> list[bytes]:
104
+ results: list[bytes] = []
105
+ with self._body_path.open("rb") as fh:
106
+ for seq in range(begin_seq, end_seq + 1):
107
+ entry = self._index.get(seq)
108
+ if entry is None:
109
+ continue
110
+ offset, length = entry
111
+ fh.seek(offset)
112
+ results.append(fh.read(length))
113
+ return results
114
+
115
+ def set(self, seq_num: int, message: bytes) -> None:
116
+ # Append message to body (newline-terminated for human readability)
117
+ with self._body_path.open("ab") as fh:
118
+ offset = fh.seek(0, os.SEEK_END)
119
+ fh.write(message)
120
+ fh.write(b"\n")
121
+ length = len(message)
122
+
123
+ # Append index entry
124
+ with self._index_path.open("a") as fh:
125
+ fh.write(f"{seq_num}={offset},{length}\n")
126
+
127
+ self._index[seq_num] = (offset, length)
128
+
129
+ def next_sender_msg_seq_num(self) -> int:
130
+ return self._next_sender
131
+
132
+ def next_target_msg_seq_num(self) -> int:
133
+ return self._next_target
134
+
135
+ def incr_next_sender_msg_seq_num(self) -> None:
136
+ self._next_sender += 1
137
+ self._flush_seqnums()
138
+
139
+ def incr_next_target_msg_seq_num(self) -> None:
140
+ self._next_target += 1
141
+ self._flush_seqnums()
142
+
143
+ def set_next_sender_msg_seq_num(self, seq_num: int) -> None:
144
+ self._next_sender = seq_num
145
+ self._flush_seqnums()
146
+
147
+ def set_next_target_msg_seq_num(self, seq_num: int) -> None:
148
+ self._next_target = seq_num
149
+ self._flush_seqnums()
150
+
151
+ def reset(self) -> None:
152
+ self._body_path.write_bytes(b"")
153
+ self._index_path.write_text("")
154
+ self._index.clear()
155
+ self._next_sender = 1
156
+ self._next_target = 1
157
+ self._flush_seqnums()
158
+
159
+ def refresh(self) -> None:
160
+ """Reload all state from disk (e.g. after external modification)."""
161
+ self._load_seqnums()
162
+ self._load_index()
@@ -0,0 +1,50 @@
1
+ """In-memory MessageStore implementation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from fixcore.store.base import MessageStore
6
+
7
+
8
+ class MemoryStore(MessageStore):
9
+ """Non-persistent store — useful for testing and ephemeral sessions."""
10
+
11
+ def __init__(self) -> None:
12
+ self._messages: dict[int, bytes] = {}
13
+ self._next_sender: int = 1
14
+ self._next_target: int = 1
15
+
16
+ def get(self, begin_seq: int, end_seq: int) -> list[bytes]:
17
+ return [
18
+ self._messages[seq]
19
+ for seq in range(begin_seq, end_seq + 1)
20
+ if seq in self._messages
21
+ ]
22
+
23
+ def set(self, seq_num: int, message: bytes) -> None:
24
+ self._messages[seq_num] = message
25
+
26
+ def next_sender_msg_seq_num(self) -> int:
27
+ return self._next_sender
28
+
29
+ def next_target_msg_seq_num(self) -> int:
30
+ return self._next_target
31
+
32
+ def incr_next_sender_msg_seq_num(self) -> None:
33
+ self._next_sender += 1
34
+
35
+ def incr_next_target_msg_seq_num(self) -> None:
36
+ self._next_target += 1
37
+
38
+ def set_next_sender_msg_seq_num(self, seq_num: int) -> None:
39
+ self._next_sender = seq_num
40
+
41
+ def set_next_target_msg_seq_num(self, seq_num: int) -> None:
42
+ self._next_target = seq_num
43
+
44
+ def reset(self) -> None:
45
+ self._messages.clear()
46
+ self._next_sender = 1
47
+ self._next_target = 1
48
+
49
+ def refresh(self) -> None:
50
+ pass # nothing to reload
@@ -0,0 +1,7 @@
1
+ """Transport layer — asyncio-based TCP acceptor and initiator."""
2
+
3
+ from fixcore.transport.acceptor import SocketAcceptor
4
+ from fixcore.transport.framer import MessageFramer
5
+ from fixcore.transport.initiator import SocketInitiator
6
+
7
+ __all__ = ["MessageFramer", "SocketAcceptor", "SocketInitiator"]
@@ -0,0 +1,166 @@
1
+ """SocketAcceptor — listens for inbound FIX connections."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+
7
+ from fixcore.application import Application
8
+ from fixcore.log.base import LogFactory
9
+ from fixcore.message.message import Message
10
+ from fixcore.session.session import Session
11
+ from fixcore.session.session_id import SessionID
12
+ from fixcore.session.session_settings import SessionSettings
13
+ from fixcore.session.state import TAG_BEGIN_STRING, TAG_SENDER_COMP_ID, TAG_TARGET_COMP_ID
14
+ from fixcore.store.factory import StoreFactory
15
+ from fixcore.transport.framer import MessageFramer
16
+
17
+
18
+ class SocketAcceptor:
19
+ """Listens on a TCP port and routes incoming connections to the correct Session.
20
+
21
+ One :class:`~fixcore.session.Session` is pre-created per configured
22
+ acceptor session. When a connection arrives, the first FIX message
23
+ (expected to be a Logon) is used to look up the matching session by
24
+ BeginString/SenderCompID/TargetCompID.
25
+
26
+ Usage::
27
+
28
+ acceptor = SocketAcceptor(settings, app, store_factory, log_factory)
29
+ async with acceptor:
30
+ await asyncio.sleep(3600) # serve for an hour
31
+ """
32
+
33
+ def __init__(
34
+ self,
35
+ settings: SessionSettings,
36
+ application: Application,
37
+ store_factory: StoreFactory,
38
+ log_factory: LogFactory,
39
+ ) -> None:
40
+ self._settings = settings
41
+ self._app = application
42
+ self._store_factory = store_factory
43
+ self._log_factory = log_factory
44
+
45
+ self._sessions: dict[SessionID, Session] = {}
46
+ self._server: asyncio.Server | None = None
47
+ self._connection_tasks: set[asyncio.Task[None]] = set()
48
+
49
+ for sid in settings.session_ids:
50
+ conn_type = settings.get_or(sid, "ConnectionType", "acceptor").lower()
51
+ if conn_type == "acceptor":
52
+ self._sessions[sid] = Session(
53
+ sid, settings, application,
54
+ store_factory.create(sid),
55
+ log_factory.create(sid),
56
+ )
57
+
58
+ # ------------------------------------------------------------------
59
+ # Lifecycle
60
+ # ------------------------------------------------------------------
61
+
62
+ async def start(self) -> None:
63
+ """Start the TCP server. Binds to SocketAcceptPort (default 9878)."""
64
+ # Use the first acceptor session to get host/port
65
+ sid = next(iter(self._sessions))
66
+ host = self._settings.get_or(sid, "SocketAcceptHost", "0.0.0.0")
67
+ port = int(self._settings.get_or(sid, "SocketAcceptPort", "9878"))
68
+
69
+ self._server = await asyncio.start_server(
70
+ self._handle_connection, host, port
71
+ )
72
+
73
+ async def stop(self) -> None:
74
+ """Stop accepting new connections and close all active sessions."""
75
+ # Cancel all in-flight connection handlers (triggers their finally blocks)
76
+ for task in list(self._connection_tasks):
77
+ task.cancel()
78
+ if self._connection_tasks:
79
+ await asyncio.gather(*self._connection_tasks, return_exceptions=True)
80
+ self._connection_tasks.clear()
81
+
82
+ if self._server is not None:
83
+ self._server.close()
84
+ await self._server.wait_closed()
85
+ self._server = None
86
+
87
+ async def __aenter__(self) -> "SocketAcceptor":
88
+ await self.start()
89
+ return self
90
+
91
+ async def __aexit__(self, *_: object) -> None:
92
+ await self.stop()
93
+
94
+ @property
95
+ def sessions(self) -> dict[SessionID, Session]:
96
+ return dict(self._sessions)
97
+
98
+ # ------------------------------------------------------------------
99
+ # Connection handler
100
+ # ------------------------------------------------------------------
101
+
102
+ async def _handle_connection(
103
+ self,
104
+ reader: asyncio.StreamReader,
105
+ writer: asyncio.StreamWriter,
106
+ ) -> None:
107
+ framer = MessageFramer()
108
+ session: Session | None = None
109
+ task = asyncio.current_task()
110
+ if task is not None:
111
+ self._connection_tasks.add(task)
112
+
113
+ async def send_fn(data: bytes) -> None:
114
+ writer.write(data)
115
+ await writer.drain()
116
+
117
+ try:
118
+ while True:
119
+ try:
120
+ data = await reader.read(4096)
121
+ except (ConnectionResetError, asyncio.IncompleteReadError, asyncio.CancelledError):
122
+ break
123
+ if not data:
124
+ break
125
+
126
+ for raw_msg in framer.feed(data):
127
+ if session is None:
128
+ session = self._find_session(raw_msg)
129
+ if session is None:
130
+ # Unknown session — close connection
131
+ return
132
+ await session.on_connect(send_fn)
133
+
134
+ await session.on_data(raw_msg)
135
+
136
+ finally:
137
+ task = asyncio.current_task()
138
+ if task is not None:
139
+ self._connection_tasks.discard(task)
140
+ if session is not None:
141
+ await session.on_disconnect()
142
+ try:
143
+ writer.close()
144
+ await writer.wait_closed()
145
+ except Exception:
146
+ pass
147
+
148
+ def _find_session(self, raw_msg: bytes) -> Session | None:
149
+ """Match an incoming message to a pre-configured session."""
150
+ try:
151
+ msg = Message.decode(raw_msg)
152
+ except ValueError:
153
+ return None
154
+
155
+ begin = msg.header.get_or(TAG_BEGIN_STRING)
156
+ sender = msg.header.get_or(TAG_SENDER_COMP_ID)
157
+ target = msg.header.get_or(TAG_TARGET_COMP_ID)
158
+
159
+ for sid, session in self._sessions.items():
160
+ if (
161
+ sid.begin_string == begin
162
+ and sid.sender_comp_id == target # our sender == their target
163
+ and sid.target_comp_id == sender # our target == their sender
164
+ ):
165
+ return session
166
+ return None