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.
- cfdp/__init__.py +106 -0
- cfdp/checksum.py +94 -0
- cfdp/constants.py +153 -0
- cfdp/core.py +895 -0
- cfdp/event.py +55 -0
- cfdp/filestore/__init__.py +2 -0
- cfdp/filestore/base.py +99 -0
- cfdp/filestore/native.py +162 -0
- cfdp/meta/__init__.py +3 -0
- cfdp/meta/datafield.py +11 -0
- cfdp/meta/filestore.py +127 -0
- cfdp/meta/message.py +659 -0
- cfdp/pdu/__init__.py +8 -0
- cfdp/pdu/ack.py +90 -0
- cfdp/pdu/eof.py +123 -0
- cfdp/pdu/filedata.py +90 -0
- cfdp/pdu/finished.py +99 -0
- cfdp/pdu/header.py +159 -0
- cfdp/pdu/keep_alive.py +108 -0
- cfdp/pdu/metadata.py +211 -0
- cfdp/pdu/nak.py +220 -0
- cfdp/pdu/prompt.py +75 -0
- cfdp/pure/__init__.py +68 -0
- cfdp/pure/config.py +141 -0
- cfdp/pure/indications.py +130 -0
- cfdp/pure/machines/__init__.py +25 -0
- cfdp/pure/machines/receiver1.py +433 -0
- cfdp/pure/machines/receiver2.py +893 -0
- cfdp/pure/machines/sender1.py +400 -0
- cfdp/pure/machines/sender2.py +774 -0
- cfdp/pure/outputs.py +203 -0
- cfdp/pure/query_port.py +48 -0
- cfdp/pure/transaction.py +43 -0
- cfdp/py.typed +0 -0
- cfdp/remote_entity_config.py +12 -0
- cfdp/shell/__init__.py +44 -0
- cfdp/shell/checksum_service.py +136 -0
- cfdp/shell/dispatcher.py +185 -0
- cfdp/shell/effect_executor.py +285 -0
- cfdp/shell/machine_registry.py +165 -0
- cfdp/shell/query_port.py +70 -0
- cfdp/shell/timer_service.py +252 -0
- cfdp/transaction_handle.py +50 -0
- cfdp/transport/__init__.py +0 -0
- cfdp/transport/base.py +12 -0
- cfdp/transport/spacepacket.py +47 -0
- cfdp/transport/udp.py +90 -0
- cfdp/transport/zmq.py +54 -0
- pycfdp-0.2.2.dist-info/METADATA +76 -0
- pycfdp-0.2.2.dist-info/RECORD +53 -0
- pycfdp-0.2.2.dist-info/WHEEL +5 -0
- pycfdp-0.2.2.dist-info/licenses/LICENSE.txt +19 -0
- 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
|
cfdp/pure/query_port.py
ADDED
|
@@ -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
|
+
...
|
cfdp/pure/transaction.py
ADDED
|
@@ -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()
|
cfdp/shell/dispatcher.py
ADDED
|
@@ -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)
|