pycfdp 0.2.2__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.
Files changed (53) hide show
  1. cfdp/__init__.py +106 -0
  2. cfdp/checksum.py +94 -0
  3. cfdp/constants.py +153 -0
  4. cfdp/core.py +895 -0
  5. cfdp/event.py +55 -0
  6. cfdp/filestore/__init__.py +2 -0
  7. cfdp/filestore/base.py +99 -0
  8. cfdp/filestore/native.py +162 -0
  9. cfdp/meta/__init__.py +3 -0
  10. cfdp/meta/datafield.py +11 -0
  11. cfdp/meta/filestore.py +127 -0
  12. cfdp/meta/message.py +659 -0
  13. cfdp/pdu/__init__.py +8 -0
  14. cfdp/pdu/ack.py +90 -0
  15. cfdp/pdu/eof.py +123 -0
  16. cfdp/pdu/filedata.py +90 -0
  17. cfdp/pdu/finished.py +99 -0
  18. cfdp/pdu/header.py +159 -0
  19. cfdp/pdu/keep_alive.py +108 -0
  20. cfdp/pdu/metadata.py +211 -0
  21. cfdp/pdu/nak.py +220 -0
  22. cfdp/pdu/prompt.py +75 -0
  23. cfdp/pure/__init__.py +68 -0
  24. cfdp/pure/config.py +141 -0
  25. cfdp/pure/indications.py +130 -0
  26. cfdp/pure/machines/__init__.py +25 -0
  27. cfdp/pure/machines/receiver1.py +433 -0
  28. cfdp/pure/machines/receiver2.py +893 -0
  29. cfdp/pure/machines/sender1.py +400 -0
  30. cfdp/pure/machines/sender2.py +774 -0
  31. cfdp/pure/outputs.py +203 -0
  32. cfdp/pure/query_port.py +48 -0
  33. cfdp/pure/transaction.py +43 -0
  34. cfdp/py.typed +0 -0
  35. cfdp/remote_entity_config.py +12 -0
  36. cfdp/shell/__init__.py +44 -0
  37. cfdp/shell/checksum_service.py +136 -0
  38. cfdp/shell/dispatcher.py +185 -0
  39. cfdp/shell/effect_executor.py +285 -0
  40. cfdp/shell/machine_registry.py +165 -0
  41. cfdp/shell/query_port.py +70 -0
  42. cfdp/shell/timer_service.py +252 -0
  43. cfdp/transaction_handle.py +50 -0
  44. cfdp/transport/__init__.py +0 -0
  45. cfdp/transport/base.py +12 -0
  46. cfdp/transport/spacepacket.py +47 -0
  47. cfdp/transport/udp.py +90 -0
  48. cfdp/transport/zmq.py +54 -0
  49. pycfdp-0.2.2.dist-info/METADATA +76 -0
  50. pycfdp-0.2.2.dist-info/RECORD +53 -0
  51. pycfdp-0.2.2.dist-info/WHEEL +5 -0
  52. pycfdp-0.2.2.dist-info/licenses/LICENSE.txt +19 -0
  53. pycfdp-0.2.2.dist-info/top_level.txt +1 -0
