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
@@ -0,0 +1,893 @@
1
+ """Pure Class-2 (acknowledged) receiver machine (issue #19).
2
+
3
+ A pure function of ``(state, event)`` returning an ordered ``list[Output]``. It
4
+ obeys the machine contract:
5
+
6
+ Receiver2(transaction, config, query_port)
7
+ update_state(event) -> list[Output]
8
+
9
+ No ``kernel`` reference, no threads, no open file descriptors, no wall clock.
10
+
11
+ The Class-2 receiver is the most complex machine. On top of the Class-1 receiver
12
+ it adds:
13
+
14
+ * **NAK-driven recovery** — it computes a NAK list from its own received-segment
15
+ map (:meth:`_update_nak_list`) and sends NAKs (:class:`SendPdu`) while any gap
16
+ remains; a missing metadata is requested via the ``(0, 0)`` segment request;
17
+ * a **NAK timer** (design D6) driving retransmission, with the expiration-limit
18
+ counter held in machine state and bumped on each ``E26_NAK_TIMEOUT`` via
19
+ :meth:`note_nak_timeout` (mirroring the real ``Timer._expired`` counter bump);
20
+ * an **ACK timer** guarding the Finished/ACK-Finished handshake, whose limit
21
+ counter is likewise machine state, bumped via :meth:`note_ack_timeout`;
22
+ * the **inactivity timer**, restarted on every inbound PDU (5.3.7);
23
+ * **checksum verification** of the received temp file, computed asynchronously
24
+ (issue #21): the machine emits ``RequestChecksum`` and finishes in the
25
+ ``E42_CHECKSUM_READY`` continuation so a large-file checksum never blocks the
26
+ dispatcher;
27
+ * the ``ACK(EOF)`` / ``Finished`` / ``ACK(Finished)`` handshake and cancellation
28
+ via a ``TRANSACTION_CANCELLED`` state.
29
+
30
+ Reply PDUs (NAK, ACK, Finished) reuse the sender's first PDU header with the
31
+ direction reversed (CCSDS 5.6.1 l). Rather than mutate the inbound PDU's header
32
+ in place, this machine *copies* the relevant header fields once
33
+ (:meth:`_reuse_senders_first_pdu_header`) and rebuilds each reply PDU from them,
34
+ producing byte-identical output without mutating the inbound PDU (purity).
35
+
36
+ The triggering inbound PDU rides on the single input door as ``event.pdu`` (see
37
+ :class:`cfdp.event.Event`), matching the other receiver machines.
38
+
39
+ Verified by differential trace testing against frozen golden fixtures captured
40
+ from the pre-cutover implementation — see ``tests/test_pure_receiver2.py``. In
41
+ particular the pre-cutover machine closed the received file **twice** on the
42
+ happy path (``close_tempfile`` in SEND_FINISHED then ``shutdown``); this machine
43
+ reproduces both :class:`CloseFile` outputs on purpose (the second close is a
44
+ no-op).
45
+ """
46
+
47
+ from cfdp.constants import (
48
+ ConditionCode,
49
+ DeliveryCode,
50
+ Direction,
51
+ DirectiveCode,
52
+ DirectiveSubTypeCode,
53
+ FaultHandlerAction,
54
+ FileStatus,
55
+ MachineState,
56
+ TransactionStatus,
57
+ TransmissionMode,
58
+ )
59
+ from cfdp.event import Event, EventType
60
+ from cfdp.pdu import AckPdu, FinishedPdu, NakPdu
61
+
62
+ from .. import indications
63
+ from ..config import TransactionConfig
64
+ from ..outputs import (
65
+ CancelTimer,
66
+ CloseFile,
67
+ ExecuteFilestoreRequests,
68
+ Finalize,
69
+ InternalEvent,
70
+ OpenTemp,
71
+ Output,
72
+ PostUserMessage,
73
+ RequestChecksum,
74
+ SendPdu,
75
+ StartTimer,
76
+ TimerKind,
77
+ WriteSegment,
78
+ )
79
+ from ..query_port import QueryPort
80
+ from ..transaction import Transaction
81
+
82
+
83
+ class Receiver2:
84
+ """Pure implementation of the Class 2 (acknowledged transfer) receiver."""
85
+
86
+ def __init__(
87
+ self,
88
+ transaction: Transaction,
89
+ config: TransactionConfig,
90
+ query_port: QueryPort,
91
+ ) -> None:
92
+ self.transaction = transaction
93
+ self.config = config
94
+ self.query_port = query_port
95
+
96
+ self.transmission_mode = TransmissionMode.ACKNOWLEDGED
97
+ self.state = MachineState.WAIT_FOR_EOF
98
+ self.file_size = None
99
+
100
+ # Request payload received off the inbound Metadata PDU (design D3). The
101
+ # old machine stashes these on the mutable Transaction; here they are
102
+ # machine state.
103
+ self.filestore_requests = None
104
+ self.messages_to_user = None
105
+ self.source_filename = transaction.source_filename
106
+ self.destination_filename = transaction.destination_filename
107
+ self.checksum_type = transaction.checksum_type
108
+
109
+ # Captured first-PDU header fields (design: reuse sender's header,
110
+ # direction reversed). None until the first inbound PDU is seen.
111
+ self._first_header = None
112
+
113
+ # Timer bookkeeping (design D6): the ACK timer's suspend flag and both
114
+ # expiration-limit counters are protocol state on the machine.
115
+ self._ack_timer_suspended = False
116
+ self._ack_expiration_counter = 0
117
+ self._nak_expiration_counter = 0
118
+
119
+ self._initialize()
120
+
121
+ # -- state reset (mirrors Machine.initialize) -------------------------- #
122
+
123
+ def _initialize(self):
124
+ # as per CCSDS 720.2-G-3, Page 5-13, g)
125
+ self.condition_code = ConditionCode.NO_ERROR
126
+ self.delivery_code = DeliveryCode.DATA_INCOMPLETE
127
+ self.frozen = False
128
+ self.metadata_received = False
129
+ self.pdu_received = False
130
+ self.suspended = False
131
+ self.file_status = FileStatus.UNREPORTED
132
+ self.filestore_responses = None
133
+ self.status_report = None
134
+
135
+ self.received_file_size = 0
136
+ # Whether a temp file handle is open (formerly transaction.file_handle
137
+ # truthiness). shutdown() emits CloseFile only if one was opened.
138
+ self._file_open = False
139
+ self.received_file_segments = {}
140
+ self.nak_list = []
141
+ self._progress = 0
142
+ self.received_file_checksum = None
143
+
144
+ # -- the single input door --------------------------------------------- #
145
+
146
+ def update_state(self, event: Event) -> list[Output]:
147
+ """See state table given in CCSDS 720.2-G-3, Table 5-4.
148
+
149
+ Returns the ordered list of :class:`~cfdp.pure.outputs.Output` produced
150
+ by handling ``event``. The triggering inbound PDU, if any, is read from
151
+ ``event.pdu``.
152
+ """
153
+ self._out: list[Output] = []
154
+ pdu = getattr(event, "pdu", None)
155
+
156
+ # as per CCSDS 720.2-G-3, 5.3.7: any inbound PDU restarts inactivity.
157
+ if pdu is not None:
158
+ self._restart_inactivity_timer()
159
+
160
+ if self.state == MachineState.WAIT_FOR_EOF:
161
+ self._update_wait_for_eof(event, pdu)
162
+ elif self.state == MachineState.GET_MISSING_DATA:
163
+ self._update_get_missing_data(event, pdu)
164
+ elif self.state == MachineState.SEND_FINISHED:
165
+ self._update_send_finished(event, pdu)
166
+ elif self.state == MachineState.TRANSACTION_CANCELLED:
167
+ self._update_transaction_cancelled(event, pdu)
168
+
169
+ out, self._out = self._out, []
170
+ return out
171
+
172
+ # -- WAIT_FOR_EOF state ------------------------------------------------ #
173
+
174
+ def _update_wait_for_eof(self, event, pdu):
175
+ if event.type == EventType.E0_ENTERED_STATE:
176
+ self._initialize()
177
+ self._restart_inactivity_timer()
178
+
179
+ elif event.type == EventType.E2_ABANDON_TRANSACTION:
180
+ self._issue_abandoned_indication()
181
+ self._shutdown()
182
+
183
+ elif event.type == EventType.E3_NOTICE_OF_CANCELLATION:
184
+ self.state = MachineState.TRANSACTION_CANCELLED
185
+ self._emit(InternalEvent(EventType.E0_ENTERED_STATE))
186
+
187
+ elif event.type == EventType.E4_NOTICE_OF_SUSPENSION:
188
+ self._notice_of_suspension()
189
+
190
+ elif event.type == EventType.E5_SUSPEND_TIMERS:
191
+ self._suspend_inactivity_timer()
192
+
193
+ elif event.type == EventType.E6_RESUME_TIMERS:
194
+ self._resume_inactivity_timer()
195
+
196
+ elif event.type == EventType.E10_RECEIVED_METADATA:
197
+ self._reuse_senders_first_pdu_header(pdu)
198
+ if not self.metadata_received:
199
+ self._absorb_metadata(pdu)
200
+
201
+ elif event.type == EventType.E11_RECEIVED_FILEDATA:
202
+ self._issue_filesegment_received_indication(
203
+ pdu.segment_offset, len(pdu.file_data)
204
+ )
205
+ self._reuse_senders_first_pdu_header(pdu)
206
+ if not self._file_open:
207
+ self._open_tempfile()
208
+ self._store_file_data(pdu)
209
+ self._update_received_file_size(pdu)
210
+ # NAK list is recomputed lazily at send points (issue #32), not on
211
+ # every stored segment.
212
+
213
+ elif event.type == EventType.E12_RECEIVED_EOF_NO_ERROR:
214
+ self._reuse_senders_first_pdu_header(pdu)
215
+ # Persist the authoritative EOF file size so later NAK recomputes
216
+ # (GET_MISSING_DATA entry / NAK timeout) use it even when Metadata
217
+ # never arrived (issue #32).
218
+ self.file_size = pdu.file_size
219
+ self._update_nak_list(self.file_size)
220
+ self._send_ack_eof()
221
+
222
+ if self._is_file_size_error(pdu.file_size):
223
+ # Honor the configured fault handler: CANCEL/SUSPEND/ABANDON emit
224
+ # an internal event that sets its own state, so we must NOT fall
225
+ # through and overwrite it (issue #25). IGNORE returns True.
226
+ if not self._fault_file_size():
227
+ return
228
+
229
+ self.received_file_checksum = pdu.file_checksum
230
+
231
+ if self._is_nak_list_empty():
232
+ self.state = MachineState.SEND_FINISHED
233
+ else:
234
+ self.state = MachineState.GET_MISSING_DATA
235
+ self._emit(InternalEvent(EventType.E0_ENTERED_STATE))
236
+
237
+ elif event.type == EventType.E13_RECEIVED_EOF_CANCEL:
238
+ self._reuse_senders_first_pdu_header(pdu)
239
+ self.condition_code = pdu.condition_code
240
+ if pdu.fault_location is not None:
241
+ self.status_report = {"fault_location": pdu.fault_location}
242
+ self._send_ack_eof()
243
+ self._issue_transaction_finished_indication()
244
+ self._shutdown()
245
+
246
+ elif event.type == EventType.E27_INACTIVITY_TIMEOUT:
247
+ self._fault_inactivity()
248
+
249
+ elif event.type == EventType.E31_RECEIVED_SUSPEND_REQUEST:
250
+ self._emit(InternalEvent(EventType.E4_NOTICE_OF_SUSPENSION))
251
+
252
+ elif event.type == EventType.E32_RECEIVED_RESUME_REQUEST:
253
+ self._resume_request()
254
+
255
+ elif event.type == EventType.E33_RECEIVED_CANCEL_REQUEST:
256
+ self.condition_code = ConditionCode.CANCEL_REQUEST_RECEIVED
257
+ self._emit(InternalEvent(EventType.E3_NOTICE_OF_CANCELLATION))
258
+
259
+ elif event.type == EventType.E34_RECEIVED_REPORT_REQUEST:
260
+ self._issue_report_indication()
261
+
262
+ elif event.type == EventType.E40_RECEIVED_FREEZE:
263
+ self._freeze()
264
+
265
+ elif event.type == EventType.E41_RECEIVED_THAW:
266
+ self._thaw()
267
+
268
+ # -- GET_MISSING_DATA state -------------------------------------------- #
269
+
270
+ def _update_get_missing_data(self, event, pdu):
271
+ if event.type == EventType.E0_ENTERED_STATE:
272
+ if not self.suspended and not self.frozen:
273
+ self._update_nak_list(self.file_size)
274
+ self._send_nak()
275
+ self._restart_nak_timer()
276
+
277
+ elif event.type == EventType.E2_ABANDON_TRANSACTION:
278
+ self._issue_abandoned_indication()
279
+ self._shutdown()
280
+
281
+ elif event.type == EventType.E3_NOTICE_OF_CANCELLATION:
282
+ self.state = MachineState.TRANSACTION_CANCELLED
283
+ self._emit(InternalEvent(EventType.E0_ENTERED_STATE))
284
+
285
+ elif event.type == EventType.E4_NOTICE_OF_SUSPENSION:
286
+ self._notice_of_suspension()
287
+
288
+ elif event.type == EventType.E5_SUSPEND_TIMERS:
289
+ self._suspend_inactivity_timer()
290
+
291
+ elif event.type == EventType.E6_RESUME_TIMERS:
292
+ self._restart_inactivity_timer()
293
+
294
+ elif event.type == EventType.E10_RECEIVED_METADATA:
295
+ if not self.metadata_received:
296
+ self._absorb_metadata(pdu)
297
+
298
+ elif event.type == EventType.E11_RECEIVED_FILEDATA:
299
+ if not self._file_open:
300
+ self._open_tempfile()
301
+ self._store_file_data(pdu)
302
+ self._update_received_file_size(pdu)
303
+ # NAK list is recomputed lazily at send points (issue #32).
304
+ self._restart_nak_timer()
305
+
306
+ elif event.type == EventType.E12_RECEIVED_EOF_NO_ERROR:
307
+ self._send_ack_eof()
308
+
309
+ elif event.type == EventType.E13_RECEIVED_EOF_CANCEL:
310
+ self.condition_code = pdu.condition_code
311
+ if pdu.fault_location is not None:
312
+ self.status_report = {"fault_location": pdu.fault_location}
313
+ self._send_ack_eof()
314
+ self._issue_transaction_finished_indication()
315
+ self._shutdown()
316
+
317
+ elif event.type == EventType.E26_NAK_TIMEOUT:
318
+ self._restart_nak_timer()
319
+ self._update_nak_list(self.file_size)
320
+ if self._is_nak_list_empty():
321
+ self.state = MachineState.SEND_FINISHED
322
+ self._emit(InternalEvent(EventType.E0_ENTERED_STATE))
323
+ elif not self.suspended and not self.frozen:
324
+ if self._is_nak_limit_reached():
325
+ if not self._fault_nak_limit():
326
+ return
327
+ self._send_nak()
328
+
329
+ elif event.type == EventType.E27_INACTIVITY_TIMEOUT:
330
+ self._fault_inactivity()
331
+
332
+ elif event.type == EventType.E31_RECEIVED_SUSPEND_REQUEST:
333
+ self._emit(InternalEvent(EventType.E4_NOTICE_OF_SUSPENSION))
334
+
335
+ elif event.type == EventType.E32_RECEIVED_RESUME_REQUEST:
336
+ self._resume_request()
337
+
338
+ elif event.type == EventType.E33_RECEIVED_CANCEL_REQUEST:
339
+ self.condition_code = ConditionCode.CANCEL_REQUEST_RECEIVED
340
+ self._emit(InternalEvent(EventType.E3_NOTICE_OF_CANCELLATION))
341
+
342
+ elif event.type == EventType.E34_RECEIVED_REPORT_REQUEST:
343
+ self._issue_report_indication()
344
+
345
+ elif event.type == EventType.E40_RECEIVED_FREEZE:
346
+ self._freeze()
347
+
348
+ elif event.type == EventType.E41_RECEIVED_THAW:
349
+ self._thaw()
350
+
351
+ # -- SEND_FINISHED state ----------------------------------------------- #
352
+
353
+ def _update_send_finished(self, event, pdu):
354
+ if event.type == EventType.E0_ENTERED_STATE:
355
+ self.delivery_code = DeliveryCode.DATA_COMPLETE
356
+ self._cancel_nak_timer()
357
+
358
+ if self._is_file_transfer():
359
+ # Checksum verification is async (issue #21): compute the temp
360
+ # file's checksum off the event thread and finish in the E42
361
+ # continuation below.
362
+ self._emit(
363
+ RequestChecksum(
364
+ checksum_type=self.checksum_type, from_temp_handle=True
365
+ )
366
+ )
367
+ return
368
+
369
+ if self.filestore_requests:
370
+ self._execute_filestore_requests()
371
+
372
+ self._send_finished()
373
+ self._restart_ack_timer()
374
+
375
+ elif event.type == EventType.E42_CHECKSUM_READY:
376
+ self._finish_after_checksum(event.checksum)
377
+
378
+ elif event.type == EventType.E2_ABANDON_TRANSACTION:
379
+ self._issue_abandoned_indication()
380
+ self._shutdown()
381
+
382
+ elif event.type == EventType.E3_NOTICE_OF_CANCELLATION:
383
+ self.state = MachineState.TRANSACTION_CANCELLED
384
+ self._emit(InternalEvent(EventType.E0_ENTERED_STATE))
385
+
386
+ elif event.type == EventType.E4_NOTICE_OF_SUSPENSION:
387
+ self._notice_of_suspension()
388
+
389
+ elif event.type == EventType.E5_SUSPEND_TIMERS:
390
+ self._suspend_inactivity_timer()
391
+ self._suspend_ack_timer()
392
+
393
+ elif event.type == EventType.E6_RESUME_TIMERS:
394
+ self._restart_inactivity_timer()
395
+ self._resume_ack_timer()
396
+
397
+ elif event.type == EventType.E12_RECEIVED_EOF_NO_ERROR:
398
+ self._send_ack_eof()
399
+
400
+ elif event.type == EventType.E13_RECEIVED_EOF_CANCEL:
401
+ self.condition_code = pdu.condition_code
402
+ if pdu.fault_location is not None:
403
+ self.status_report = {"fault_location": pdu.fault_location}
404
+ self._send_ack_eof()
405
+ self._issue_transaction_finished_indication()
406
+ self._shutdown()
407
+
408
+ elif event.type == EventType.E18_RECEIVED_ACK_FINISHED:
409
+ self._issue_transaction_finished_indication()
410
+ self._shutdown()
411
+
412
+ elif event.type == EventType.E25_ACK_TIMEOUT:
413
+ self._restart_ack_timer()
414
+ if self._is_ack_limit_reached():
415
+ self._fault_ack_limit()
416
+ self._send_finished()
417
+
418
+ elif event.type == EventType.E27_INACTIVITY_TIMEOUT:
419
+ self._fault_inactivity()
420
+
421
+ elif event.type == EventType.E31_RECEIVED_SUSPEND_REQUEST:
422
+ self._emit(InternalEvent(EventType.E4_NOTICE_OF_SUSPENSION))
423
+
424
+ elif event.type == EventType.E32_RECEIVED_RESUME_REQUEST:
425
+ self._resume_request()
426
+
427
+ elif event.type == EventType.E33_RECEIVED_CANCEL_REQUEST:
428
+ self.condition_code = ConditionCode.CANCEL_REQUEST_RECEIVED
429
+ self._emit(InternalEvent(EventType.E3_NOTICE_OF_CANCELLATION))
430
+
431
+ elif event.type == EventType.E34_RECEIVED_REPORT_REQUEST:
432
+ self._issue_report_indication()
433
+
434
+ elif event.type == EventType.E40_RECEIVED_FREEZE:
435
+ self._freeze()
436
+
437
+ elif event.type == EventType.E41_RECEIVED_THAW:
438
+ self._thaw()
439
+
440
+ # -- TRANSACTION_CANCELLED state --------------------------------------- #
441
+
442
+ def _update_transaction_cancelled(self, event, pdu):
443
+ if event.type == EventType.E0_ENTERED_STATE:
444
+ self.suspended = False
445
+ self._send_finished()
446
+ self._restart_ack_timer()
447
+
448
+ elif event.type == EventType.E2_ABANDON_TRANSACTION:
449
+ self._issue_abandoned_indication()
450
+ self._shutdown()
451
+
452
+ elif event.type == EventType.E4_NOTICE_OF_SUSPENSION:
453
+ self._notice_of_suspension()
454
+
455
+ elif event.type == EventType.E5_SUSPEND_TIMERS:
456
+ self._suspend_inactivity_timer()
457
+ self._suspend_ack_timer()
458
+
459
+ elif event.type == EventType.E6_RESUME_TIMERS:
460
+ self._restart_inactivity_timer()
461
+ self._resume_ack_timer()
462
+
463
+ elif event.type == EventType.E13_RECEIVED_EOF_CANCEL:
464
+ self.condition_code = pdu.condition_code
465
+ if pdu.fault_location is not None:
466
+ self.status_report = {"fault_location": pdu.fault_location}
467
+ self._send_ack_eof()
468
+ self._issue_transaction_finished_indication()
469
+ self._shutdown()
470
+
471
+ elif event.type == EventType.E18_RECEIVED_ACK_FINISHED:
472
+ if self.condition_code != ConditionCode.NO_ERROR:
473
+ self._issue_transaction_finished_indication()
474
+ self._shutdown()
475
+
476
+ elif event.type == EventType.E25_ACK_TIMEOUT:
477
+ self._restart_ack_timer()
478
+ if self._is_ack_limit_reached():
479
+ self._emit(InternalEvent(EventType.E2_ABANDON_TRANSACTION))
480
+ else:
481
+ self.condition_code = ConditionCode.CANCEL_REQUEST_RECEIVED
482
+ self._send_finished()
483
+
484
+ elif event.type == EventType.E27_INACTIVITY_TIMEOUT:
485
+ self._issue_abandoned_indication()
486
+ self._shutdown()
487
+
488
+ elif event.type == EventType.E31_RECEIVED_SUSPEND_REQUEST:
489
+ self._emit(InternalEvent(EventType.E4_NOTICE_OF_SUSPENSION))
490
+
491
+ elif event.type == EventType.E32_RECEIVED_RESUME_REQUEST:
492
+ self._resume_request()
493
+
494
+ elif event.type == EventType.E34_RECEIVED_REPORT_REQUEST:
495
+ self._issue_report_indication()
496
+
497
+ elif event.type == EventType.E40_RECEIVED_FREEZE:
498
+ self._freeze()
499
+
500
+ elif event.type == EventType.E41_RECEIVED_THAW:
501
+ self._thaw()
502
+
503
+ # -- shared suspend/resume/freeze/thaw helpers ------------------------- #
504
+
505
+ def _notice_of_suspension(self):
506
+ if not self.suspended:
507
+ self._issue_suspended_indication()
508
+ self.suspended = True
509
+ if not self.frozen:
510
+ self._emit(InternalEvent(EventType.E5_SUSPEND_TIMERS))
511
+
512
+ def _resume_request(self):
513
+ if self.suspended:
514
+ self._issue_resumed_indication()
515
+ self.suspended = False
516
+ if not self.frozen:
517
+ self._emit(InternalEvent(EventType.E6_RESUME_TIMERS))
518
+
519
+ def _freeze(self):
520
+ if not self.frozen:
521
+ self.frozen = True
522
+ if not self.suspended:
523
+ self._emit(InternalEvent(EventType.E5_SUSPEND_TIMERS))
524
+
525
+ def _thaw(self):
526
+ if self.frozen:
527
+ self.frozen = False
528
+ if not self.suspended:
529
+ self._emit(InternalEvent(EventType.E6_RESUME_TIMERS))
530
+
531
+ # -- output helper ----------------------------------------------------- #
532
+
533
+ def _emit(self, output):
534
+ self._out.append(output)
535
+
536
+ # -- queries / predicates ---------------------------------------------- #
537
+
538
+ def _is_file_transfer(self):
539
+ return bool(self.source_filename and len(self.source_filename) > 0)
540
+
541
+ def _is_file_size_error(self, file_size):
542
+ return self.received_file_size > file_size
543
+
544
+ def _is_file_checksum_failure(self, expected, calculated):
545
+ # ``calculated`` is the temp-file checksum computed asynchronously by the
546
+ # shell (issue #21) and delivered on E42_CHECKSUM_READY; ``expected`` is
547
+ # the EOF-carried value stashed on ``received_file_checksum``.
548
+ return calculated != expected
549
+
550
+ def _finish_after_checksum(self, calculated_checksum):
551
+ """E42 continuation: verify checksum, finalize, and send Finished (#21)."""
552
+ if self._is_file_checksum_failure(
553
+ self.received_file_checksum, calculated_checksum
554
+ ):
555
+ if not self._fault_file_checksum():
556
+ self._close_tempfile()
557
+ return
558
+ self._copy_tempfile_to_destfile()
559
+ self._close_tempfile()
560
+ self.file_status = FileStatus.RETAINED_SUCCESSFULLY
561
+
562
+ if self.filestore_requests:
563
+ self._execute_filestore_requests()
564
+
565
+ self._send_finished()
566
+ self._restart_ack_timer()
567
+
568
+ def _is_nak_list_empty(self):
569
+ return not self.nak_list
570
+
571
+ # -- NAK list computation (pure, mirrors Machine.update_nak_list) ------- #
572
+
573
+ def _update_nak_list(self, file_size=None):
574
+ self.nak_list = []
575
+ pointer = 0
576
+ if not self.metadata_received:
577
+ self.nak_list.append((0, 0))
578
+ for offset, length in sorted(self.received_file_segments.items()):
579
+ if offset > pointer:
580
+ self.nak_list.append((pointer, offset))
581
+ pointer = offset + length
582
+ if file_size is not None:
583
+ if pointer < file_size:
584
+ self.nak_list.append((pointer, file_size))
585
+
586
+ def _get_segment_requests(self):
587
+ return self.nak_list
588
+
589
+ # -- timer effects (design D6) ----------------------------------------- #
590
+
591
+ def _restart_inactivity_timer(self):
592
+ if not self.suspended:
593
+ self._emit(
594
+ StartTimer(
595
+ kind=TimerKind.INACTIVITY,
596
+ interval=self.config.transaction_inactivity_limit,
597
+ )
598
+ )
599
+
600
+ def _resume_inactivity_timer(self):
601
+ if not self.suspended:
602
+ self._emit(
603
+ StartTimer(
604
+ kind=TimerKind.INACTIVITY,
605
+ interval=self.config.transaction_inactivity_limit,
606
+ )
607
+ )
608
+
609
+ def _suspend_inactivity_timer(self):
610
+ self._emit(CancelTimer(kind=TimerKind.INACTIVITY))
611
+
612
+ def _restart_ack_timer(self):
613
+ self._emit(
614
+ StartTimer(kind=TimerKind.ACK, interval=self.config.ack_timer_interval)
615
+ )
616
+
617
+ def _suspend_ack_timer(self):
618
+ self._ack_timer_suspended = True
619
+ self._emit(CancelTimer(kind=TimerKind.ACK))
620
+
621
+ def _resume_ack_timer(self):
622
+ self._ack_timer_suspended = False
623
+ self._emit(
624
+ StartTimer(kind=TimerKind.ACK, interval=self.config.ack_timer_interval)
625
+ )
626
+
627
+ def _restart_nak_timer(self):
628
+ self._emit(
629
+ StartTimer(kind=TimerKind.NAK, interval=self.config.nak_timer_interval)
630
+ )
631
+
632
+ def _cancel_nak_timer(self):
633
+ self._emit(CancelTimer(kind=TimerKind.NAK))
634
+
635
+ def _is_ack_limit_reached(self):
636
+ limit = self.config.ack_timer_expiration_limit
637
+ if limit is None:
638
+ return False
639
+ return self._ack_expiration_counter >= limit
640
+
641
+ def _is_nak_limit_reached(self):
642
+ limit = self.config.nak_timer_expiration_limit
643
+ if limit is None:
644
+ return False
645
+ return self._nak_expiration_counter >= limit
646
+
647
+ # -- expiration counters (bumped by the shell's timer event) ----------- #
648
+
649
+ def note_ack_timeout(self):
650
+ """Mirror the real ``Timer._expired`` ACK counter bump (design D6)."""
651
+ self._ack_expiration_counter += 1
652
+
653
+ def note_nak_timeout(self):
654
+ """Mirror the real ``Timer._expired`` NAK counter bump (design D6)."""
655
+ self._nak_expiration_counter += 1
656
+
657
+ # -- fault handling ---------------------------------------------------- #
658
+
659
+ def _fault_inactivity(self):
660
+ return self._run_fault_handler(
661
+ self.config.get_fault_handler(ConditionCode.INACTIVITY_DETECTED)
662
+ )
663
+
664
+ def _fault_file_size(self):
665
+ return self._run_fault_handler(
666
+ self.config.get_fault_handler(ConditionCode.FILE_SIZE_ERROR)
667
+ )
668
+
669
+ def _fault_file_checksum(self):
670
+ return self._run_fault_handler(
671
+ self.config.get_fault_handler(ConditionCode.FILE_CHECKSUM_FAILURE)
672
+ )
673
+
674
+ def _fault_ack_limit(self):
675
+ return self._run_fault_handler(
676
+ self.config.get_fault_handler(ConditionCode.POSITIVE_ACK_LIMIT_REACHED)
677
+ )
678
+
679
+ def _fault_nak_limit(self):
680
+ return self._run_fault_handler(
681
+ self.config.get_fault_handler(ConditionCode.NAK_LIMIT_REACHED)
682
+ )
683
+
684
+ def _run_fault_handler(self, fault_handler):
685
+ """Return True if execution should continue (IGNORE), False otherwise."""
686
+ if fault_handler == FaultHandlerAction.CANCEL:
687
+ self._emit(InternalEvent(EventType.E33_RECEIVED_CANCEL_REQUEST))
688
+ return False
689
+ elif fault_handler == FaultHandlerAction.SUSPEND:
690
+ self._emit(InternalEvent(EventType.E31_RECEIVED_SUSPEND_REQUEST))
691
+ return False
692
+ elif fault_handler == FaultHandlerAction.IGNORE:
693
+ return True
694
+ elif fault_handler == FaultHandlerAction.ABANDON:
695
+ self._emit(InternalEvent(EventType.E2_ABANDON_TRANSACTION))
696
+ return False
697
+ else:
698
+ raise ValueError
699
+
700
+ # -- file lifecycle effects -------------------------------------------- #
701
+
702
+ def _open_tempfile(self):
703
+ self._emit(OpenTemp())
704
+ self._file_open = True
705
+
706
+ def _store_file_data(self, pdu):
707
+ self._emit(WriteSegment(offset=pdu.segment_offset, data=bytes(pdu.file_data)))
708
+ self.received_file_segments[pdu.segment_offset] = len(pdu.file_data)
709
+
710
+ def _copy_tempfile_to_destfile(self):
711
+ # Carry the machine-state destination filename: on a lost-then-recovered
712
+ # Metadata (F2 seq 1/6) the frozen transaction has no filename, only the
713
+ # machine does (updated from the late Metadata).
714
+ self._emit(Finalize(destination_filename=self.destination_filename))
715
+
716
+ def _close_tempfile(self):
717
+ # Mirror Machine.close_tempfile: it does NOT clear the handle, so a
718
+ # subsequent shutdown() closes it a second time.
719
+ self._emit(CloseFile())
720
+
721
+ def _update_received_file_size(self, pdu):
722
+ new_size = pdu.segment_offset + len(pdu.file_data)
723
+ if self.received_file_size < new_size:
724
+ self.received_file_size = new_size
725
+ self._progress = new_size
726
+
727
+ def _absorb_metadata(self, pdu):
728
+ """Absorb a received Metadata PDU into machine state.
729
+
730
+ Shared by the ``WAIT_FOR_EOF`` and ``GET_MISSING_DATA`` E10 handlers so
731
+ they cannot drift (issue #26). Critically it sets ``source_filename`` /
732
+ ``destination_filename`` / ``checksum_type`` from the PDU — when the
733
+ receiver was created from a filename-less Filedata/EOF (Metadata lost or
734
+ reordered) the frozen transaction carries none of these, so without this
735
+ the destination filename stays ``None`` (finalize goes nowhere) and the
736
+ checksum type stays the default MODULAR (spurious FILE_CHECKSUM_FAILURE).
737
+ The filenames/messages are set before the metadata-received indication so
738
+ it reports the resolved values.
739
+ """
740
+ self.source_filename = pdu.source_filename
741
+ self.destination_filename = pdu.destination_filename
742
+ # ``messages_to_user`` is read by the metadata-received indication below;
743
+ # ``filestore_requests`` is (re)set by _process_metadata_options before
744
+ # any read, so it is not assigned here.
745
+ self.messages_to_user = pdu.messages_to_user
746
+ self.checksum_type = pdu.checksum_type
747
+
748
+ self.metadata_received = True
749
+ self.file_size = pdu.file_size
750
+ self._issue_metadata_received_indication(pdu.file_size)
751
+
752
+ if self._is_file_transfer():
753
+ if not self._file_open:
754
+ self._open_tempfile()
755
+ self._process_metadata_options(pdu)
756
+
757
+ def _process_metadata_options(self, pdu):
758
+ self.filestore_requests = pdu.filestore_requests
759
+ self.messages_to_user = pdu.messages_to_user
760
+ for message in self.messages_to_user or []:
761
+ message.originating_transaction = self.transaction
762
+ self._emit(PostUserMessage(message=message))
763
+
764
+ def _execute_filestore_requests(self):
765
+ # Filestore-request execution is a shell effect (design D3/D10): the
766
+ # machine carries the request TLVs but never touches the filestore. The
767
+ # old machine calls ``execute_filestore_requests`` directly here; this
768
+ # machine emits the equivalent command for the EffectExecutor to run.
769
+ self._emit(ExecuteFilestoreRequests(requests=self.filestore_requests))
770
+
771
+ def _shutdown(self):
772
+ # Mirror Receiver2.shutdown: base close (handle only if opened), then
773
+ # all three timers' shutdown() -> CancelTimer, always.
774
+ if self._file_open:
775
+ self._emit(CloseFile())
776
+ self._file_open = False
777
+ self._emit(CancelTimer(kind=TimerKind.INACTIVITY))
778
+ self._emit(CancelTimer(kind=TimerKind.ACK))
779
+ self._emit(CancelTimer(kind=TimerKind.NAK))
780
+ self.state = MachineState.COMPLETED
781
+
782
+ # -- reply-PDU header reuse (CCSDS 5.6.1 l), pure copy) ---------------- #
783
+
784
+ def _reuse_senders_first_pdu_header(self, pdu):
785
+ if not self.pdu_received:
786
+ self.pdu_received = True
787
+ h = pdu.pdu_header
788
+ if h.direction == Direction.TOWARD_RECEIVER:
789
+ direction = Direction.TOWARD_SENDER
790
+ else:
791
+ direction = Direction.TOWARD_RECEIVER
792
+ self._first_header = {
793
+ "direction": direction,
794
+ "transmission_mode": h.transmission_mode,
795
+ "crc_flag": h.crc_flag,
796
+ "large_file_flag": h.large_file_flag,
797
+ "segmentation_control": h.segmentation_control,
798
+ "length_of_entity_ids": h.length_of_entity_ids,
799
+ "segmentation_metadata_flag": h.segmentation_metadata_flag,
800
+ "length_of_seq_number": h.length_of_seq_number,
801
+ "source_entity_id": h.source_entity_id,
802
+ "transaction_seq_number": h.transaction_seq_number,
803
+ "destination_entity_id": h.destination_entity_id,
804
+ }
805
+
806
+ # -- reply PDUs (pure builders) ---------------------------------------- #
807
+
808
+ def _send_nak(self):
809
+ # end_of_scope must cover every segment request. When the tail of the
810
+ # file is missing (up to the whole file when Metadata + all Filedata are
811
+ # lost) the nak_list carries a (pointer, file_size) request whose end is
812
+ # the EOF-declared file_size, which exceeds received_file_size. Scope to
813
+ # the known file_size so the NAK's scope encloses its own requests (a
814
+ # strict peer validates requests lie within [start_of_scope, end_of_scope]);
815
+ # fall back to received_file_size before EOF sets file_size.
816
+ end_of_scope = (
817
+ self.file_size if self.file_size is not None else self.received_file_size
818
+ )
819
+ pdu = NakPdu(
820
+ start_of_scope=0,
821
+ end_of_scope=end_of_scope,
822
+ segment_requests=self._get_segment_requests(),
823
+ **self._first_header,
824
+ )
825
+ self._emit(SendPdu(pdu=pdu))
826
+
827
+ def _send_ack_eof(self):
828
+ pdu = AckPdu(
829
+ directive_code_ack=DirectiveCode.EOF,
830
+ directive_subtype_code=DirectiveSubTypeCode.ACK_OTHERS,
831
+ condition_code=self.condition_code,
832
+ transaction_status=TransactionStatus.UNDEFINED,
833
+ **self._first_header,
834
+ )
835
+ self._emit(SendPdu(pdu=pdu))
836
+
837
+ def _send_finished(self):
838
+ pdu = FinishedPdu(
839
+ condition_code=self.condition_code,
840
+ delivery_code=self.delivery_code,
841
+ file_status=self.file_status,
842
+ filestore_responses=self.filestore_responses,
843
+ fault_location=None,
844
+ **self._first_header,
845
+ )
846
+ self._emit(SendPdu(pdu=pdu))
847
+
848
+ # -- indications ------------------------------------------------------- #
849
+
850
+ def _issue_metadata_received_indication(self, file_size):
851
+ self._emit(
852
+ indications.metadata_received(
853
+ self.transaction.id,
854
+ self.transaction.source_entity_id,
855
+ file_size,
856
+ self.source_filename,
857
+ self.destination_filename,
858
+ self.messages_to_user,
859
+ )
860
+ )
861
+
862
+ def _issue_filesegment_received_indication(self, offset, length):
863
+ self._emit(
864
+ indications.filesegment_received(self.transaction.id, offset, length)
865
+ )
866
+
867
+ def _issue_transaction_finished_indication(self):
868
+ self._emit(
869
+ indications.transaction_finished(
870
+ self.transaction.id,
871
+ self.condition_code,
872
+ self.file_status,
873
+ self.delivery_code,
874
+ self.filestore_responses,
875
+ self.status_report,
876
+ )
877
+ )
878
+
879
+ def _issue_suspended_indication(self):
880
+ self._emit(indications.suspended(self.transaction.id, self.condition_code))
881
+
882
+ def _issue_resumed_indication(self):
883
+ self._emit(indications.resumed(self.transaction.id, self._progress))
884
+
885
+ def _issue_report_indication(self):
886
+ self._emit(indications.report(self.transaction.id, self.status_report))
887
+
888
+ def _issue_abandoned_indication(self):
889
+ self._emit(
890
+ indications.abandoned(
891
+ self.transaction.id, self.condition_code, self._progress
892
+ )
893
+ )