ccx-native 0.0.0-stage → 0.1.0
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.
- package/LICENSE +21 -0
- package/README.md +241 -2
- package/bin/ccx +56 -0
- package/ccx/__init__.py +163 -0
- package/ccx/__main__.py +28 -0
- package/ccx/claude/__init__.py +0 -0
- package/ccx/claude/analytics.py +466 -0
- package/ccx/claude/attach.py +762 -0
- package/ccx/claude/context.py +465 -0
- package/ccx/claude/delivery.py +806 -0
- package/ccx/claude/external.py +253 -0
- package/ccx/claude/host/__init__.py +0 -0
- package/ccx/claude/host/events.py +297 -0
- package/ccx/claude/host/hooks.py +438 -0
- package/ccx/claude/host/pty.py +250 -0
- package/ccx/claude/host/session.py +5184 -0
- package/ccx/claude/host/subagents.py +169 -0
- package/ccx/claude/host/successor.py +267 -0
- package/ccx/claude/managed_turns.py +240 -0
- package/ccx/claude/oauth.py +176 -0
- package/ccx/claude/settings.py +339 -0
- package/ccx/claude/spawn.py +557 -0
- package/ccx/claude/tasks.py +72 -0
- package/ccx/claude/telemetry.py +282 -0
- package/ccx/claude/transcript.py +2281 -0
- package/ccx/claude/usage.py +837 -0
- package/ccx/claude/usage_control.py +152 -0
- package/ccx/cli/__init__.py +0 -0
- package/ccx/cli/codex.py +1009 -0
- package/ccx/cli/commands/__init__.py +0 -0
- package/ccx/cli/commands/_common.py +407 -0
- package/ccx/cli/commands/control.py +161 -0
- package/ccx/cli/commands/create.py +669 -0
- package/ccx/cli/commands/read.py +1020 -0
- package/ccx/cli/commands/turns.py +1081 -0
- package/ccx/cli/controller.py +757 -0
- package/ccx/cli/main.py +301 -0
- package/ccx/cli/parser.py +1324 -0
- package/ccx/cli/skills.py +215 -0
- package/ccx/cli/statusline.py +160 -0
- package/ccx/client.py +1900 -0
- package/ccx/codex/__init__.py +0 -0
- package/ccx/codex/attach_tty.py +267 -0
- package/ccx/codex/client.py +732 -0
- package/ccx/codex/completion.py +121 -0
- package/ccx/codex/control.py +288 -0
- package/ccx/codex/daemon/__init__.py +0 -0
- package/ccx/codex/daemon/__main__.py +4 -0
- package/ccx/codex/daemon/daemon.py +1556 -0
- package/ccx/codex/daemon/models.py +140 -0
- package/ccx/codex/daemon/native.py +1590 -0
- package/ccx/codex/daemon/release.py +367 -0
- package/ccx/codex/daemon/worker.py +2396 -0
- package/ccx/codex/delivery.py +758 -0
- package/ccx/codex/delivery_recovery.py +232 -0
- package/ccx/codex/evidence.py +827 -0
- package/ccx/codex/history.py +158 -0
- package/ccx/codex/notify_history.py +140 -0
- package/ccx/codex/ops.py +666 -0
- package/ccx/codex/paths.py +70 -0
- package/ccx/codex/protocol.py +805 -0
- package/ccx/codex/provenance.py +105 -0
- package/ccx/codex/read.py +29 -0
- package/ccx/codex/records.py +51 -0
- package/ccx/codex/reports.py +177 -0
- package/ccx/codex/runtime.py +1412 -0
- package/ccx/codex/settle.py +363 -0
- package/ccx/codex/telemetry.py +792 -0
- package/ccx/codex/types.py +659 -0
- package/ccx/codex/worker_environment.py +25 -0
- package/ccx/constants.py +288 -0
- package/ccx/controllers/__init__.py +0 -0
- package/ccx/controllers/controller.py +1449 -0
- package/ccx/controllers/live_vars.py +1706 -0
- package/ccx/controllers/live_vars_runner.py +216 -0
- package/ccx/controllers/notify.py +213 -0
- package/ccx/controllers/orchestrator.py +193 -0
- package/ccx/controllers/policy.py +57 -0
- package/ccx/controllers/store.py +681 -0
- package/ccx/errors.py +634 -0
- package/ccx/notify/__init__.py +0 -0
- package/ccx/notify/__main__.py +5 -0
- package/ccx/notify/client.py +116 -0
- package/ccx/notify/daemon.py +480 -0
- package/ccx/notify/deliver.py +304 -0
- package/ccx/notify/paths.py +51 -0
- package/ccx/notify/registry.py +649 -0
- package/ccx/notify/select.py +750 -0
- package/ccx/notify/settle.py +684 -0
- package/ccx/ops/__init__.py +0 -0
- package/ccx/ops/background.py +115 -0
- package/ccx/ops/cancel.py +289 -0
- package/ccx/ops/codex_import.py +96 -0
- package/ccx/ops/common.py +397 -0
- package/ccx/ops/compact.py +337 -0
- package/ccx/ops/compact_ops.py +550 -0
- package/ccx/ops/context_events.py +48 -0
- package/ccx/ops/conversation.py +1540 -0
- package/ccx/ops/conversation_paging.py +1338 -0
- package/ccx/ops/create.py +56 -0
- package/ccx/ops/creation.py +952 -0
- package/ccx/ops/creation_steps.py +579 -0
- package/ccx/ops/event_feed.py +710 -0
- package/ccx/ops/forking.py +711 -0
- package/ccx/ops/input.py +556 -0
- package/ccx/ops/maintenance.py +295 -0
- package/ccx/ops/report.py +770 -0
- package/ccx/ops/semantics.py +490 -0
- package/ccx/ops/send.py +961 -0
- package/ccx/ops/steer_ops.py +867 -0
- package/ccx/ops/terminate.py +383 -0
- package/ccx/ops/turn_reports.py +306 -0
- package/ccx/ops/wait.py +762 -0
- package/ccx/ops/worker_events.py +636 -0
- package/ccx/rpc/__init__.py +0 -0
- package/ccx/rpc/local_rpc.py +1602 -0
- package/ccx/rpc/rpc.py +559 -0
- package/ccx/rpc/serve.py +374 -0
- package/ccx/rpc/transport.py +409 -0
- package/ccx/rpc/view.py +125 -0
- package/ccx/store/__init__.py +0 -0
- package/ccx/store/discovery.py +170 -0
- package/ccx/store/identity.py +105 -0
- package/ccx/store/journal_io.py +79 -0
- package/ccx/store/liveness.py +194 -0
- package/ccx/store/paths.py +254 -0
- package/ccx/store/record_cache.py +40 -0
- package/ccx/store/records.py +309 -0
- package/ccx/store/root.py +804 -0
- package/ccx/store/runtime_presence.py +137 -0
- package/ccx/store/store.py +2332 -0
- package/ccx/store/worker_state.py +86 -0
- package/ccx/system/__init__.py +0 -0
- package/ccx/system/bounded.py +296 -0
- package/ccx/system/cwd_paths.py +25 -0
- package/ccx/system/engine_binary.py +59 -0
- package/ccx/system/executable.py +50 -0
- package/ccx/system/oslayer.py +667 -0
- package/ccx/system/pipes.py +46 -0
- package/ccx/system/sanitize.py +43 -0
- package/ccx/types.py +705 -0
- package/ccx/version.py +29 -0
- package/ccx/workers.py +2081 -0
- package/ccx.py +9 -0
- package/package.json +40 -4
- package/pyproject.toml +87 -0
- package/skills/ccx/SKILL.md +155 -0
- package/skills/ccx/references/controllers.md +244 -0
- package/skills/ccx/references/troubleshooting.md +94 -0
- package/skills/ccx/references/variables.md +62 -0
package/ccx/rpc/serve.py
ADDED
|
@@ -0,0 +1,374 @@
|
|
|
1
|
+
"""Persistent stdio transport for Protocol V1 (`ccx rpc --serve`).
|
|
2
|
+
|
|
3
|
+
The one-shot transport pays interpreter start-up and the full CCX import on
|
|
4
|
+
every request. Here a warm parent reads newline-delimited request envelopes
|
|
5
|
+
and forks one child per request. The child runs exactly the one-shot
|
|
6
|
+
`respond` path, so each request keeps today's process-per-request semantics:
|
|
7
|
+
its own locks, caches and failure domain. Only the import is shared.
|
|
8
|
+
|
|
9
|
+
The parent never dispatches. It owns the streams, correlates each child's
|
|
10
|
+
single response line with its request, and enforces a hard deadline so every
|
|
11
|
+
request is answered within its budget even if a child wedges.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import json
|
|
17
|
+
import os
|
|
18
|
+
import selectors
|
|
19
|
+
import signal
|
|
20
|
+
import stat
|
|
21
|
+
import sys
|
|
22
|
+
import time
|
|
23
|
+
from dataclasses import dataclass, field
|
|
24
|
+
from typing import Any
|
|
25
|
+
|
|
26
|
+
from ccx.system import oslayer
|
|
27
|
+
from ccx.rpc.local_rpc import (
|
|
28
|
+
CANCEL_OP,
|
|
29
|
+
DEFAULT_TIMEOUT_S,
|
|
30
|
+
MAX_MESSAGE_BYTES,
|
|
31
|
+
PROTOCOL_VERSION,
|
|
32
|
+
SUPPORTED_PROTOCOL_VERSIONS,
|
|
33
|
+
_error,
|
|
34
|
+
_error_from_exception,
|
|
35
|
+
_params,
|
|
36
|
+
_request_id,
|
|
37
|
+
_required_text,
|
|
38
|
+
_serialized,
|
|
39
|
+
_success,
|
|
40
|
+
_timeout_seconds,
|
|
41
|
+
_validate_operation_params,
|
|
42
|
+
parse_request,
|
|
43
|
+
respond,
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
# Requests a single serve process runs at once; beyond it, refused as data.
|
|
47
|
+
MAX_IN_FLIGHT = 256
|
|
48
|
+
# How long past its own budget a child may run before the parent kills it.
|
|
49
|
+
DEADLINE_GRACE_S = 5.0
|
|
50
|
+
# Requests that only observe. A child running one dies with the parent: its
|
|
51
|
+
# answer has nowhere to go, and a held wait could otherwise outlive the server
|
|
52
|
+
# by hours. A mutating request is left to finish, as a one-shot `ccx rpc`
|
|
53
|
+
# would be when its caller dies; it has its own bounded timeout.
|
|
54
|
+
READ_ONLY_OPS = frozenset({
|
|
55
|
+
"system.info", "worker.inspect", "worker.wait", "turn.reconcile",
|
|
56
|
+
"worker.report", "worker.telemetry", "worker.events",
|
|
57
|
+
"worker.compact.status",
|
|
58
|
+
"transcript.read", "conversation.page", "account.usage.read",
|
|
59
|
+
"notification.inspect",
|
|
60
|
+
})
|
|
61
|
+
_REAP_INTERVAL_S = 1.0
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@dataclass
|
|
65
|
+
class _Child:
|
|
66
|
+
pid: int
|
|
67
|
+
fd: int
|
|
68
|
+
request_id: str | None
|
|
69
|
+
op: str | None
|
|
70
|
+
deadline: float
|
|
71
|
+
buf: bytearray = field(default_factory=bytearray)
|
|
72
|
+
# Reaped already: its pid may belong to another process now.
|
|
73
|
+
exited: bool = False
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _envelope_field(raw: bytes, name: str) -> str | None:
|
|
77
|
+
"""Best-effort text field of a request envelope; never raises."""
|
|
78
|
+
try:
|
|
79
|
+
value = json.loads(raw)
|
|
80
|
+
except (ValueError, UnicodeError):
|
|
81
|
+
return None
|
|
82
|
+
field_value = value.get(name) if isinstance(value, dict) else None
|
|
83
|
+
return field_value if isinstance(field_value, str) else None
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _deadline_of(raw: bytes) -> float:
|
|
87
|
+
try:
|
|
88
|
+
value = json.loads(raw)
|
|
89
|
+
budget = _timeout_seconds(value, DEFAULT_TIMEOUT_S) if isinstance(value, dict) else DEFAULT_TIMEOUT_S
|
|
90
|
+
except Exception:
|
|
91
|
+
budget = DEFAULT_TIMEOUT_S
|
|
92
|
+
return time.monotonic() + budget + DEADLINE_GRACE_S
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _max_fd() -> int:
|
|
96
|
+
try:
|
|
97
|
+
return int(os.sysconf("SC_OPEN_MAX"))
|
|
98
|
+
except (OSError, ValueError):
|
|
99
|
+
return 65536
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _run_child(raw: bytes, write_fd: int, parent: int, generation: str) -> None:
|
|
103
|
+
"""Child side: answer one request on ``write_fd``, then exit."""
|
|
104
|
+
try:
|
|
105
|
+
if _envelope_field(raw, "op") in READ_ONLY_OPS:
|
|
106
|
+
oslayer.arm_parent_death(parent)
|
|
107
|
+
# Keep the parent's streams out of reach: stray prints go to stderr and
|
|
108
|
+
# nothing a request spawns can read the request stream.
|
|
109
|
+
devnull = os.open(os.devnull, os.O_RDONLY)
|
|
110
|
+
os.dup2(devnull, 0)
|
|
111
|
+
os.close(devnull)
|
|
112
|
+
os.dup2(2, 1)
|
|
113
|
+
# Nothing else the parent holds (its streams, the selector, sibling
|
|
114
|
+
# pipes) may outlive this request in a process it spawns.
|
|
115
|
+
os.closerange(3, write_fd)
|
|
116
|
+
os.closerange(write_fd + 1, _max_fd())
|
|
117
|
+
payload = respond(lambda: parse_request(raw),
|
|
118
|
+
expected_generation=generation)
|
|
119
|
+
view = memoryview(payload)
|
|
120
|
+
while view:
|
|
121
|
+
view = view[os.write(write_fd, view):]
|
|
122
|
+
finally:
|
|
123
|
+
os._exit(0)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
class _Server:
|
|
127
|
+
def __init__(self, in_fd: int, out_fd: int, generation: str) -> None:
|
|
128
|
+
if not isinstance(generation, str) or not generation:
|
|
129
|
+
raise ValueError("serve requires the original ACTIVE generation")
|
|
130
|
+
self.generation = generation
|
|
131
|
+
self.in_fd = in_fd
|
|
132
|
+
self.out_fd = out_fd
|
|
133
|
+
# epoll refuses a regular file and kqueue never reports its EOF;
|
|
134
|
+
# select() treats it as always readable, which is what it is.
|
|
135
|
+
self.selector = (selectors.SelectSelector()
|
|
136
|
+
if stat.S_ISREG(os.fstat(in_fd).st_mode)
|
|
137
|
+
else selectors.DefaultSelector())
|
|
138
|
+
self.children: dict[int, _Child] = {}
|
|
139
|
+
self.pending = bytearray()
|
|
140
|
+
self.discarding = False
|
|
141
|
+
self.eof = False
|
|
142
|
+
|
|
143
|
+
def emit(self, payload: bytes) -> None:
|
|
144
|
+
view = memoryview(payload)
|
|
145
|
+
while view:
|
|
146
|
+
view = view[os.write(self.out_fd, view):]
|
|
147
|
+
|
|
148
|
+
def refuse(self, request_id: str | None, code: str, message: str, **details: Any) -> None:
|
|
149
|
+
self.emit(_serialized(_error(request_id, code, message, details=details or None)))
|
|
150
|
+
|
|
151
|
+
def start(self, raw: bytes) -> None:
|
|
152
|
+
request_id = _envelope_field(raw, "request_id")
|
|
153
|
+
op = _envelope_field(raw, "op")
|
|
154
|
+
if op == CANCEL_OP:
|
|
155
|
+
# Answered here: the request it names lives in this process.
|
|
156
|
+
self.cancel(raw, request_id)
|
|
157
|
+
return
|
|
158
|
+
if len(self.children) >= MAX_IN_FLIGHT:
|
|
159
|
+
self.refuse(request_id, "runtime_unavailable",
|
|
160
|
+
"serve concurrency limit reached", limit=MAX_IN_FLIGHT)
|
|
161
|
+
return
|
|
162
|
+
if request_id is not None and any(
|
|
163
|
+
child.request_id == request_id for child in self.children.values()
|
|
164
|
+
):
|
|
165
|
+
# Responses are matched by request_id, so two in flight would be
|
|
166
|
+
# indistinguishable to the caller.
|
|
167
|
+
self.refuse(request_id, "conflict",
|
|
168
|
+
"a request with this request_id is already in flight")
|
|
169
|
+
return
|
|
170
|
+
read_fd, write_fd = os.pipe()
|
|
171
|
+
parent = os.getpid()
|
|
172
|
+
pid = os.fork()
|
|
173
|
+
if pid == 0:
|
|
174
|
+
os.close(read_fd)
|
|
175
|
+
_run_child(raw, write_fd, parent, self.generation)
|
|
176
|
+
os.close(write_fd)
|
|
177
|
+
os.set_blocking(read_fd, False)
|
|
178
|
+
child = _Child(pid, read_fd, request_id, op, _deadline_of(raw))
|
|
179
|
+
self.children[read_fd] = child
|
|
180
|
+
self.selector.register(read_fd, selectors.EVENT_READ, child)
|
|
181
|
+
|
|
182
|
+
def cancel(self, raw: bytes, request_id: str | None) -> None:
|
|
183
|
+
"""Answer ``request.cancel``: stop one in-flight observation.
|
|
184
|
+
|
|
185
|
+
Only a request in ``READ_ONLY_OPS`` is stopped; it is answered with
|
|
186
|
+
``cancelled`` before the cancel's own result. A mutation is left to
|
|
187
|
+
finish, since killing it would only turn its answer into a lost reply.
|
|
188
|
+
"""
|
|
189
|
+
try:
|
|
190
|
+
request = parse_request(raw)
|
|
191
|
+
request_id = _request_id(request.get("request_id"))
|
|
192
|
+
if request.get("protocol_version") != PROTOCOL_VERSION:
|
|
193
|
+
self.refuse(
|
|
194
|
+
request_id, "unsupported_protocol_version",
|
|
195
|
+
f"unsupported protocol_version {request.get('protocol_version')!r}",
|
|
196
|
+
supported_protocol_versions=list(SUPPORTED_PROTOCOL_VERSIONS),
|
|
197
|
+
)
|
|
198
|
+
return
|
|
199
|
+
params = _params(request)
|
|
200
|
+
_validate_operation_params(CANCEL_OP, params)
|
|
201
|
+
target = _required_text(params, "cancel_request_id")
|
|
202
|
+
except Exception as error:
|
|
203
|
+
self.emit(_serialized(_error_from_exception(request_id, error)))
|
|
204
|
+
return
|
|
205
|
+
if any(child.request_id == request_id for child in self.children.values()):
|
|
206
|
+
self.refuse(request_id, "conflict",
|
|
207
|
+
"a request with this request_id is already in flight")
|
|
208
|
+
return
|
|
209
|
+
child = next(
|
|
210
|
+
(c for c in self.children.values() if c.request_id == target), None
|
|
211
|
+
)
|
|
212
|
+
if child is not None:
|
|
213
|
+
# An answer already in the pipe wins over the cancellation.
|
|
214
|
+
self.read_child(child)
|
|
215
|
+
if child.fd not in self.children:
|
|
216
|
+
child = None
|
|
217
|
+
if child is None:
|
|
218
|
+
reason = "not_in_flight"
|
|
219
|
+
elif child.op not in READ_ONLY_OPS:
|
|
220
|
+
reason = "not_cancellable"
|
|
221
|
+
else:
|
|
222
|
+
self.drop(child, "cancelled", "request cancelled by request.cancel")
|
|
223
|
+
reason = None
|
|
224
|
+
self.emit(_serialized(_success(request_id, {
|
|
225
|
+
"cancel_request_id": target,
|
|
226
|
+
"cancelled": reason is None,
|
|
227
|
+
"reason": reason,
|
|
228
|
+
})))
|
|
229
|
+
|
|
230
|
+
def drop(self, child: _Child, code: str, message: str) -> None:
|
|
231
|
+
"""Kill an unanswered child and answer its request with ``code``."""
|
|
232
|
+
if not child.exited:
|
|
233
|
+
try:
|
|
234
|
+
os.kill(child.pid, signal.SIGKILL)
|
|
235
|
+
except ProcessLookupError:
|
|
236
|
+
pass
|
|
237
|
+
self.selector.unregister(child.fd)
|
|
238
|
+
os.close(child.fd)
|
|
239
|
+
del self.children[child.fd]
|
|
240
|
+
self.refuse(child.request_id, code, message)
|
|
241
|
+
if not child.exited:
|
|
242
|
+
try:
|
|
243
|
+
os.waitpid(child.pid, 0)
|
|
244
|
+
except ChildProcessError:
|
|
245
|
+
pass
|
|
246
|
+
|
|
247
|
+
def finish(self, child: _Child, payload: bytes | None) -> None:
|
|
248
|
+
self.selector.unregister(child.fd)
|
|
249
|
+
os.close(child.fd)
|
|
250
|
+
del self.children[child.fd]
|
|
251
|
+
if payload is None:
|
|
252
|
+
self.refuse(child.request_id, "internal_error",
|
|
253
|
+
"request process exited without a response")
|
|
254
|
+
else:
|
|
255
|
+
self.emit(payload)
|
|
256
|
+
# The response is complete; reap without waiting on anything the
|
|
257
|
+
# request may have spawned.
|
|
258
|
+
if not child.exited:
|
|
259
|
+
try:
|
|
260
|
+
os.waitpid(child.pid, os.WNOHANG)
|
|
261
|
+
except ChildProcessError:
|
|
262
|
+
pass
|
|
263
|
+
|
|
264
|
+
def read_child(self, child: _Child) -> None:
|
|
265
|
+
try:
|
|
266
|
+
chunk = os.read(child.fd, 65536)
|
|
267
|
+
except BlockingIOError:
|
|
268
|
+
if child.exited:
|
|
269
|
+
# Dead, its output drained, and a process it spawned keeps the
|
|
270
|
+
# pipe open: no line is coming.
|
|
271
|
+
self.finish(child, None)
|
|
272
|
+
return
|
|
273
|
+
if chunk:
|
|
274
|
+
child.buf.extend(chunk)
|
|
275
|
+
# A response is exactly one line; stop at it rather than waiting
|
|
276
|
+
# for EOF, which a process the request spawned could hold open.
|
|
277
|
+
end = child.buf.find(b"\n")
|
|
278
|
+
if end >= 0:
|
|
279
|
+
self.finish(child, bytes(child.buf[: end + 1]))
|
|
280
|
+
elif child.exited:
|
|
281
|
+
self.read_child(child)
|
|
282
|
+
return
|
|
283
|
+
self.finish(child, None)
|
|
284
|
+
|
|
285
|
+
def read_input(self) -> None:
|
|
286
|
+
chunk = os.read(self.in_fd, 65536)
|
|
287
|
+
if not chunk:
|
|
288
|
+
self.eof = True
|
|
289
|
+
self.selector.unregister(self.in_fd)
|
|
290
|
+
if self.pending.strip() and not self.discarding:
|
|
291
|
+
self.start(bytes(self.pending))
|
|
292
|
+
self.pending.clear()
|
|
293
|
+
return
|
|
294
|
+
self.pending.extend(chunk)
|
|
295
|
+
while True:
|
|
296
|
+
end = self.pending.find(b"\n")
|
|
297
|
+
if end < 0:
|
|
298
|
+
if len(self.pending) > MAX_MESSAGE_BYTES and not self.discarding:
|
|
299
|
+
self.discarding = True
|
|
300
|
+
self.refuse(None, "invalid_request",
|
|
301
|
+
"protocol request exceeds the 4 MiB V1 limit",
|
|
302
|
+
limit_bytes=MAX_MESSAGE_BYTES)
|
|
303
|
+
if self.discarding:
|
|
304
|
+
self.pending.clear()
|
|
305
|
+
return
|
|
306
|
+
line = bytes(self.pending[:end])
|
|
307
|
+
del self.pending[: end + 1]
|
|
308
|
+
if self.discarding:
|
|
309
|
+
self.discarding = False
|
|
310
|
+
continue
|
|
311
|
+
if len(line) > MAX_MESSAGE_BYTES:
|
|
312
|
+
self.refuse(None, "invalid_request",
|
|
313
|
+
"protocol request exceeds the 4 MiB V1 limit",
|
|
314
|
+
limit_bytes=MAX_MESSAGE_BYTES)
|
|
315
|
+
continue
|
|
316
|
+
if line.strip():
|
|
317
|
+
self.start(line)
|
|
318
|
+
|
|
319
|
+
def expire(self) -> None:
|
|
320
|
+
now = time.monotonic()
|
|
321
|
+
for child in [c for c in self.children.values() if c.deadline <= now]:
|
|
322
|
+
self.drop(child, "timeout",
|
|
323
|
+
"request exceeded its timeout_ms budget in serve mode")
|
|
324
|
+
|
|
325
|
+
def reap(self) -> None:
|
|
326
|
+
# Children whose response was already forwarded may exit later. A
|
|
327
|
+
# child still tracked is answered from what it wrote: its pipe is
|
|
328
|
+
# drained now, since a process it spawned may hold it open forever.
|
|
329
|
+
while True:
|
|
330
|
+
try:
|
|
331
|
+
pid, _status = os.waitpid(-1, os.WNOHANG)
|
|
332
|
+
except ChildProcessError:
|
|
333
|
+
return
|
|
334
|
+
if pid == 0:
|
|
335
|
+
return
|
|
336
|
+
for child in list(self.children.values()):
|
|
337
|
+
if child.pid == pid:
|
|
338
|
+
child.exited = True
|
|
339
|
+
self.read_child(child)
|
|
340
|
+
|
|
341
|
+
def run(self) -> int:
|
|
342
|
+
self.selector.register(self.in_fd, selectors.EVENT_READ, None)
|
|
343
|
+
while not (self.eof and not self.children):
|
|
344
|
+
soonest = min((c.deadline for c in self.children.values()), default=None)
|
|
345
|
+
# While requests run, wake at least every second to reap a child
|
|
346
|
+
# that died without a response (its pipe may never close).
|
|
347
|
+
wait = None if soonest is None else min(
|
|
348
|
+
_REAP_INTERVAL_S, max(0.0, soonest - time.monotonic()))
|
|
349
|
+
for key, _events in self.selector.select(wait):
|
|
350
|
+
if key.data is None:
|
|
351
|
+
self.read_input()
|
|
352
|
+
elif key.fd in self.children:
|
|
353
|
+
self.read_child(key.data)
|
|
354
|
+
self.expire()
|
|
355
|
+
self.reap()
|
|
356
|
+
return 0
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
def serve(in_fd: int | None = None, out_fd: int | None = None) -> int:
|
|
360
|
+
"""Run the original per-request process server on one pinned ACTIVE generation."""
|
|
361
|
+
from ccx.constants import CCX_ROOT
|
|
362
|
+
from ccx.store.root import GenerationError, open_or_initialize
|
|
363
|
+
try:
|
|
364
|
+
generation = open_or_initialize(CCX_ROOT)[0].generation
|
|
365
|
+
except (GenerationError, OSError) as exc:
|
|
366
|
+
print(f"ccx rpc --serve unavailable: {exc}", file=sys.stderr)
|
|
367
|
+
return 3
|
|
368
|
+
signal.signal(signal.SIGPIPE, signal.SIG_DFL)
|
|
369
|
+
in_fd = sys.stdin.fileno() if in_fd is None else in_fd
|
|
370
|
+
out_fd = sys.stdout.fileno() if out_fd is None else out_fd
|
|
371
|
+
return _Server(in_fd, out_fd, generation).run()
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
__all__ = ["DEADLINE_GRACE_S", "DEFAULT_TIMEOUT_S", "MAX_IN_FLIGHT", "serve"]
|