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.
- pycforge/__init__.py +8 -0
- pycforge/__main__.py +2 -0
- pycforge/_version.py +1 -0
- pycforge/converter/__init__.py +0 -0
- pycforge/converter/analysis/__init__.py +5 -0
- pycforge/converter/analysis/functions.py +723 -0
- pycforge/converter/analysis/model.py +158 -0
- pycforge/converter/analysis/planning.py +464 -0
- pycforge/converter/analysis/stage.py +1976 -0
- pycforge/converter/analysis/symbols.py +237 -0
- pycforge/converter/analysis/validation.py +2166 -0
- pycforge/converter/c_output/__init__.py +2 -0
- pycforge/converter/c_output/conformance.py +297 -0
- pycforge/converter/c_output/renderer.py +220 -0
- pycforge/converter/conditional_regions/__init__.py +71 -0
- pycforge/converter/conditional_regions/analysis.py +481 -0
- pycforge/converter/conditional_regions/lowering.py +579 -0
- pycforge/converter/conditional_regions/model.py +243 -0
- pycforge/converter/conditional_regions/validation.py +690 -0
- pycforge/converter/containers/__init__.py +19 -0
- pycforge/converter/containers/analysis.py +542 -0
- pycforge/converter/containers/lowering.py +290 -0
- pycforge/converter/containers/model.py +146 -0
- pycforge/converter/contracts/__init__.py +0 -0
- pycforge/converter/contracts/configuration.py +178 -0
- pycforge/converter/contracts/identifiers.py +68 -0
- pycforge/converter/contracts/versions.py +71 -0
- pycforge/converter/core/__init__.py +0 -0
- pycforge/converter/core/artifact_io.py +87 -0
- pycforge/converter/core/cancellation.py +7 -0
- pycforge/converter/core/canonicalization.py +254 -0
- pycforge/converter/core/context.py +13 -0
- pycforge/converter/core/diagnostics.py +95 -0
- pycforge/converter/core/enums.py +23 -0
- pycforge/converter/core/fingerprint.py +22 -0
- pycforge/converter/core/pipeline.py +8 -0
- pycforge/converter/core/progress.py +25 -0
- pycforge/converter/core/request.py +50 -0
- pycforge/converter/core/resource_policy.py +31 -0
- pycforge/converter/core/result.py +23 -0
- pycforge/converter/core/serialization.py +36 -0
- pycforge/converter/core/stage.py +10 -0
- pycforge/converter/core/stage_artifact.py +80 -0
- pycforge/converter/core/stage_outcome.py +15 -0
- pycforge/converter/decision_trace/__init__.py +0 -0
- pycforge/converter/decision_trace/recorder.py +52 -0
- pycforge/converter/facade.py +459 -0
- pycforge/converter/frontend/__init__.py +4 -0
- pycforge/converter/frontend/normalizer.py +59 -0
- pycforge/converter/frontend/parser.py +20 -0
- pycforge/converter/frontend/source_document.py +93 -0
- pycforge/converter/frontend/stages.py +518 -0
- pycforge/converter/frontend/validation.py +76 -0
- pycforge/converter/io/__init__.py +0 -0
- pycforge/converter/io/atomic_writer.py +33 -0
- pycforge/converter/ir/__init__.py +1 -0
- pycforge/converter/ir/c_ir/__init__.py +4 -0
- pycforge/converter/ir/c_ir/builder.py +26 -0
- pycforge/converter/ir/c_ir/model.py +318 -0
- pycforge/converter/ir/c_ir/serialization.py +88 -0
- pycforge/converter/ir/c_ir/validation.py +834 -0
- pycforge/converter/ir/python_ir/__init__.py +17 -0
- pycforge/converter/ir/python_ir/nodes.py +86 -0
- pycforge/converter/keyword_calls/__init__.py +68 -0
- pycforge/converter/keyword_calls/analysis.py +458 -0
- pycforge/converter/keyword_calls/lowering.py +271 -0
- pycforge/converter/keyword_calls/model.py +304 -0
- pycforge/converter/keyword_calls/validation.py +802 -0
- pycforge/converter/keyword_only_calls/__init__.py +68 -0
- pycforge/converter/keyword_only_calls/analysis.py +640 -0
- pycforge/converter/keyword_only_calls/lowering.py +376 -0
- pycforge/converter/keyword_only_calls/model.py +328 -0
- pycforge/converter/keyword_only_calls/validation.py +1357 -0
- pycforge/converter/lowering.py +1000 -0
- pycforge/converter/modules/__init__.py +13 -0
- pycforge/converter/modules/analysis.py +1250 -0
- pycforge/converter/modules/lowering.py +200 -0
- pycforge/converter/modules/model.py +73 -0
- pycforge/converter/modules/stage.py +192 -0
- pycforge/converter/numeric_semantics/__init__.py +23 -0
- pycforge/converter/numeric_semantics/analysis.py +420 -0
- pycforge/converter/numeric_semantics/lowering.py +153 -0
- pycforge/converter/numeric_semantics/model.py +123 -0
- pycforge/converter/records/__init__.py +34 -0
- pycforge/converter/records/analysis.py +1394 -0
- pycforge/converter/records/lowering.py +393 -0
- pycforge/converter/records/model.py +319 -0
- pycforge/converter/rules/__init__.py +3 -0
- pycforge/converter/support_templates/__init__.py +49 -0
- pycforge/converter/support_templates/assembly.py +106 -0
- pycforge/converter/support_templates/factories.py +348 -0
- pycforge/converter/support_templates/model.py +159 -0
- pycforge/converter/support_templates/registry.py +388 -0
- pycforge/converter/telemetry/__init__.py +0 -0
- pycforge/converter/telemetry/sink.py +39 -0
- pycforge/ide/__init__.py +19 -0
- pycforge/ide/__main__.py +5 -0
- pycforge/ide/_worker_event_codec.py +276 -0
- pycforge/ide/_worker_protocol_json.py +197 -0
- pycforge/ide/_worker_protocol_types.py +90 -0
- pycforge/ide/_worker_request_codec.py +200 -0
- pycforge/ide/_worker_request_data.py +260 -0
- pycforge/ide/_worker_result_data.py +343 -0
- pycforge/ide/action_contract.py +469 -0
- pycforge/ide/action_validation.py +229 -0
- pycforge/ide/command_palette.py +223 -0
- pycforge/ide/controller.py +904 -0
- pycforge/ide/controller_conversion.py +546 -0
- pycforge/ide/controller_io.py +205 -0
- pycforge/ide/editor.py +951 -0
- pycforge/ide/editor_commands_qt.py +274 -0
- pycforge/ide/editor_lexical.py +43 -0
- pycforge/ide/editor_sidebars.py +236 -0
- pycforge/ide/editor_syntax.py +243 -0
- pycforge/ide/find_replace.py +535 -0
- pycforge/ide/icons.py +91 -0
- pycforge/ide/io_service.py +196 -0
- pycforge/ide/model.py +164 -0
- pycforge/ide/panels.py +903 -0
- pycforge/ide/positions.py +220 -0
- pycforge/ide/process_worker.py +130 -0
- pycforge/ide/qt.py +230 -0
- pycforge/ide/qt_actions.py +418 -0
- pycforge/ide/qt_close.py +169 -0
- pycforge/ide/qt_command_palette.py +297 -0
- pycforge/ide/qt_contract.py +220 -0
- pycforge/ide/qt_documents.py +590 -0
- pycforge/ide/qt_editor_buffers.py +113 -0
- pycforge/ide/qt_editor_surfaces.py +112 -0
- pycforge/ide/qt_menus.py +324 -0
- pycforge/ide/qt_projection.py +500 -0
- pycforge/ide/qt_shell.py +348 -0
- pycforge/ide/qt_shell_interactions.py +320 -0
- pycforge/ide/qt_state.py +393 -0
- pycforge/ide/qt_workspace_features.py +398 -0
- pycforge/ide/qt_workspace_navigation.py +208 -0
- pycforge/ide/qt_workspace_observers.py +173 -0
- pycforge/ide/qt_workspace_panels.py +545 -0
- pycforge/ide/qt_workspace_widgets.py +228 -0
- pycforge/ide/resources/icons/about.svg +1 -0
- pycforge/ide/resources/icons/add-document.svg +1 -0
- pycforge/ide/resources/icons/brand-mark.svg +1 -0
- pycforge/ide/resources/icons/bundle-search.svg +1 -0
- pycforge/ide/resources/icons/cancel.svg +1 -0
- pycforge/ide/resources/icons/check.svg +1 -0
- pycforge/ide/resources/icons/chevron-right.svg +1 -0
- pycforge/ide/resources/icons/close.svg +1 -0
- pycforge/ide/resources/icons/collapse-all.svg +1 -0
- pycforge/ide/resources/icons/command-palette.svg +1 -0
- pycforge/ide/resources/icons/convert.svg +1 -0
- pycforge/ide/resources/icons/copy.svg +1 -0
- pycforge/ide/resources/icons/cut.svg +1 -0
- pycforge/ide/resources/icons/decision-trace.svg +1 -0
- pycforge/ide/resources/icons/details.svg +1 -0
- pycforge/ide/resources/icons/diagnostics.svg +1 -0
- pycforge/ide/resources/icons/duplicate-line.svg +1 -0
- pycforge/ide/resources/icons/exit.svg +1 -0
- pycforge/ide/resources/icons/expand-all.svg +1 -0
- pycforge/ide/resources/icons/export.svg +1 -0
- pycforge/ide/resources/icons/find.svg +1 -0
- pycforge/ide/resources/icons/go-to-line.svg +1 -0
- pycforge/ide/resources/icons/go-to-output.svg +1 -0
- pycforge/ide/resources/icons/go-to-source.svg +1 -0
- pycforge/ide/resources/icons/history.svg +1 -0
- pycforge/ide/resources/icons/indent.svg +1 -0
- pycforge/ide/resources/icons/link-c.svg +1 -0
- pycforge/ide/resources/icons/mappings.svg +1 -0
- pycforge/ide/resources/icons/module.svg +1 -0
- pycforge/ide/resources/icons/move-down.svg +1 -0
- pycforge/ide/resources/icons/move-line-down.svg +1 -0
- pycforge/ide/resources/icons/move-line-up.svg +1 -0
- pycforge/ide/resources/icons/move-up.svg +1 -0
- pycforge/ide/resources/icons/next.svg +1 -0
- pycforge/ide/resources/icons/open.svg +1 -0
- pycforge/ide/resources/icons/outdent.svg +1 -0
- pycforge/ide/resources/icons/outline.svg +1 -0
- pycforge/ide/resources/icons/paste.svg +1 -0
- pycforge/ide/resources/icons/previous.svg +1 -0
- pycforge/ide/resources/icons/primary-module.svg +1 -0
- pycforge/ide/resources/icons/redo.svg +1 -0
- pycforge/ide/resources/icons/remove-document.svg +1 -0
- pycforge/ide/resources/icons/replace.svg +1 -0
- pycforge/ide/resources/icons/save-as.svg +1 -0
- pycforge/ide/resources/icons/save.svg +1 -0
- pycforge/ide/resources/icons/select-all.svg +1 -0
- pycforge/ide/resources/icons/settings.svg +1 -0
- pycforge/ide/resources/icons/show-c.svg +1 -0
- pycforge/ide/resources/icons/split-view.svg +1 -0
- pycforge/ide/resources/icons/summary.svg +1 -0
- pycforge/ide/resources/icons/telemetry.svg +1 -0
- pycforge/ide/resources/icons/toggle-comment.svg +1 -0
- pycforge/ide/resources/icons/toggle-fold.svg +1 -0
- pycforge/ide/resources/icons/undo.svg +1 -0
- pycforge/ide/resources/icons/whitespace.svg +1 -0
- pycforge/ide/revisions.py +400 -0
- pycforge/ide/search_service.py +280 -0
- pycforge/ide/session_history.py +150 -0
- pycforge/ide/source_editing.py +417 -0
- pycforge/ide/source_structure.py +558 -0
- pycforge/ide/source_structure_async.py +191 -0
- pycforge/ide/supervisor.py +503 -0
- pycforge/ide/theme.py +119 -0
- pycforge/ide/theme_stylesheet.py +598 -0
- pycforge/ide/theme_workspace_stylesheet.py +155 -0
- pycforge/ide/visual_tokens.py +142 -0
- pycforge/ide/worker_protocol.py +80 -0
- pycforge/ide/workspace_search.py +462 -0
- pycforge/ide/workspace_session.py +256 -0
- pycforge/laboratory/__init__.py +6 -0
- pycforge/laboratory/audits.py +2244 -0
- pycforge/laboratory/checkpoint_e.py +2685 -0
- pycforge/laboratory/cli.py +119 -0
- pycforge/laboratory/exit_codes.py +11 -0
- pycforge/laboratory/keyword_audit.py +661 -0
- pycforge/laboratory/keyword_only_audit.py +481 -0
- pycforge-0.15.2.dist-info/METADATA +209 -0
- pycforge-0.15.2.dist-info/RECORD +221 -0
- pycforge-0.15.2.dist-info/WHEEL +5 -0
- pycforge-0.15.2.dist-info/entry_points.txt +5 -0
- pycforge-0.15.2.dist-info/licenses/LICENSE +674 -0
- 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
|
+
]
|