cfdp/pure/outputs.py ADDED
@@ -0,0 +1,203 @@
1
+ """Output taxonomy for the pure machine boundary (design D3/D8/D9).
2
+
3
+ A pure machine's ``update_state(event) -> list[Output]`` returns an ordered list
4
+ of :class:`Output` values. Each output is either an **effect** (a command the
5
+ shell's ``EffectExecutor`` interprets) or an **internal event** (an existing
6
+ ``EventType`` the dispatcher re-enqueues on the shared FIFO queue).
7
+
8
+ All outputs are frozen dataclasses so they are readable, pattern-matchable, and
9
+ value-equatable — the last property is what makes differential trace comparison
10
+ against the old machines possible.
11
+ """
12
+
13
+ from dataclasses import dataclass
14
+ from typing import Any
15
+
16
+
17
+ class TimerKind:
18
+ """The wall-clock timers a machine may drive (design D6)."""
19
+
20
+ ACK = "ACK"
21
+ NAK = "NAK"
22
+ INACTIVITY = "INACTIVITY"
23
+
24
+
25
+ class IndicationKind:
26
+ """CFDP indications a machine may emit, mirroring ``CfdpEntity._indication_*``."""
27
+
28
+ TRANSACTION = "transaction"
29
+ EOF_SENT = "eof_sent"
30
+ EOF_RECEIVED = "eof_received"
31
+ TRANSACTION_FINISHED = "transaction_finished"
32
+ METADATA_RECEIVED = "metadata_received"
33
+ FILESEGMENT_RECEIVED = "filesegment_received"
34
+ SUSPENDED = "suspended"
35
+ RESUMED = "resumed"
36
+ REPORT = "report"
37
+ FAULT = "fault"
38
+ ABANDONED = "abandoned"
39
+
40
+
41
+ class Output:
42
+ """Marker base class for everything a machine returns."""
43
+
44
+
45
+ # --------------------------------------------------------------------------- #
46
+ # Effects — commands the shell executes #
47
+ # --------------------------------------------------------------------------- #
48
+
49
+
50
+ @dataclass(frozen=True)
51
+ class SendPdu(Output):
52
+ """Encode and transmit a PDU the machine has already built (pure)."""
53
+
54
+ pdu: Any
55
+
56
+
57
+ @dataclass(frozen=True)
58
+ class OpenSource(Output):
59
+ """Open the transaction's source file for reading (send side)."""
60
+
61
+
62
+ @dataclass(frozen=True)
63
+ class OpenTemp(Output):
64
+ """Open a temporary file to receive incoming file data (receive side)."""
65
+
66
+
67
+ @dataclass(frozen=True)
68
+ class WriteSegment(Output):
69
+ """Write ``data`` at ``offset`` into the receive-side file."""
70
+
71
+ offset: int
72
+ data: bytes
73
+
74
+
75
+ @dataclass(frozen=True)
76
+ class ExecuteFilestoreRequests(Output):
77
+ """Execute the transaction's filestore requests against the filestore.
78
+
79
+ The receive side runs the Metadata-carried filestore requests (create /
80
+ delete / rename / ... ) at Finished time. The pure machine holds the request
81
+ TLVs but never touches the filestore; the shell interprets this effect via
82
+ ``cfdp.meta.execute_filestore_requests`` (design D3/D10 — mutating filestore
83
+ operations are returned commands, not sync queries).
84
+ """
85
+
86
+ requests: Any
87
+
88
+
89
+ @dataclass(frozen=True)
90
+ class Finalize(Output):
91
+ """Copy the completed temp file to its destination (receive side).
92
+
93
+ ``destination_filename`` carries the resolved destination path from machine
94
+ state. This matters for Class-2 recovery: when the Metadata PDU is lost and
95
+ later recovered via NAK, the destination filename is only known on the
96
+ machine (the frozen :class:`~cfdp.pure.transaction.Transaction` was built
97
+ from a filename-less Filedata/EOF PDU). ``None`` means "fall back to the
98
+ transaction's ``destination_filename``" — the Class-1 path, where Metadata
99
+ always arrives first, leaves this unset.
100
+ """
101
+
102
+ destination_filename: Any = None
103
+
104
+
105
+ @dataclass(frozen=True)
106
+ class CloseFile(Output):
107
+ """Close the transaction's open file handle."""
108
+
109
+
110
+ @dataclass(frozen=True)
111
+ class StartTimer(Output):
112
+ """(Re)start the timer of ``kind`` with the given interval in seconds."""
113
+
114
+ kind: str
115
+ interval: float
116
+
117
+
118
+ @dataclass(frozen=True)
119
+ class CancelTimer(Output):
120
+ """Cancel the timer of ``kind``."""
121
+
122
+ kind: str
123
+
124
+
125
+ @dataclass(frozen=True)
126
+ class SuspendTimer(Output):
127
+ """Suspend (cancel-without-forget) the timer of ``kind``."""
128
+
129
+ kind: str
130
+
131
+
132
+ @dataclass(frozen=True)
133
+ class ResumeTimer(Output):
134
+ """Resume (restart) a previously suspended timer of ``kind``."""
135
+
136
+ kind: str
137
+
138
+
139
+ @dataclass(frozen=True)
140
+ class ScheduleEvent(Output):
141
+ """Enqueue ``event`` on the shared FIFO queue after ``delay`` seconds.
142
+
143
+ Used for paced re-triggers (e.g. the ``inter_pdu_delay`` file-data pump).
144
+ A delay of ``0.0`` is an immediate re-enqueue.
145
+ """
146
+
147
+ event: str
148
+ delay: float = 0.0
149
+
150
+
151
+ @dataclass(frozen=True)
152
+ class EmitIndication(Output):
153
+ """Emit a CFDP indication of ``kind`` with positional ``args``.
154
+
155
+ ``kind`` is an :class:`IndicationKind` value; the shell maps it to the
156
+ matching ``CfdpEntity._indication_*`` call.
157
+ """
158
+
159
+ kind: str
160
+ args: tuple[Any, ...] = ()
161
+
162
+
163
+ @dataclass(frozen=True)
164
+ class PostUserMessage(Output):
165
+ """Post a message-to-user onto the entity's message thread."""
166
+
167
+ message: Any
168
+
169
+
170
+ @dataclass(frozen=True)
171
+ class RequestChecksum(Output):
172
+ """Ask the shell to compute the file checksum asynchronously (issue #21).
173
+
174
+ The result returns as an injected ``E42_CHECKSUM_READY`` event carrying the
175
+ value on ``event.checksum`` (design R1: the async-result channel is baseline
176
+ shell machinery so ``checksum`` runs off the single dispatcher thread and a
177
+ large-file checksum no longer stalls every other transaction).
178
+
179
+ ``checksum_type`` is the algorithm to use. ``from_temp_handle`` selects the
180
+ file: the receive side (``True``) reads back the shell-held in-progress temp
181
+ handle; the send side (``False``) opens the source file **afresh** so the
182
+ checksum worker never shares the file-data pump's handle (which it seeks and
183
+ later closes on a different thread).
184
+ """
185
+
186
+ checksum_type: int = 0
187
+ from_temp_handle: bool = False
188
+
189
+
190
+ # --------------------------------------------------------------------------- #
191
+ # Internal event — an existing EventType re-enqueued by the dispatcher #
192
+ # --------------------------------------------------------------------------- #
193
+
194
+
195
+ @dataclass(frozen=True)
196
+ class InternalEvent(Output):
197
+ """An existing ``EventType`` the machine re-feeds through the shell (D2/D8).
198
+
199
+ The dispatcher enqueues it on the shared FIFO queue immediately. Delayed
200
+ re-triggers use :class:`ScheduleEvent` instead.
201
+ """
202
+
203
+ event: str
@@ -0,0 +1,48 @@
1
+ """The read-only synchronous query port (design D3/D5).
2
+
3
+ The machine may call these mid-decision, synchronously. Per D5 they touch only
4
+ **local, fast, non-blocking** resources — the local filestore and the local
5
+ transport-buffer readiness — never the remote peer. Anything remote or
6
+ possibly-slow must be a command + injected result event instead.
7
+
8
+ This module defines the interface only; concrete backing (filestore + transport
9
+ + the in-progress temp handle) is wired up in the shell seam (issue #16).
10
+ """
11
+
12
+ from typing import Protocol, runtime_checkable
13
+
14
+
15
+ @runtime_checkable
16
+ class QueryPort(Protocol):
17
+ """Local-only, synchronous reads a pure machine may perform."""
18
+
19
+ def is_file(self, filename: str) -> bool:
20
+ """Return True if ``filename`` names an existing file in the filestore."""
21
+ ...
22
+
23
+ def size(self, filename: str) -> int:
24
+ """Return the size in bytes of ``filename`` (0 if absent)."""
25
+ ...
26
+
27
+ def checksum(self, checksum_type: int) -> int:
28
+ """Return the checksum of the transaction's current file.
29
+
30
+ No longer called by the pure machines: as of issue #21 checksum is an
31
+ async effect (:class:`~cfdp.pure.outputs.RequestChecksum` -> the
32
+ shell's :class:`~cfdp.shell.checksum_service.ChecksumService` ->
33
+ ``E42_CHECKSUM_READY``) so a large-file checksum never blocks the
34
+ dispatcher (design R1). Retained on the port interface for the
35
+ differential test harness, which splices the async hop synchronously.
36
+ """
37
+ ...
38
+
39
+ def read_segment(self, offset: int, length: int) -> tuple[int, bytes]:
40
+ """Read ``length`` bytes from the source file starting at ``offset``.
41
+
42
+ Returns ``(offset, data)`` mirroring the old ``Transaction.get_file_segment``.
43
+ """
44
+ ...
45
+
46
+ def transport_ready(self) -> bool:
47
+ """Return True if the local transport buffer can accept another PDU."""
48
+ ...
@@ -0,0 +1,43 @@
1
+ """Frozen ``Transaction`` value type (design D3, "Data model").
2
+
3
+ This is the immutable identity + descriptor of a transaction: entity ids, the
4
+ sequence number, transmission mode, filenames, and checksum type — exactly the
5
+ fields the design's "Data model" section lists, nothing more. It carries **no**
6
+ ``kernel`` reference, **no** ``file_handle``, and **no** mutable runtime state —
7
+ all of that moves into the pure machine as plain data or into the shell. The
8
+ request payload (messages-to-user / filestore requests) is *not* identity; it
9
+ arrives with the creating event, not on this descriptor.
10
+
11
+ This is the only transaction type in the codebase; it is re-exported as the
12
+ public ``cfdp.Transaction`` (the pre-cutover mutable, kernel-coupled Transaction
13
+ was removed with the old machines, issue #22).
14
+ """
15
+
16
+ from dataclasses import dataclass
17
+ from typing import Any
18
+
19
+ from ..constants import ChecksumType
20
+
21
+ # The identity of a transaction: ``(source_entity_id, seq_number)``. Entity ids
22
+ # are protocol-opaque (int or bytes depending on the deployment), hence ``Any``
23
+ # for the first slot. This is the single most repeated implicit type across the
24
+ # machines and shell — naming it once makes those signatures self-documenting.
25
+ TransactionId = tuple[Any, int]
26
+
27
+
28
+ @dataclass(frozen=True)
29
+ class Transaction:
30
+ """Immutable end-to-end descriptor of a single File Delivery Unit transfer."""
31
+
32
+ source_entity_id: Any
33
+ seq_number: int
34
+ destination_entity_id: Any
35
+ transmission_mode: int
36
+ source_filename: str | None = None
37
+ destination_filename: str | None = None
38
+ checksum_type: int = ChecksumType.MODULAR
39
+
40
+ @property
41
+ def id(self) -> TransactionId:
42
+ """Transaction id: ``(source_entity_id, seq_number)``."""
43
+ return (self.source_entity_id, self.seq_number)
cfdp/py.typed ADDED
File without changes
@@ -0,0 +1,12 @@
1
+ from dataclasses import dataclass
2
+
3
+
4
+ @dataclass
5
+ class RemoteEntityConfig:
6
+ ack_timer_interval: float | None = None
7
+ ack_timer_expiration_limit: int | None = None
8
+ nak_timer_interval: float | None = None
9
+ nak_timer_expiration_limit: int | None = None
10
+ transaction_inactivity_limit: float | None = None
11
+ default_fault_handlers: dict | None = None
12
+ inter_pdu_delay: float | None = None
cfdp/shell/__init__.py ADDED
@@ -0,0 +1,44 @@
1
+ """Imperative shell for the pure state machines (issue #16).
2
+
3
+ The pure machines (``cfdp.pure.machines``) are functions of ``(state, event)``
4
+ returning an ordered ``list[Output]`` (design D1). This package is the
5
+ **imperative shell** that runs them over real I/O:
6
+
7
+ * :class:`~cfdp.shell.effect_executor.EffectExecutor` — interprets effect
8
+ outputs and owns the ``tid -> handle`` file table and file lifecycle (D10).
9
+ * :class:`~cfdp.shell.query_port.EntityQueryPort` — backs the synchronous,
10
+ local-only query port a machine calls mid-decision (D3/D5), reading the source
11
+ filestore and the shell-held in-progress temp handle.
12
+ * :class:`~cfdp.shell.machine_registry.MachineRegistry` — the ``tid -> machine``
13
+ map, machine creation (from ``put`` and from inbound Metadata/Filedata/EOF),
14
+ completion detection and TTL reaping (D7).
15
+ * :class:`~cfdp.shell.timer_service.TimerService` — the ``(tid, kind)`` wall-clock
16
+ table with start/cancel/suspend/resume semantics; injects an expiry as a normal
17
+ ``EventType`` event onto the shared FIFO so pure machines stay threadless (D6).
18
+ * :class:`~cfdp.shell.dispatcher.Dispatcher` — owns the single event thread +
19
+ queue, feeds events to the owning machine, walks the returned ordered output
20
+ list executing effects inline and re-enqueuing internal events on the shared
21
+ FIFO queue (D8), with a rigid per-effect exception boundary that maps a
22
+ failing effect to a fault event injected into the owning machine only (D4/D7).
23
+
24
+ Wired into :class:`cfdp.core.CfdpEntity` for all transfers — both Class-1
25
+ (``UNACKNOWLEDGED``) and Class-2 (``ACKNOWLEDGED``). This is the only machine
26
+ stack (the old machines were removed at the cutover, issue #22).
27
+ """
28
+
29
+ from .checksum_service import ChecksumService
30
+ from .dispatcher import Dispatcher
31
+ from .effect_executor import EffectExecutor, FileTable
32
+ from .machine_registry import MachineRegistry
33
+ from .query_port import EntityQueryPort
34
+ from .timer_service import TimerService
35
+
36
+ __all__ = [
37
+ "EffectExecutor",
38
+ "FileTable",
39
+ "EntityQueryPort",
40
+ "MachineRegistry",
41
+ "Dispatcher",
42
+ "TimerService",
43
+ "ChecksumService",
44
+ ]
@@ -0,0 +1,136 @@
1
+ """The shell's async-checksum worker (design R1 / issue #21).
2
+
3
+ A whole-file checksum is O(file size). Computing it on the single dispatcher
4
+ thread — as the sync query port did — freezes *every* transaction for the
5
+ duration of a large-file checksum. This service moves that work off the event
6
+ thread: a pure machine emits a ``RequestChecksum`` effect, the service computes
7
+ the value on a worker thread, and marshals the result back onto the entity's
8
+ shared FIFO as an ``E42_CHECKSUM_READY`` event (design D8 — the same one input
9
+ door as every other event). The worker never touches machine state directly.
10
+
11
+ **Which file is checksummed** mirrors the sync query port exactly (design D10):
12
+ the receive side reads back the shell-held temp handle in the
13
+ :class:`~cfdp.shell.effect_executor.FileTable`; the send side (no handle in the
14
+ table) opens the source afresh.
15
+
16
+ **Thread-safety / no leaks.** Workers are daemon threads tracked in a set under
17
+ a lock; :meth:`stop` joins them so no worker outlives entity shutdown (the suite
18
+ has active-thread-count assertions). A worker whose transaction was reaped while
19
+ it ran still injects its event harmlessly — the dispatcher drops an event for an
20
+ absent machine.
21
+
22
+ **Every worker resolves to exactly one injected event** (issue #24). The happy
23
+ path injects ``E42_CHECKSUM_READY``. If the computation itself fails (source
24
+ file removed before the worker reopens it, temp-handle read error, filestore
25
+ backend error), the worker must NOT silently drop the event: a Sender1 parked on
26
+ the EOF continuation has no inactivity timer, so a missing event strands the
27
+ transaction forever. On any compute failure the worker instead injects a
28
+ terminating ``E33_RECEIVED_CANCEL_REQUEST`` so the parked machine runs its
29
+ cancellation/fault path to a terminal state.
30
+ """
31
+
32
+ import threading
33
+
34
+ from .. import logger
35
+ from ..event import Event, EventType
36
+
37
+
38
+ class ChecksumService:
39
+ """Computes file checksums off the event thread and injects the result."""
40
+
41
+ def __init__(self, entity, file_table):
42
+ self._entity = entity
43
+ self._files = file_table
44
+ self._lock = threading.Lock()
45
+ self._workers = set()
46
+
47
+ def request(self, tid, transaction, checksum_type, from_temp_handle=False):
48
+ """Compute ``transaction``'s file checksum on a worker, then inject E42.
49
+
50
+ Returns immediately; the result arrives later as an
51
+ ``E42_CHECKSUM_READY`` event on the entity FIFO for this transaction.
52
+ ``from_temp_handle`` picks the receive-side temp handle vs the send-side
53
+ source-opened-afresh (see :class:`~cfdp.pure.outputs.RequestChecksum`).
54
+ """
55
+ worker = threading.Thread(
56
+ target=self._run,
57
+ args=(tid, transaction, checksum_type, from_temp_handle),
58
+ daemon=True,
59
+ )
60
+ with self._lock:
61
+ self._workers.add(worker)
62
+ worker.start()
63
+
64
+ def _run(self, tid, transaction, checksum_type, from_temp_handle):
65
+ try:
66
+ try:
67
+ value = self._compute_value(
68
+ tid, transaction, checksum_type, from_temp_handle
69
+ )
70
+ except Exception:
71
+ # A dropped event strands the parked machine forever (a Sender1
72
+ # has no inactivity timer). Guarantee exactly one terminating
73
+ # event so it reaches a terminal state (issue #24).
74
+ logger.exception(
75
+ "checksum computation failed for transaction %s; "
76
+ "injecting a cancellation to terminate the parked machine",
77
+ tid,
78
+ )
79
+ self._entity.trigger_event(
80
+ Event(transaction, EventType.E33_RECEIVED_CANCEL_REQUEST)
81
+ )
82
+ else:
83
+ self._entity.trigger_event(
84
+ Event(transaction, EventType.E42_CHECKSUM_READY, checksum=value)
85
+ )
86
+ finally:
87
+ with self._lock:
88
+ self._workers.discard(threading.current_thread())
89
+
90
+ def _compute_value(self, tid, transaction, checksum_type, from_temp_handle):
91
+ """Read the checksum, mirroring ``EntityQueryPort.checksum`` (design D10).
92
+
93
+ Receive side (``from_temp_handle``): read back the shell-held temp
94
+ handle. Send side: open the source **afresh** so the worker never shares
95
+ the file-data pump's source handle (concurrent seek/close on another
96
+ thread). Isolated so a test can wrap it to simulate a slow computation.
97
+ """
98
+ if from_temp_handle:
99
+ handle = self._files.get(tid)
100
+ if handle is None:
101
+ # A receive-side RequestChecksum is only emitted for a file
102
+ # transfer whose temp handle the shell opened, so a missing
103
+ # handle is an abnormal state (reaped/dropped). Returning 0 here
104
+ # would be miscompared against the real EOF checksum and fault a
105
+ # correctly-delivered file; raise instead so the worker's
106
+ # exactly-one-terminating-event fallback injects E33 (issue #24).
107
+ raise ValueError(f"no temp handle for transaction {tid} to checksum")
108
+ return handle.calculate_checksum(checksum_type)
109
+ source = transaction.source_filename
110
+ if not source:
111
+ return 0
112
+ fh = self._entity.filestore.open(source)
113
+ try:
114
+ return fh.calculate_checksum(checksum_type)
115
+ finally:
116
+ fh.close()
117
+
118
+ def active_worker_count(self):
119
+ with self._lock:
120
+ return len(self._workers)
121
+
122
+ def stop(self):
123
+ """Join every outstanding worker so none leaks past entity shutdown."""
124
+ with self._lock:
125
+ workers = list(self._workers)
126
+ for worker in workers:
127
+ if worker is not threading.current_thread():
128
+ worker.join(timeout=5)
129
+ if worker.is_alive():
130
+ logger.warning(
131
+ "checksum worker %s did not finish within 5s of stop(); "
132
+ "it keeps running as a daemon",
133
+ worker.name,
134
+ )
135
+ with self._lock:
136
+ self._workers.clear()
@@ -0,0 +1,185 @@
1
+ """The dispatcher: runs a pure machine's outputs against the shell (design D8).
2
+
3
+ ``update_state(event)`` returns one ordered ``list[Output]`` mixing effects and
4
+ internal events. The :class:`Dispatcher` walks that list in order (design D8):
5
+
6
+ * an **effect** is executed immediately, in place, via the
7
+ :class:`~cfdp.shell.effect_executor.EffectExecutor`;
8
+ * an **internal event** (or a delayed :class:`~cfdp.pure.outputs.ScheduleEvent`)
9
+ is **re-enqueued** on the shared FIFO queue — via the entity's
10
+ ``trigger_event`` so the file-data pump's ``inter_pdu_delay`` pacing (and any
11
+ test hook on ``trigger_event``) still applies.
12
+
13
+ It never drains to quiescence: it walks one event's outputs, enqueues the
14
+ follow-ups, and returns to the queue. That is what keeps the file-data pump
15
+ interruptible by a mid-transfer cancel/suspend (design D8).
16
+
17
+ **Per-effect exception boundary (design D4/D7, required).** Each effect runs
18
+ inside a ``try``; a raised exception is caught, mapped to the corresponding
19
+ fault event, and injected into the *owning* machine only. It never escapes to
20
+ kill the event thread or disturb another transaction. An effect whose failure
21
+ has no mapping abandons that one transaction (an ``E2_ABANDON_TRANSACTION``)
22
+ and is logged.
23
+ """
24
+
25
+ from .. import logger
26
+ from ..constants import ConditionCode, FaultHandlerAction, MachineState
27
+ from ..event import Event, EventType
28
+ from ..pure.outputs import (
29
+ CloseFile,
30
+ Finalize,
31
+ InternalEvent,
32
+ OpenSource,
33
+ OpenTemp,
34
+ ScheduleEvent,
35
+ SendPdu,
36
+ WriteSegment,
37
+ )
38
+
39
+ # Filestore/file effects: a failure here is a FILESTORE_REJECTION fault (design
40
+ # D4, issue #28). The action is resolved from the machine's configured
41
+ # ``FILESTORE_REJECTION`` handler (default CANCEL) rather than hardcoding cancel,
42
+ # so a configured IGNORE/SUSPEND/ABANDON is honored.
43
+ _FILESTORE_FAULT_EFFECTS = (
44
+ OpenSource,
45
+ OpenTemp,
46
+ WriteSegment,
47
+ Finalize,
48
+ CloseFile,
49
+ )
50
+
51
+ # How each FaultHandlerAction maps to an injected event, mirroring the machines'
52
+ # own ``_run_fault_handler`` (issue #28). IGNORE injects nothing (continue).
53
+ _FAULT_ACTION_EVENT = {
54
+ FaultHandlerAction.CANCEL: EventType.E33_RECEIVED_CANCEL_REQUEST,
55
+ FaultHandlerAction.SUSPEND: EventType.E31_RECEIVED_SUSPEND_REQUEST,
56
+ FaultHandlerAction.ABANDON: EventType.E2_ABANDON_TRANSACTION,
57
+ }
58
+
59
+
60
+ class Dispatcher:
61
+ """Walks a pure machine's output list, executing effects and re-feeding events."""
62
+
63
+ def __init__(self, entity, registry, executor):
64
+ self._entity = entity
65
+ self._registry = registry
66
+ self._executor = executor
67
+
68
+ def dispatch(self, machine, event):
69
+ """Feed ``event`` to ``machine`` and process its returned outputs.
70
+
71
+ Returns nothing; side effects (I/O, re-enqueued events, completion
72
+ marking) happen in place. This is the single entry the entity's event
73
+ thread calls for a pure (new-stack) machine.
74
+ """
75
+ # Bump the ACK/NAK expiration-limit counter BEFORE the handler reads it
76
+ # (issue #29). These counters are protocol state on the machine; the
77
+ # machine's E25/E26 handler reads them via ``_is_ack_limit_reached`` /
78
+ # ``_is_nak_limit_reached``, so the bump must land first — otherwise the
79
+ # limit never trips and retransmission loops forever (F2 seq 7/8).
80
+ #
81
+ # This used to run on the TimerService's timer thread, a lost-update race
82
+ # with this event thread. E25/E26 are produced ONLY by the TimerService,
83
+ # so this single-threaded event-thread site is the one safe place to bump.
84
+ # Class-1 machines lack ``note_*`` methods but also never receive E25/E26,
85
+ # so ``getattr(..., None)`` guards them harmlessly.
86
+ if event.type == EventType.E25_ACK_TIMEOUT:
87
+ note = getattr(machine, "note_ack_timeout", None)
88
+ if note is not None:
89
+ note()
90
+ elif event.type == EventType.E26_NAK_TIMEOUT:
91
+ note = getattr(machine, "note_nak_timeout", None)
92
+ if note is not None:
93
+ note()
94
+
95
+ try:
96
+ outputs = machine.update_state(event)
97
+ except Exception:
98
+ # A machine that raises is a bug, not a protocol fault; contain it
99
+ # to this transaction rather than killing the event thread.
100
+ logger.exception(
101
+ "Pure machine raised handling %s for %s",
102
+ event.type,
103
+ machine.transaction.id,
104
+ )
105
+ self._mark_if_complete(machine)
106
+ return
107
+
108
+ for out in outputs:
109
+ if isinstance(out, InternalEvent):
110
+ self._enqueue(machine, out.event)
111
+ elif isinstance(out, ScheduleEvent):
112
+ self._enqueue(machine, out.event)
113
+ else:
114
+ self._run_effect(machine, out)
115
+
116
+ self._mark_if_complete(machine)
117
+
118
+ # -- effect execution with the D4 exception boundary ------------------- #
119
+
120
+ def _run_effect(self, machine, effect):
121
+ try:
122
+ self._executor.execute(machine.transaction.id, machine.transaction, effect)
123
+ except Exception:
124
+ logger.exception(
125
+ "Effect %r failed for %s; faulting that transaction only",
126
+ type(effect).__name__,
127
+ machine.transaction.id,
128
+ )
129
+ self._inject_fault(machine, effect)
130
+
131
+ def _inject_fault(self, machine, effect):
132
+ """Map a failed effect to a fault event injected into ``machine`` (D4).
133
+
134
+ A failed filestore/file effect is a FILESTORE_REJECTION fault: resolve the
135
+ configured handler (default CANCEL) and map it exactly as the machines'
136
+ own ``_run_fault_handler`` does — CANCEL -> E33, SUSPEND -> E31,
137
+ ABANDON -> E2, IGNORE -> inject nothing (continue). This honors a
138
+ configured non-default handler instead of always cancelling (issue #28).
139
+ """
140
+ if isinstance(effect, _FILESTORE_FAULT_EFFECTS):
141
+ action = machine.config.get_fault_handler(ConditionCode.FILESTORE_REJECTION)
142
+ if action == FaultHandlerAction.IGNORE:
143
+ # IGNORE: continue as if the effect succeeded. There is nothing to
144
+ # inject; the transaction proceeds. (The world side-effect did not
145
+ # happen, matching how the machines treat an ignored fault.)
146
+ return
147
+ event_type = _FAULT_ACTION_EVENT.get(action)
148
+ if event_type is None:
149
+ # Unknown action: fail safe by abandoning just this transaction.
150
+ event_type = EventType.E2_ABANDON_TRANSACTION
151
+ self._enqueue(machine, event_type)
152
+ elif isinstance(effect, SendPdu):
153
+ # A transport send failure is NOT a filestore rejection, so it does
154
+ # NOT consult the FILESTORE_REJECTION handler (issue #28). There is no
155
+ # CFDP condition code for "local transport send failed", so we map it
156
+ # to a contained cancel of just this transaction (E33): the machine's
157
+ # E33 handler sets CANCEL_REQUEST_RECEIVED and shuts the transaction
158
+ # down cleanly. Cancel (not abandon) preserves the long-standing
159
+ # behavior the Class-1 shell-seam contract test relies on, and unlike
160
+ # a filestore rejection this action is fixed rather than configurable.
161
+ self._enqueue(machine, EventType.E33_RECEIVED_CANCEL_REQUEST)
162
+ else:
163
+ # No mapping for this effect: abandon just this transaction.
164
+ self._enqueue(machine, EventType.E2_ABANDON_TRANSACTION)
165
+
166
+ # -- re-feed onto the shared FIFO queue (design D8) -------------------- #
167
+
168
+ def _enqueue(self, machine, event_type):
169
+ self._entity.trigger_event(Event(machine.transaction, event_type))
170
+
171
+ # -- completion detection ---------------------------------------------- #
172
+
173
+ def _mark_if_complete(self, machine):
174
+ # Only COMPLETED is terminal (issue #23). TRANSACTION_CANCELLED is NOT:
175
+ # a Class-2 machine entering it has just armed its cancel-handshake ACK
176
+ # timer (sends EOF-cancel/Finished-cancel then ``_restart_ack_timer``).
177
+ # Treating it as terminal would call ``_on_pure_machine_complete`` ->
178
+ # ``_timer_service.cancel_all`` and instantly kill that ACK timer, making
179
+ # the E25 retransmit path dead code. The Class-2 cancel machines reach
180
+ # COMPLETED via ``_shutdown()`` only once the handshake actually closes
181
+ # (ACK-EOF / Finished received, or abandonment after the ACK limit).
182
+ # Class-1 cancel goes straight to COMPLETED (never TRANSACTION_CANCELLED),
183
+ # so prompt teardown is preserved.
184
+ if machine.state == MachineState.COMPLETED:
185
+ self._entity._on_pure_machine_complete(machine.transaction.id)