pycforge 0.15.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 (221) hide show
  1. pycforge/__init__.py +8 -0
  2. pycforge/__main__.py +2 -0
  3. pycforge/_version.py +1 -0
  4. pycforge/converter/__init__.py +0 -0
  5. pycforge/converter/analysis/__init__.py +5 -0
  6. pycforge/converter/analysis/functions.py +723 -0
  7. pycforge/converter/analysis/model.py +158 -0
  8. pycforge/converter/analysis/planning.py +464 -0
  9. pycforge/converter/analysis/stage.py +1976 -0
  10. pycforge/converter/analysis/symbols.py +237 -0
  11. pycforge/converter/analysis/validation.py +2166 -0
  12. pycforge/converter/c_output/__init__.py +2 -0
  13. pycforge/converter/c_output/conformance.py +297 -0
  14. pycforge/converter/c_output/renderer.py +220 -0
  15. pycforge/converter/conditional_regions/__init__.py +71 -0
  16. pycforge/converter/conditional_regions/analysis.py +481 -0
  17. pycforge/converter/conditional_regions/lowering.py +579 -0
  18. pycforge/converter/conditional_regions/model.py +243 -0
  19. pycforge/converter/conditional_regions/validation.py +690 -0
  20. pycforge/converter/containers/__init__.py +19 -0
  21. pycforge/converter/containers/analysis.py +542 -0
  22. pycforge/converter/containers/lowering.py +290 -0
  23. pycforge/converter/containers/model.py +146 -0
  24. pycforge/converter/contracts/__init__.py +0 -0
  25. pycforge/converter/contracts/configuration.py +178 -0
  26. pycforge/converter/contracts/identifiers.py +68 -0
  27. pycforge/converter/contracts/versions.py +71 -0
  28. pycforge/converter/core/__init__.py +0 -0
  29. pycforge/converter/core/artifact_io.py +87 -0
  30. pycforge/converter/core/cancellation.py +7 -0
  31. pycforge/converter/core/canonicalization.py +254 -0
  32. pycforge/converter/core/context.py +13 -0
  33. pycforge/converter/core/diagnostics.py +95 -0
  34. pycforge/converter/core/enums.py +23 -0
  35. pycforge/converter/core/fingerprint.py +22 -0
  36. pycforge/converter/core/pipeline.py +8 -0
  37. pycforge/converter/core/progress.py +25 -0
  38. pycforge/converter/core/request.py +50 -0
  39. pycforge/converter/core/resource_policy.py +31 -0
  40. pycforge/converter/core/result.py +23 -0
  41. pycforge/converter/core/serialization.py +36 -0
  42. pycforge/converter/core/stage.py +10 -0
  43. pycforge/converter/core/stage_artifact.py +80 -0
  44. pycforge/converter/core/stage_outcome.py +15 -0
  45. pycforge/converter/decision_trace/__init__.py +0 -0
  46. pycforge/converter/decision_trace/recorder.py +52 -0
  47. pycforge/converter/facade.py +459 -0
  48. pycforge/converter/frontend/__init__.py +4 -0
  49. pycforge/converter/frontend/normalizer.py +59 -0
  50. pycforge/converter/frontend/parser.py +20 -0
  51. pycforge/converter/frontend/source_document.py +93 -0
  52. pycforge/converter/frontend/stages.py +518 -0
  53. pycforge/converter/frontend/validation.py +76 -0
  54. pycforge/converter/io/__init__.py +0 -0
  55. pycforge/converter/io/atomic_writer.py +33 -0
  56. pycforge/converter/ir/__init__.py +1 -0
  57. pycforge/converter/ir/c_ir/__init__.py +4 -0
  58. pycforge/converter/ir/c_ir/builder.py +26 -0
  59. pycforge/converter/ir/c_ir/model.py +318 -0
  60. pycforge/converter/ir/c_ir/serialization.py +88 -0
  61. pycforge/converter/ir/c_ir/validation.py +834 -0
  62. pycforge/converter/ir/python_ir/__init__.py +17 -0
  63. pycforge/converter/ir/python_ir/nodes.py +86 -0
  64. pycforge/converter/keyword_calls/__init__.py +68 -0
  65. pycforge/converter/keyword_calls/analysis.py +458 -0
  66. pycforge/converter/keyword_calls/lowering.py +271 -0
  67. pycforge/converter/keyword_calls/model.py +304 -0
  68. pycforge/converter/keyword_calls/validation.py +802 -0
  69. pycforge/converter/keyword_only_calls/__init__.py +68 -0
  70. pycforge/converter/keyword_only_calls/analysis.py +640 -0
  71. pycforge/converter/keyword_only_calls/lowering.py +376 -0
  72. pycforge/converter/keyword_only_calls/model.py +328 -0
  73. pycforge/converter/keyword_only_calls/validation.py +1357 -0
  74. pycforge/converter/lowering.py +1000 -0
  75. pycforge/converter/modules/__init__.py +13 -0
  76. pycforge/converter/modules/analysis.py +1250 -0
  77. pycforge/converter/modules/lowering.py +200 -0
  78. pycforge/converter/modules/model.py +73 -0
  79. pycforge/converter/modules/stage.py +192 -0
  80. pycforge/converter/numeric_semantics/__init__.py +23 -0
  81. pycforge/converter/numeric_semantics/analysis.py +420 -0
  82. pycforge/converter/numeric_semantics/lowering.py +153 -0
  83. pycforge/converter/numeric_semantics/model.py +123 -0
  84. pycforge/converter/records/__init__.py +34 -0
  85. pycforge/converter/records/analysis.py +1394 -0
  86. pycforge/converter/records/lowering.py +393 -0
  87. pycforge/converter/records/model.py +319 -0
  88. pycforge/converter/rules/__init__.py +3 -0
  89. pycforge/converter/support_templates/__init__.py +49 -0
  90. pycforge/converter/support_templates/assembly.py +106 -0
  91. pycforge/converter/support_templates/factories.py +348 -0
  92. pycforge/converter/support_templates/model.py +159 -0
  93. pycforge/converter/support_templates/registry.py +388 -0
  94. pycforge/converter/telemetry/__init__.py +0 -0
  95. pycforge/converter/telemetry/sink.py +39 -0
  96. pycforge/ide/__init__.py +19 -0
  97. pycforge/ide/__main__.py +5 -0
  98. pycforge/ide/_worker_event_codec.py +276 -0
  99. pycforge/ide/_worker_protocol_json.py +197 -0
  100. pycforge/ide/_worker_protocol_types.py +90 -0
  101. pycforge/ide/_worker_request_codec.py +200 -0
  102. pycforge/ide/_worker_request_data.py +260 -0
  103. pycforge/ide/_worker_result_data.py +343 -0
  104. pycforge/ide/action_contract.py +469 -0
  105. pycforge/ide/action_validation.py +229 -0
  106. pycforge/ide/command_palette.py +223 -0
  107. pycforge/ide/controller.py +904 -0
  108. pycforge/ide/controller_conversion.py +546 -0
  109. pycforge/ide/controller_io.py +205 -0
  110. pycforge/ide/editor.py +951 -0
  111. pycforge/ide/editor_commands_qt.py +274 -0
  112. pycforge/ide/editor_lexical.py +43 -0
  113. pycforge/ide/editor_sidebars.py +236 -0
  114. pycforge/ide/editor_syntax.py +243 -0
  115. pycforge/ide/find_replace.py +535 -0
  116. pycforge/ide/icons.py +91 -0
  117. pycforge/ide/io_service.py +196 -0
  118. pycforge/ide/model.py +164 -0
  119. pycforge/ide/panels.py +903 -0
  120. pycforge/ide/positions.py +220 -0
  121. pycforge/ide/process_worker.py +130 -0
  122. pycforge/ide/qt.py +230 -0
  123. pycforge/ide/qt_actions.py +418 -0
  124. pycforge/ide/qt_close.py +169 -0
  125. pycforge/ide/qt_command_palette.py +297 -0
  126. pycforge/ide/qt_contract.py +220 -0
  127. pycforge/ide/qt_documents.py +590 -0
  128. pycforge/ide/qt_editor_buffers.py +113 -0
  129. pycforge/ide/qt_editor_surfaces.py +112 -0
  130. pycforge/ide/qt_menus.py +324 -0
  131. pycforge/ide/qt_projection.py +500 -0
  132. pycforge/ide/qt_shell.py +348 -0
  133. pycforge/ide/qt_shell_interactions.py +320 -0
  134. pycforge/ide/qt_state.py +393 -0
  135. pycforge/ide/qt_workspace_features.py +398 -0
  136. pycforge/ide/qt_workspace_navigation.py +208 -0
  137. pycforge/ide/qt_workspace_observers.py +173 -0
  138. pycforge/ide/qt_workspace_panels.py +545 -0
  139. pycforge/ide/qt_workspace_widgets.py +228 -0
  140. pycforge/ide/resources/icons/about.svg +1 -0
  141. pycforge/ide/resources/icons/add-document.svg +1 -0
  142. pycforge/ide/resources/icons/brand-mark.svg +1 -0
  143. pycforge/ide/resources/icons/bundle-search.svg +1 -0
  144. pycforge/ide/resources/icons/cancel.svg +1 -0
  145. pycforge/ide/resources/icons/check.svg +1 -0
  146. pycforge/ide/resources/icons/chevron-right.svg +1 -0
  147. pycforge/ide/resources/icons/close.svg +1 -0
  148. pycforge/ide/resources/icons/collapse-all.svg +1 -0
  149. pycforge/ide/resources/icons/command-palette.svg +1 -0
  150. pycforge/ide/resources/icons/convert.svg +1 -0
  151. pycforge/ide/resources/icons/copy.svg +1 -0
  152. pycforge/ide/resources/icons/cut.svg +1 -0
  153. pycforge/ide/resources/icons/decision-trace.svg +1 -0
  154. pycforge/ide/resources/icons/details.svg +1 -0
  155. pycforge/ide/resources/icons/diagnostics.svg +1 -0
  156. pycforge/ide/resources/icons/duplicate-line.svg +1 -0
  157. pycforge/ide/resources/icons/exit.svg +1 -0
  158. pycforge/ide/resources/icons/expand-all.svg +1 -0
  159. pycforge/ide/resources/icons/export.svg +1 -0
  160. pycforge/ide/resources/icons/find.svg +1 -0
  161. pycforge/ide/resources/icons/go-to-line.svg +1 -0
  162. pycforge/ide/resources/icons/go-to-output.svg +1 -0
  163. pycforge/ide/resources/icons/go-to-source.svg +1 -0
  164. pycforge/ide/resources/icons/history.svg +1 -0
  165. pycforge/ide/resources/icons/indent.svg +1 -0
  166. pycforge/ide/resources/icons/link-c.svg +1 -0
  167. pycforge/ide/resources/icons/mappings.svg +1 -0
  168. pycforge/ide/resources/icons/module.svg +1 -0
  169. pycforge/ide/resources/icons/move-down.svg +1 -0
  170. pycforge/ide/resources/icons/move-line-down.svg +1 -0
  171. pycforge/ide/resources/icons/move-line-up.svg +1 -0
  172. pycforge/ide/resources/icons/move-up.svg +1 -0
  173. pycforge/ide/resources/icons/next.svg +1 -0
  174. pycforge/ide/resources/icons/open.svg +1 -0
  175. pycforge/ide/resources/icons/outdent.svg +1 -0
  176. pycforge/ide/resources/icons/outline.svg +1 -0
  177. pycforge/ide/resources/icons/paste.svg +1 -0
  178. pycforge/ide/resources/icons/previous.svg +1 -0
  179. pycforge/ide/resources/icons/primary-module.svg +1 -0
  180. pycforge/ide/resources/icons/redo.svg +1 -0
  181. pycforge/ide/resources/icons/remove-document.svg +1 -0
  182. pycforge/ide/resources/icons/replace.svg +1 -0
  183. pycforge/ide/resources/icons/save-as.svg +1 -0
  184. pycforge/ide/resources/icons/save.svg +1 -0
  185. pycforge/ide/resources/icons/select-all.svg +1 -0
  186. pycforge/ide/resources/icons/settings.svg +1 -0
  187. pycforge/ide/resources/icons/show-c.svg +1 -0
  188. pycforge/ide/resources/icons/split-view.svg +1 -0
  189. pycforge/ide/resources/icons/summary.svg +1 -0
  190. pycforge/ide/resources/icons/telemetry.svg +1 -0
  191. pycforge/ide/resources/icons/toggle-comment.svg +1 -0
  192. pycforge/ide/resources/icons/toggle-fold.svg +1 -0
  193. pycforge/ide/resources/icons/undo.svg +1 -0
  194. pycforge/ide/resources/icons/whitespace.svg +1 -0
  195. pycforge/ide/revisions.py +400 -0
  196. pycforge/ide/search_service.py +280 -0
  197. pycforge/ide/session_history.py +150 -0
  198. pycforge/ide/source_editing.py +417 -0
  199. pycforge/ide/source_structure.py +558 -0
  200. pycforge/ide/source_structure_async.py +191 -0
  201. pycforge/ide/supervisor.py +503 -0
  202. pycforge/ide/theme.py +119 -0
  203. pycforge/ide/theme_stylesheet.py +598 -0
  204. pycforge/ide/theme_workspace_stylesheet.py +155 -0
  205. pycforge/ide/visual_tokens.py +142 -0
  206. pycforge/ide/worker_protocol.py +80 -0
  207. pycforge/ide/workspace_search.py +462 -0
  208. pycforge/ide/workspace_session.py +256 -0
  209. pycforge/laboratory/__init__.py +6 -0
  210. pycforge/laboratory/audits.py +2244 -0
  211. pycforge/laboratory/checkpoint_e.py +2685 -0
  212. pycforge/laboratory/cli.py +119 -0
  213. pycforge/laboratory/exit_codes.py +11 -0
  214. pycforge/laboratory/keyword_audit.py +661 -0
  215. pycforge/laboratory/keyword_only_audit.py +481 -0
  216. pycforge-0.15.2.dist-info/METADATA +209 -0
  217. pycforge-0.15.2.dist-info/RECORD +221 -0
  218. pycforge-0.15.2.dist-info/WHEEL +5 -0
  219. pycforge-0.15.2.dist-info/entry_points.txt +5 -0
  220. pycforge-0.15.2.dist-info/licenses/LICENSE +674 -0
  221. pycforge-0.15.2.dist-info/top_level.txt +1 -0
@@ -0,0 +1,191 @@
1
+ """Latest-wins worker for :mod:`pycforge.ide.source_structure`."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from threading import Condition, Thread, current_thread
7
+ from typing import Callable, Iterable
8
+
9
+ from .source_structure import (
10
+ DEFAULT_OUTLINE_DEPTH,
11
+ DEFAULT_OUTLINE_NAME_CHARS,
12
+ DEFAULT_OUTLINE_TEXT_CHARS,
13
+ MAX_OUTLINE_DEPTH,
14
+ MAX_OUTLINE_NAME_CHARS,
15
+ MAX_OUTLINE_SYMBOLS,
16
+ MAX_OUTLINE_TEXT_CHARS,
17
+ MAX_STRUCTURE_WORKSPACE_KEY_CHARS,
18
+ SourceStructureDocument,
19
+ SourceStructureResult,
20
+ _bounded_int,
21
+ _captured_documents,
22
+ build_source_structure,
23
+ )
24
+
25
+
26
+ @dataclass(frozen=True, slots=True)
27
+ class _StructureRequest:
28
+ generation: int
29
+ workspace_key: str
30
+ documents: tuple[SourceStructureDocument, ...]
31
+ callback: Callable[[SourceStructureResult], None]
32
+
33
+
34
+ class AsyncSourceStructureService:
35
+ """One-active, one-latest structure observer with stale suppression."""
36
+
37
+ def __init__(
38
+ self,
39
+ *,
40
+ max_symbols: int = MAX_OUTLINE_SYMBOLS,
41
+ max_depth: int = DEFAULT_OUTLINE_DEPTH,
42
+ max_name_chars: int = DEFAULT_OUTLINE_NAME_CHARS,
43
+ max_text_chars: int = DEFAULT_OUTLINE_TEXT_CHARS,
44
+ ) -> None:
45
+ self._max_symbols = _bounded_int(
46
+ max_symbols, name="max_symbols", maximum=MAX_OUTLINE_SYMBOLS
47
+ )
48
+ self._max_depth = _bounded_int(
49
+ max_depth, name="max_depth", maximum=MAX_OUTLINE_DEPTH
50
+ )
51
+ self._max_name_chars = _bounded_int(
52
+ max_name_chars,
53
+ name="max_name_chars",
54
+ maximum=MAX_OUTLINE_NAME_CHARS,
55
+ )
56
+ self._max_text_chars = _bounded_int(
57
+ max_text_chars,
58
+ name="max_text_chars",
59
+ maximum=MAX_OUTLINE_TEXT_CHARS,
60
+ )
61
+ self._condition = Condition()
62
+ self._generation = 0
63
+ self._active_generation: int | None = None
64
+ self._pending: _StructureRequest | None = None
65
+ self._closed = False
66
+ self._worker = Thread(
67
+ target=self._worker_main,
68
+ name="pycforge-source-structure",
69
+ daemon=True,
70
+ )
71
+ self._worker.start()
72
+
73
+ @property
74
+ def worker_is_daemon(self) -> bool:
75
+ return self._worker.daemon
76
+
77
+ @property
78
+ def active_generation(self) -> int | None:
79
+ with self._condition:
80
+ return self._active_generation
81
+
82
+ @property
83
+ def pending_generation(self) -> int | None:
84
+ with self._condition:
85
+ return (
86
+ self._pending.generation
87
+ if self._pending is not None
88
+ else None
89
+ )
90
+
91
+ @property
92
+ def is_closed(self) -> bool:
93
+ with self._condition:
94
+ return self._closed
95
+
96
+ def submit(
97
+ self,
98
+ documents: Iterable[SourceStructureDocument],
99
+ *,
100
+ workspace_key: str,
101
+ callback: Callable[[SourceStructureResult], None],
102
+ ) -> int:
103
+ records = _captured_documents(documents)
104
+ if not isinstance(workspace_key, str):
105
+ raise TypeError("workspace key must be a string")
106
+ if len(workspace_key) > MAX_STRUCTURE_WORKSPACE_KEY_CHARS:
107
+ raise ValueError("workspace key exceeds the observer identity limit")
108
+ if not callable(callback):
109
+ raise TypeError("structure callback must be callable")
110
+ with self._condition:
111
+ if self._closed:
112
+ raise RuntimeError("source structure service is closed")
113
+ self._generation += 1
114
+ generation = self._generation
115
+ self._pending = _StructureRequest(
116
+ generation, workspace_key, records, callback
117
+ )
118
+ self._condition.notify()
119
+ return generation
120
+
121
+ def cancel(self) -> int:
122
+ with self._condition:
123
+ if self._closed:
124
+ return self._generation
125
+ self._generation += 1
126
+ self._pending = None
127
+ self._condition.notify()
128
+ return self._generation
129
+
130
+ def close(self, *, wait_seconds: float = 0.05) -> None:
131
+ with self._condition:
132
+ if self._closed:
133
+ return
134
+ self._closed = True
135
+ self._generation += 1
136
+ self._pending = None
137
+ self._condition.notify_all()
138
+ if wait_seconds > 0 and current_thread() is not self._worker:
139
+ self._worker.join(timeout=wait_seconds)
140
+
141
+ def _is_cancelled(self, generation: int) -> bool:
142
+ with self._condition:
143
+ return self._closed or generation != self._generation
144
+
145
+ def _worker_main(self) -> None:
146
+ while True:
147
+ with self._condition:
148
+ while self._pending is None and not self._closed:
149
+ self._condition.wait()
150
+ if self._closed:
151
+ return
152
+ request = self._pending
153
+ self._pending = None
154
+ assert request is not None
155
+ self._active_generation = request.generation
156
+
157
+ try:
158
+ result = build_source_structure(
159
+ request.documents,
160
+ generation=request.generation,
161
+ workspace_key=request.workspace_key,
162
+ max_symbols=self._max_symbols,
163
+ max_depth=self._max_depth,
164
+ max_name_chars=self._max_name_chars,
165
+ max_text_chars=self._max_text_chars,
166
+ cancelled=lambda: self._is_cancelled(
167
+ request.generation
168
+ ),
169
+ )
170
+ except Exception:
171
+ # Structure is an observer; failure cannot retire future work
172
+ # or publish an unauthenticated partial outline.
173
+ result = None
174
+
175
+ with self._condition:
176
+ if self._active_generation == request.generation:
177
+ self._active_generation = None
178
+ deliver = (
179
+ result is not None
180
+ and not self._closed
181
+ and request.generation == self._generation
182
+ )
183
+ if deliver:
184
+ try:
185
+ request.callback(result)
186
+ except Exception:
187
+ # Observer callbacks are outside structure authority.
188
+ pass
189
+
190
+
191
+ __all__ = ["AsyncSourceStructureService"]
@@ -0,0 +1,503 @@
1
+ from __future__ import annotations
2
+
3
+ from concurrent.futures import CancelledError, Future
4
+ from dataclasses import dataclass
5
+ from multiprocessing import get_context
6
+ from multiprocessing.connection import Connection
7
+ from threading import Condition, RLock, Thread, current_thread
8
+ from time import monotonic
9
+ from typing import Callable
10
+
11
+ from pycforge.converter.core.progress import ConversionProgress
12
+ from pycforge.converter.core.request import ConversionRequest, ObservationOptions
13
+ from pycforge.converter.core.result import ConversionResult
14
+
15
+ from .process_worker import worker_main
16
+ from .worker_protocol import (
17
+ WorkerEvent,
18
+ WorkerRequest,
19
+ encode_request,
20
+ receive_event,
21
+ send_cancel,
22
+ )
23
+
24
+
25
+ COOPERATIVE_CANCEL_GRACE_SECONDS = 0.750
26
+ FORCED_RECLAMATION_SECONDS = 2.000
27
+ KILL_ESCALATION_SECONDS = 1.750
28
+ POLL_SECONDS = 0.010
29
+
30
+
31
+ class ConversionSupervisorError(RuntimeError):
32
+ """Base class for recoverable isolated-worker failures."""
33
+
34
+
35
+ class ConversionCancelled(CancelledError):
36
+ """The request was retired before a complete result could publish."""
37
+
38
+
39
+ class ConversionSuperseded(ConversionCancelled):
40
+ """A newer latest request replaced this request."""
41
+
42
+
43
+ class WorkerFailure(ConversionSupervisorError):
44
+ """The isolated process failed without publishing a valid result."""
45
+
46
+ def __init__(self, classification: str) -> None:
47
+ self.classification = classification
48
+ super().__init__(f"isolated conversion worker failed: {classification}")
49
+
50
+
51
+ ProgressListener = Callable[[int, str, ConversionProgress], None]
52
+ WorkerTarget = Callable[[Connection, Connection, Connection], None]
53
+
54
+
55
+ @dataclass(frozen=True, slots=True)
56
+ class SupervisorSnapshot:
57
+ active_generation: int | None
58
+ pending_generation: int | None
59
+ active_pid: int | None
60
+ started_workers: int
61
+ reaped_workers: int
62
+ forced_terminations: int
63
+ maximum_simultaneous_workers: int
64
+ latest_pending_start_delay_seconds: float | None
65
+ closed: bool
66
+
67
+
68
+ @dataclass(slots=True)
69
+ class _Slot:
70
+ generation: int
71
+ bundle_fingerprint: str
72
+ request: ConversionRequest
73
+ observation: ObservationOptions
74
+ progress: ProgressListener | None
75
+ future: Future[ConversionResult]
76
+ submitted_at: float
77
+
78
+
79
+ @dataclass(slots=True)
80
+ class _Active:
81
+ slot: _Slot
82
+ worker_request: WorkerRequest
83
+ process: object
84
+ event_connection: Connection
85
+ control_connection: Connection
86
+ cancel_requested_at: float | None = None
87
+ cancel_reason: str | None = None
88
+ cancel_frame_sent: bool = False
89
+ terminate_sent: bool = False
90
+ kill_sent: bool = False
91
+ terminal_event: WorkerEvent | None = None
92
+ protocol_failure: str | None = None
93
+
94
+
95
+ class ProcessConversionSupervisor:
96
+ """One-process/one-latest-pending supervisor for conversion requests.
97
+
98
+ The calling thread only mutates bounded scheduler state. Process creation,
99
+ JSON encoding, pipe traffic, validation, escalation, joining, and Future
100
+ completion all belong to the coordinator thread.
101
+ """
102
+
103
+ def __init__(
104
+ self,
105
+ *,
106
+ context_name: str = "spawn",
107
+ worker_target: WorkerTarget = worker_main,
108
+ clock: Callable[[], float] = monotonic,
109
+ ) -> None:
110
+ if context_name != "spawn":
111
+ raise ValueError("PyCForge conversion workers require spawn isolation")
112
+ self._context = get_context(context_name)
113
+ self._worker_target = worker_target
114
+ self._clock = clock
115
+ self._condition = Condition(RLock())
116
+ self._active: _Active | None = None
117
+ self._pending: _Slot | None = None
118
+ self._retired: list[tuple[_Slot, BaseException]] = []
119
+ self._closed = False
120
+ self._started_workers = 0
121
+ self._reaped_workers = 0
122
+ self._forced_terminations = 0
123
+ self._maximum_simultaneous_workers = 0
124
+ self._last_reaped_at: float | None = None
125
+ self._latest_pending_start_delay: float | None = None
126
+ self._thread = Thread(
127
+ target=self._coordinate,
128
+ name="pycforge-process-supervisor",
129
+ daemon=True,
130
+ )
131
+ self._thread.start()
132
+
133
+ def submit(
134
+ self,
135
+ *,
136
+ generation: int,
137
+ bundle_fingerprint: str,
138
+ request: ConversionRequest,
139
+ progress: ProgressListener | None = None,
140
+ observation: ObservationOptions | None = None,
141
+ ) -> Future[ConversionResult]:
142
+ if not isinstance(generation, int) or isinstance(generation, bool):
143
+ raise TypeError("request generation must be an integer")
144
+ if generation < 1:
145
+ raise ValueError("request generation must be positive")
146
+ if not isinstance(bundle_fingerprint, str) or not bundle_fingerprint:
147
+ raise ValueError("bundle fingerprint must be non-empty")
148
+ if not isinstance(request, ConversionRequest):
149
+ raise TypeError("isolated conversion requires ConversionRequest")
150
+ future: Future[ConversionResult] = Future()
151
+ slot = _Slot(
152
+ generation,
153
+ bundle_fingerprint,
154
+ request,
155
+ observation or ObservationOptions("Full", True),
156
+ progress,
157
+ future,
158
+ self._clock(),
159
+ )
160
+ with self._condition:
161
+ if self._closed:
162
+ raise RuntimeError("conversion supervisor is closed")
163
+ replaced = self._pending
164
+ self._pending = slot
165
+ if replaced is not None:
166
+ self._retired.append(
167
+ (replaced, ConversionSuperseded("request superseded"))
168
+ )
169
+ if self._active is not None:
170
+ self._request_cancel_locked(self._active, "superseded")
171
+ self._condition.notify_all()
172
+ return future
173
+
174
+ def cancel(self, generation: int | None = None) -> None:
175
+ """Request cancellation without waiting for pipe or process work."""
176
+
177
+ with self._condition:
178
+ if self._pending is not None and (
179
+ generation is None or self._pending.generation == generation
180
+ ):
181
+ pending = self._pending
182
+ self._pending = None
183
+ self._retired.append(
184
+ (pending, ConversionCancelled("request canceled"))
185
+ )
186
+ if self._active is not None and (
187
+ generation is None or self._active.slot.generation == generation
188
+ ):
189
+ self._request_cancel_locked(self._active, "canceled")
190
+ self._condition.notify_all()
191
+
192
+ def close(self, *, wait: bool = True, timeout: float | None = None) -> None:
193
+ """Accept shutdown immediately; reap the worker on the coordinator."""
194
+
195
+ with self._condition:
196
+ if not self._closed:
197
+ self._closed = True
198
+ if self._pending is not None:
199
+ self._retired.append(
200
+ (
201
+ self._pending,
202
+ ConversionCancelled("supervisor closed"),
203
+ )
204
+ )
205
+ self._pending = None
206
+ if self._active is not None:
207
+ self._request_cancel_locked(self._active, "closed")
208
+ self._condition.notify_all()
209
+ if wait and current_thread() is not self._thread:
210
+ self._thread.join(timeout)
211
+
212
+ def wait_idle(self, timeout: float | None = None) -> bool:
213
+ deadline = None if timeout is None else self._clock() + timeout
214
+ with self._condition:
215
+ while self._active is not None or self._pending is not None:
216
+ remaining = None if deadline is None else deadline - self._clock()
217
+ if remaining is not None and remaining <= 0:
218
+ return False
219
+ self._condition.wait(remaining)
220
+ return True
221
+
222
+ @property
223
+ def snapshot(self) -> SupervisorSnapshot:
224
+ with self._condition:
225
+ active = self._active
226
+ return SupervisorSnapshot(
227
+ active_generation=(
228
+ None if active is None else active.slot.generation
229
+ ),
230
+ pending_generation=(
231
+ None if self._pending is None else self._pending.generation
232
+ ),
233
+ active_pid=(
234
+ None
235
+ if active is None
236
+ else getattr(active.process, "pid", None)
237
+ ),
238
+ started_workers=self._started_workers,
239
+ reaped_workers=self._reaped_workers,
240
+ forced_terminations=self._forced_terminations,
241
+ maximum_simultaneous_workers=self._maximum_simultaneous_workers,
242
+ latest_pending_start_delay_seconds=self._latest_pending_start_delay,
243
+ closed=self._closed,
244
+ )
245
+
246
+ def _request_cancel_locked(self, active: _Active, reason: str) -> None:
247
+ if active.cancel_requested_at is None:
248
+ active.cancel_requested_at = self._clock()
249
+ active.cancel_reason = reason
250
+ elif reason == "closed":
251
+ active.cancel_reason = reason
252
+
253
+ def _coordinate(self) -> None:
254
+ while True:
255
+ self._complete_retired()
256
+ with self._condition:
257
+ if self._active is None and self._pending is not None:
258
+ slot = self._pending
259
+ self._pending = None
260
+ else:
261
+ slot = None
262
+ should_exit = (
263
+ self._closed
264
+ and self._active is None
265
+ and self._pending is None
266
+ and not self._retired
267
+ )
268
+ if should_exit:
269
+ return
270
+ if slot is not None:
271
+ self._start(slot)
272
+ continue
273
+ with self._condition:
274
+ active = self._active
275
+ if active is None:
276
+ self._condition.wait(POLL_SECONDS)
277
+ continue
278
+ self._service(active)
279
+
280
+ def _complete_retired(self) -> None:
281
+ with self._condition:
282
+ retired = tuple(self._retired)
283
+ self._retired.clear()
284
+ for slot, failure in retired:
285
+ if not slot.future.done():
286
+ slot.future.set_exception(failure)
287
+
288
+ def _start(self, slot: _Slot) -> None:
289
+ try:
290
+ request = WorkerRequest.create(
291
+ slot.generation,
292
+ slot.bundle_fingerprint,
293
+ slot.request,
294
+ slot.observation,
295
+ )
296
+ payload = encode_request(request)
297
+ request_receive, request_send = self._context.Pipe(duplex=False)
298
+ event_receive, event_send = self._context.Pipe(duplex=False)
299
+ control_receive, control_send = self._context.Pipe(duplex=False)
300
+ process = self._context.Process(
301
+ target=self._worker_target,
302
+ args=(request_receive, event_send, control_receive),
303
+ name=f"pycforge-conversion-{slot.generation}",
304
+ daemon=True,
305
+ )
306
+ process.start()
307
+ request_receive.close()
308
+ event_send.close()
309
+ control_receive.close()
310
+ request_send.send_bytes(payload)
311
+ request_send.close()
312
+ active = _Active(
313
+ slot,
314
+ request,
315
+ process,
316
+ event_receive,
317
+ control_send,
318
+ )
319
+ except Exception:
320
+ for connection_name in (
321
+ "request_receive",
322
+ "request_send",
323
+ "event_receive",
324
+ "event_send",
325
+ "control_receive",
326
+ "control_send",
327
+ ):
328
+ connection = locals().get(connection_name)
329
+ if connection is not None:
330
+ try:
331
+ connection.close()
332
+ except Exception:
333
+ pass
334
+ if "process" in locals() and process.is_alive():
335
+ process.terminate()
336
+ process.join(0.1)
337
+ if not slot.future.done():
338
+ slot.future.set_exception(WorkerFailure("startup-failure"))
339
+ with self._condition:
340
+ self._condition.notify_all()
341
+ return
342
+
343
+ with self._condition:
344
+ self._active = active
345
+ self._started_workers += 1
346
+ self._maximum_simultaneous_workers = max(
347
+ self._maximum_simultaneous_workers,
348
+ 1,
349
+ )
350
+ if self._last_reaped_at is not None:
351
+ self._latest_pending_start_delay = max(
352
+ 0.0,
353
+ self._clock() - self._last_reaped_at,
354
+ )
355
+ if self._closed:
356
+ self._request_cancel_locked(active, "closed")
357
+ elif self._pending is not None:
358
+ self._request_cancel_locked(active, "superseded")
359
+ self._condition.notify_all()
360
+
361
+ def _service(self, active: _Active) -> None:
362
+ if active is not self._active:
363
+ return
364
+ self._drain_events(active)
365
+ now = self._clock()
366
+ self._service_cancellation(active, now)
367
+ process = active.process
368
+ if not process.is_alive():
369
+ try:
370
+ process.join(0)
371
+ except Exception:
372
+ pass
373
+ self._drain_events(active)
374
+ self._finish_active(active)
375
+ return
376
+ with self._condition:
377
+ self._condition.wait(POLL_SECONDS)
378
+
379
+ def _drain_events(self, active: _Active) -> None:
380
+ for _ in range(64):
381
+ try:
382
+ if not active.event_connection.poll(0):
383
+ return
384
+ event = receive_event(
385
+ active.event_connection,
386
+ expected=active.worker_request,
387
+ )
388
+ except (EOFError, OSError, BrokenPipeError):
389
+ return
390
+ except Exception:
391
+ active.protocol_failure = "malformed-worker-envelope"
392
+ return
393
+ if event.progress is not None:
394
+ listener = active.slot.progress
395
+ if listener is not None and active.cancel_requested_at is None:
396
+ try:
397
+ listener(
398
+ active.slot.generation,
399
+ active.slot.bundle_fingerprint,
400
+ event.progress,
401
+ )
402
+ except Exception:
403
+ pass
404
+ elif event.result is not None or event.failure_classification is not None:
405
+ if active.terminal_event is not None:
406
+ active.protocol_failure = "duplicate-terminal-envelope"
407
+ else:
408
+ active.terminal_event = event
409
+
410
+ def _service_cancellation(self, active: _Active, now: float) -> None:
411
+ requested = active.cancel_requested_at
412
+ if requested is None:
413
+ return
414
+ if not active.cancel_frame_sent:
415
+ try:
416
+ send_cancel(active.control_connection, active.worker_request)
417
+ except (EOFError, OSError, BrokenPipeError):
418
+ pass
419
+ active.cancel_frame_sent = True
420
+ elapsed = now - requested
421
+ immediate = active.cancel_reason == "closed"
422
+ if (
423
+ not active.terminate_sent
424
+ and (immediate or elapsed >= COOPERATIVE_CANCEL_GRACE_SECONDS)
425
+ and active.process.is_alive()
426
+ ):
427
+ try:
428
+ active.process.terminate()
429
+ except Exception:
430
+ pass
431
+ active.terminate_sent = True
432
+ self._forced_terminations += 1
433
+ if (
434
+ not active.kill_sent
435
+ and elapsed >= KILL_ESCALATION_SECONDS
436
+ and active.process.is_alive()
437
+ ):
438
+ try:
439
+ active.process.kill()
440
+ except Exception:
441
+ pass
442
+ active.kill_sent = True
443
+ if elapsed >= FORCED_RECLAMATION_SECONDS and active.process.is_alive():
444
+ try:
445
+ active.process.kill()
446
+ except Exception:
447
+ pass
448
+
449
+ def _finish_active(self, active: _Active) -> None:
450
+ for connection in (active.event_connection, active.control_connection):
451
+ try:
452
+ connection.close()
453
+ except Exception:
454
+ pass
455
+ failure: BaseException | None = None
456
+ result: ConversionResult | None = None
457
+ if active.cancel_requested_at is not None:
458
+ failure_type = (
459
+ ConversionSuperseded
460
+ if active.cancel_reason == "superseded"
461
+ else ConversionCancelled
462
+ )
463
+ failure = failure_type(
464
+ f"request {active.cancel_reason or 'canceled'}"
465
+ )
466
+ elif active.protocol_failure is not None:
467
+ failure = WorkerFailure(active.protocol_failure)
468
+ elif active.terminal_event is None:
469
+ failure = WorkerFailure("abrupt-worker-exit")
470
+ elif active.terminal_event.failure_classification is not None:
471
+ failure = WorkerFailure(
472
+ active.terminal_event.failure_classification
473
+ )
474
+ elif active.terminal_event.result is None:
475
+ failure = WorkerFailure("missing-terminal-result")
476
+ else:
477
+ result = active.terminal_event.result
478
+
479
+ future = active.slot.future
480
+ if not future.done():
481
+ if failure is not None:
482
+ future.set_exception(failure)
483
+ else:
484
+ assert result is not None
485
+ future.set_result(result)
486
+ with self._condition:
487
+ if self._active is active:
488
+ self._active = None
489
+ self._reaped_workers += 1
490
+ self._last_reaped_at = self._clock()
491
+ self._condition.notify_all()
492
+
493
+
494
+ __all__ = [
495
+ "COOPERATIVE_CANCEL_GRACE_SECONDS",
496
+ "FORCED_RECLAMATION_SECONDS",
497
+ "ConversionCancelled",
498
+ "ConversionSuperseded",
499
+ "ConversionSupervisorError",
500
+ "ProcessConversionSupervisor",
501
+ "SupervisorSnapshot",
502
+ "WorkerFailure",
503
+ ]