ccx-native 0.0.1 → 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 -4
- package/bin/ccx +55 -2
- 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 +39 -7
- 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
|
@@ -0,0 +1,805 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import errno
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import select
|
|
7
|
+
import queue
|
|
8
|
+
import random
|
|
9
|
+
import signal
|
|
10
|
+
import socket
|
|
11
|
+
import subprocess
|
|
12
|
+
import sys
|
|
13
|
+
import threading
|
|
14
|
+
import time
|
|
15
|
+
from collections.abc import Callable
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
from ccx.codex.control import _UnixWebSocket, _masked_frame
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class ProtocolError(RuntimeError):
|
|
22
|
+
"""A JSON-RPC or local protocol failure with machine-readable context."""
|
|
23
|
+
|
|
24
|
+
def __init__(
|
|
25
|
+
self,
|
|
26
|
+
message: str = "protocol error",
|
|
27
|
+
*,
|
|
28
|
+
method: str | None = None,
|
|
29
|
+
code: int | None = None,
|
|
30
|
+
data: Any = None,
|
|
31
|
+
request_id: Any = None,
|
|
32
|
+
):
|
|
33
|
+
self.method = method
|
|
34
|
+
self.code = code
|
|
35
|
+
self.data = data
|
|
36
|
+
self.request_id = request_id
|
|
37
|
+
self.message = str(message)
|
|
38
|
+
# A message that already names its method (a local timeout reads
|
|
39
|
+
# "<method> timed out") keeps its own wording; only an unqualified
|
|
40
|
+
# server message gets the "<method> failed:" prefix.
|
|
41
|
+
detail = (
|
|
42
|
+
f"{method} failed: {self.message}"
|
|
43
|
+
if method and not self.message.startswith(f"{method} ")
|
|
44
|
+
else self.message
|
|
45
|
+
)
|
|
46
|
+
super().__init__(detail)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class ProtocolTransportError(ProtocolError):
|
|
50
|
+
"""The local app-server protocol channel became unusable."""
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class ProtocolNotSent(ProtocolTransportError):
|
|
54
|
+
"""This request is proven to have emitted zero bytes to app-server stdin.
|
|
55
|
+
|
|
56
|
+
It can mean a previously failed channel or a local write/lock deadline
|
|
57
|
+
expiring while the shared channel is healthy. The app-server cannot have
|
|
58
|
+
seen this request, but that is not permission to clear a separately
|
|
59
|
+
fsynced CCX submitted-unknown claim or automatically retry native creation.
|
|
60
|
+
"""
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class ServerOverloaded(ProtocolError):
|
|
64
|
+
"""The server explicitly rejected a request as temporarily overloaded."""
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _error_from_response(
|
|
68
|
+
method: str,
|
|
69
|
+
error: Any,
|
|
70
|
+
request_id: Any,
|
|
71
|
+
) -> ProtocolError:
|
|
72
|
+
if isinstance(error, dict):
|
|
73
|
+
code = error.get("code")
|
|
74
|
+
if isinstance(code, bool) or not isinstance(code, int):
|
|
75
|
+
code = None
|
|
76
|
+
message = error.get("message")
|
|
77
|
+
if not isinstance(message, str):
|
|
78
|
+
message = str(message if message is not None else error)
|
|
79
|
+
data = error.get("data")
|
|
80
|
+
else:
|
|
81
|
+
code = None
|
|
82
|
+
message = str(error)
|
|
83
|
+
data = None
|
|
84
|
+
error_type = ServerOverloaded if code == -32001 else ProtocolError
|
|
85
|
+
return error_type(
|
|
86
|
+
message,
|
|
87
|
+
method=method,
|
|
88
|
+
code=code,
|
|
89
|
+
data=data,
|
|
90
|
+
request_id=request_id,
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _result_from_response(
|
|
95
|
+
method: str, response: Any, request_id: Any
|
|
96
|
+
) -> dict[str, Any]:
|
|
97
|
+
if isinstance(response, BaseException):
|
|
98
|
+
raise response
|
|
99
|
+
if "error" in response:
|
|
100
|
+
raise _error_from_response(method, response["error"], request_id)
|
|
101
|
+
result = response.get("result")
|
|
102
|
+
return result if isinstance(result, dict) else {"value": result}
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
class PendingRequest:
|
|
106
|
+
"""One sent request whose answer stays observable after callers stop waiting.
|
|
107
|
+
|
|
108
|
+
The client's reader delivers the response, or the transport failure that
|
|
109
|
+
ends the connection, through the same `put` interface as a result queue,
|
|
110
|
+
so exactly one definitive outcome always arrives.
|
|
111
|
+
"""
|
|
112
|
+
|
|
113
|
+
def __init__(self, method: str):
|
|
114
|
+
self.method = method
|
|
115
|
+
self.request_id: Any = None
|
|
116
|
+
self._lock = threading.Lock()
|
|
117
|
+
self._done = threading.Event()
|
|
118
|
+
self._response: Any = None
|
|
119
|
+
self._callbacks: list[Callable[["PendingRequest"], None]] = []
|
|
120
|
+
|
|
121
|
+
def put(self, response: Any, block: bool = True, timeout: Any = None) -> None:
|
|
122
|
+
with self._lock:
|
|
123
|
+
if self._done.is_set():
|
|
124
|
+
return
|
|
125
|
+
self._response = response
|
|
126
|
+
self._done.set()
|
|
127
|
+
callbacks, self._callbacks = self._callbacks, []
|
|
128
|
+
for callback in callbacks:
|
|
129
|
+
self._run(callback)
|
|
130
|
+
|
|
131
|
+
put_nowait = put
|
|
132
|
+
|
|
133
|
+
def done(self) -> bool:
|
|
134
|
+
return self._done.is_set()
|
|
135
|
+
|
|
136
|
+
def wait(self, timeout: float) -> bool:
|
|
137
|
+
return self._done.wait(max(0.0, timeout))
|
|
138
|
+
|
|
139
|
+
def add_done_callback(self, callback: Callable[["PendingRequest"], None]) -> None:
|
|
140
|
+
"""Run `callback` once answered: now if it already is, else on the reader."""
|
|
141
|
+
with self._lock:
|
|
142
|
+
if not self._done.is_set():
|
|
143
|
+
self._callbacks.append(callback)
|
|
144
|
+
return
|
|
145
|
+
self._run(callback)
|
|
146
|
+
|
|
147
|
+
def result(self) -> dict[str, Any]:
|
|
148
|
+
if not self._done.is_set():
|
|
149
|
+
raise ProtocolError(
|
|
150
|
+
f"{self.method} has not been answered yet", method=self.method
|
|
151
|
+
)
|
|
152
|
+
return _result_from_response(self.method, self._response, self.request_id)
|
|
153
|
+
|
|
154
|
+
def original_provider_rejection(self) -> dict[str, Any] | None:
|
|
155
|
+
"""Exact coded JSON-RPC error *reply* for this original request only.
|
|
156
|
+
|
|
157
|
+
A locally raised ProtocolError may itself have an integer code; a
|
|
158
|
+
transport exception, timeout, or mismatched response ID is never
|
|
159
|
+
positive evidence that the provider refused the native operation.
|
|
160
|
+
Large/unstructured provider errors also remain uncertain rather than
|
|
161
|
+
silently truncating the original rejection on durable replay.
|
|
162
|
+
"""
|
|
163
|
+
import hashlib
|
|
164
|
+
with self._lock:
|
|
165
|
+
if not self._done.is_set():
|
|
166
|
+
return None
|
|
167
|
+
response = self._response
|
|
168
|
+
request_id = self.request_id
|
|
169
|
+
if (not isinstance(response, dict)
|
|
170
|
+
or response.get("jsonrpc") != "2.0"
|
|
171
|
+
or type(response.get("id")) is not type(request_id)
|
|
172
|
+
or response.get("id") != request_id
|
|
173
|
+
or not isinstance(response.get("error"), dict)):
|
|
174
|
+
return None
|
|
175
|
+
error = response["error"]
|
|
176
|
+
code, message = error.get("code"), error.get("message")
|
|
177
|
+
if (type(code) is not int or not isinstance(message, str)
|
|
178
|
+
or len(message.encode("utf-8")) > 8192):
|
|
179
|
+
return None
|
|
180
|
+
try:
|
|
181
|
+
raw = json.dumps(response, sort_keys=True, separators=(",", ":"),
|
|
182
|
+
ensure_ascii=False, allow_nan=False).encode("utf-8")
|
|
183
|
+
except (TypeError, ValueError, UnicodeError):
|
|
184
|
+
return None
|
|
185
|
+
if len(raw) > 64 * 1024:
|
|
186
|
+
return None
|
|
187
|
+
return {
|
|
188
|
+
"code": code, "message": message,
|
|
189
|
+
"requestId": request_id,
|
|
190
|
+
"nativeReplySha256": hashlib.sha256(raw).hexdigest(),
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
def _run(self, callback: Callable[["PendingRequest"], None]) -> None:
|
|
194
|
+
# Callbacks usually run on the client's reader thread, where an
|
|
195
|
+
# escaping exception would stop every later message from being read.
|
|
196
|
+
try:
|
|
197
|
+
callback(self)
|
|
198
|
+
except Exception as error: # noqa: BLE001
|
|
199
|
+
print(
|
|
200
|
+
f"ccx: {self.method} completion callback failed: {error!r}",
|
|
201
|
+
file=sys.stderr, flush=True,
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def error_payload(error: ProtocolError) -> dict[str, Any]:
|
|
206
|
+
"""Project one ``ProtocolError`` into a JSON-compatible RPC member."""
|
|
207
|
+
return {
|
|
208
|
+
"method": error.method,
|
|
209
|
+
"code": error.code,
|
|
210
|
+
"message": error.message,
|
|
211
|
+
"data": error.data,
|
|
212
|
+
"requestId": error.request_id,
|
|
213
|
+
"transport": isinstance(error, ProtocolTransportError),
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def _user_command_env() -> dict[str, str]:
|
|
218
|
+
"""Environment for app-server, whose shell commands are the user's.
|
|
219
|
+
|
|
220
|
+
codexd itself runs with CCX's package on PYTHONPATH (``package_env``);
|
|
221
|
+
restore the caller's original value so it never shadows project packages.
|
|
222
|
+
"""
|
|
223
|
+
from ccx.codex.worker_environment import scrub_shared_codex_identity
|
|
224
|
+
env = scrub_shared_codex_identity(os.environ.copy())
|
|
225
|
+
# The app-server serves many threads. A caller's BASH_ENV belongs only
|
|
226
|
+
# inside that worker's generated, full-UUID thread policy, not globally.
|
|
227
|
+
env.pop("BASH_ENV", None)
|
|
228
|
+
if "CCX_CALLER_PYTHONPATH" in env:
|
|
229
|
+
original = env.pop("CCX_CALLER_PYTHONPATH")
|
|
230
|
+
if original:
|
|
231
|
+
env["PYTHONPATH"] = original
|
|
232
|
+
else:
|
|
233
|
+
env.pop("PYTHONPATH", None)
|
|
234
|
+
return env
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
# One ephemeral watcher per socket-mode app-server, not a daemon/service.
|
|
238
|
+
# Its stdin writer belongs only to codexd. If codexd is SIGKILLed, EOF
|
|
239
|
+
# terminates the watcher + launcher + native child group, even though the
|
|
240
|
+
# WebSocket listener would otherwise keep running without its CCX owner.
|
|
241
|
+
_PARENT_LIFETIME_WATCHER = (
|
|
242
|
+
"import os, signal, sys, time\n"
|
|
243
|
+
"signal.signal(signal.SIGTERM, signal.SIG_IGN)\n"
|
|
244
|
+
"sys.stdin.buffer.read(1)\n"
|
|
245
|
+
"os.killpg(os.getpgrp(), signal.SIGTERM)\n"
|
|
246
|
+
"time.sleep(0.7)\n"
|
|
247
|
+
"os.killpg(os.getpgrp(), signal.SIGKILL)\n"
|
|
248
|
+
)
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
class AppServerClient:
|
|
252
|
+
"""Small stable-API JSONL client for one dedicated Codex app-server."""
|
|
253
|
+
|
|
254
|
+
_MAX_OVERLOAD_RETRIES = 5
|
|
255
|
+
_OVERLOAD_DELAY_S = 0.1
|
|
256
|
+
_OVERLOAD_DELAY_CAP_S = 1.0
|
|
257
|
+
_WRITE_TIMEOUT_S = 2.0 # bounded even for notifications/server responses
|
|
258
|
+
_MAX_JSONL_FRAME_BYTES = 4 * 1024 * 1024
|
|
259
|
+
|
|
260
|
+
def __init__(
|
|
261
|
+
self,
|
|
262
|
+
command: list[str],
|
|
263
|
+
*,
|
|
264
|
+
cwd: str,
|
|
265
|
+
on_notification: Callable[[str, dict[str, Any]], None],
|
|
266
|
+
on_server_request: Callable[[Any, str, dict[str, Any]], None],
|
|
267
|
+
stderr=None,
|
|
268
|
+
socket_path: str | None = None,
|
|
269
|
+
):
|
|
270
|
+
self.on_notification = on_notification
|
|
271
|
+
self.on_server_request = on_server_request
|
|
272
|
+
self._ws: _UnixWebSocket | None = None
|
|
273
|
+
self._socket_shutdown = False
|
|
274
|
+
self._parent_watcher: subprocess.Popen | None = None
|
|
275
|
+
self._owned_pgid: int | None = None
|
|
276
|
+
if socket_path:
|
|
277
|
+
# process_group=0 makes this watcher the sole leader of a new
|
|
278
|
+
# process group within codexd's session. The native launcher
|
|
279
|
+
# joins it, but .process remains the actual app-server PID.
|
|
280
|
+
# The watcher has no user env/credentials; only a stdin pipe
|
|
281
|
+
# held by codexd. The app-server inherits neither pipe endpoint.
|
|
282
|
+
watcher = subprocess.Popen(
|
|
283
|
+
[sys.executable, "-I", "-c", _PARENT_LIFETIME_WATCHER],
|
|
284
|
+
cwd=cwd, env={}, stdin=subprocess.PIPE,
|
|
285
|
+
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
|
|
286
|
+
close_fds=True, process_group=0,
|
|
287
|
+
)
|
|
288
|
+
self._parent_watcher = watcher
|
|
289
|
+
self._owned_pgid = watcher.pid
|
|
290
|
+
try:
|
|
291
|
+
self.process = subprocess.Popen(
|
|
292
|
+
command,
|
|
293
|
+
cwd=cwd,
|
|
294
|
+
env=_user_command_env(),
|
|
295
|
+
stdin=subprocess.DEVNULL if socket_path else subprocess.PIPE,
|
|
296
|
+
stdout=subprocess.DEVNULL if socket_path else subprocess.PIPE,
|
|
297
|
+
stderr=stderr,
|
|
298
|
+
text=True,
|
|
299
|
+
bufsize=1,
|
|
300
|
+
**({"umask": 0o077, "process_group": self._owned_pgid}
|
|
301
|
+
if socket_path else {}),
|
|
302
|
+
)
|
|
303
|
+
except BaseException:
|
|
304
|
+
if self._parent_watcher is not None:
|
|
305
|
+
# Only the group we just created; the native launch failed.
|
|
306
|
+
try:
|
|
307
|
+
os.killpg(self._parent_watcher.pid, signal.SIGKILL)
|
|
308
|
+
except ProcessLookupError:
|
|
309
|
+
pass
|
|
310
|
+
self._parent_watcher.wait()
|
|
311
|
+
self._parent_watcher.stdin.close()
|
|
312
|
+
raise
|
|
313
|
+
if socket_path:
|
|
314
|
+
try:
|
|
315
|
+
deadline = time.monotonic() + 20
|
|
316
|
+
while True:
|
|
317
|
+
if self.process.poll() is not None:
|
|
318
|
+
raise ProtocolTransportError("Codex socket app-server exited during startup")
|
|
319
|
+
try:
|
|
320
|
+
self._ws = _UnixWebSocket(
|
|
321
|
+
socket_path, handshake_timeout=min(1., max(.1, deadline - time.monotonic())))
|
|
322
|
+
self._ws.sock.setblocking(False)
|
|
323
|
+
break
|
|
324
|
+
except OSError as error:
|
|
325
|
+
if time.monotonic() >= deadline:
|
|
326
|
+
raise ProtocolTransportError(
|
|
327
|
+
f"Codex app-server WebSocket not ready: {error}") from error
|
|
328
|
+
time.sleep(.05)
|
|
329
|
+
except BaseException:
|
|
330
|
+
self.close()
|
|
331
|
+
raise
|
|
332
|
+
self._next_id = 1
|
|
333
|
+
self._pending: dict[Any, queue.Queue] = {}
|
|
334
|
+
# Registry/reader/failure state is separate from the original client
|
|
335
|
+
# writer lock. A blocked writer never delays reader-side _fail.
|
|
336
|
+
self._state_lock = threading.Lock()
|
|
337
|
+
self._lock = threading.Lock()
|
|
338
|
+
if self.process.stdin is not None:
|
|
339
|
+
# Exclusively use raw UTF-8 os.write from this point onward. Do not
|
|
340
|
+
# use TextIOWrapper.write/flush alongside this nonblocking fd.
|
|
341
|
+
os.set_blocking(self.process.stdin.fileno(), False)
|
|
342
|
+
self._closed = threading.Event()
|
|
343
|
+
self._failure: ProtocolTransportError | None = None
|
|
344
|
+
self._reader = threading.Thread(
|
|
345
|
+
target=self._read_loop, name="ccx-codex-appserver", daemon=True
|
|
346
|
+
)
|
|
347
|
+
self._reader.start()
|
|
348
|
+
|
|
349
|
+
@property
|
|
350
|
+
def failure(self) -> ProtocolTransportError | None:
|
|
351
|
+
"""The latched transport failure, if this client has stopped reading.
|
|
352
|
+
|
|
353
|
+
The reader is the only consumer of app-server notifications, so a
|
|
354
|
+
latched failure means the connection is unusable even while the
|
|
355
|
+
subprocess is still alive. Supervisors must treat it as fatal.
|
|
356
|
+
"""
|
|
357
|
+
with self._state_lock:
|
|
358
|
+
return self._failure
|
|
359
|
+
|
|
360
|
+
@classmethod
|
|
361
|
+
def _encode_frame(cls, message: dict[str, Any]) -> bytes:
|
|
362
|
+
# Keep the JSON ASCII escape convention of the original text writer:
|
|
363
|
+
# it also encodes surrogateescape filenames/argv (e.g. \udcff) as
|
|
364
|
+
# valid JSON without attempting to UTF-8 encode an unpaired surrogate.
|
|
365
|
+
# Real Unicode still round-trips through json.loads on the provider.
|
|
366
|
+
frame = (json.dumps(message, separators=(",", ":"),
|
|
367
|
+
ensure_ascii=True, allow_nan=False) + "\n").encode("utf-8")
|
|
368
|
+
if len(frame) > cls._MAX_JSONL_FRAME_BYTES:
|
|
369
|
+
raise ProtocolError("app-server JSONL frame exceeds bounded write size")
|
|
370
|
+
return frame
|
|
371
|
+
|
|
372
|
+
def _wire_frame(self, message: dict[str, Any]) -> bytes:
|
|
373
|
+
raw = self._encode_frame(message)
|
|
374
|
+
return _masked_frame(0x1, raw[:-1]) if self._ws is not None else raw
|
|
375
|
+
|
|
376
|
+
def _write_frame_locked(self, frame: bytes, deadline: float, *,
|
|
377
|
+
method: str | None = None) -> None:
|
|
378
|
+
"""Deadline covers every byte, including a full or stalled stdin pipe.
|
|
379
|
+
|
|
380
|
+
The caller owns `_lock`. No text buffer or arbitrary worker wait
|
|
381
|
+
is involved; the reader can fail pending requests through `_state_lock` even
|
|
382
|
+
during a stalled write. Once any byte might have escaped, a timeout or
|
|
383
|
+
partial frame is transport-unknown, never a safe retry permission.
|
|
384
|
+
"""
|
|
385
|
+
sent = 0
|
|
386
|
+
pipe_blocked = False
|
|
387
|
+
full_write_window = False
|
|
388
|
+
while sent < len(frame):
|
|
389
|
+
with self._state_lock:
|
|
390
|
+
failed = self._failure
|
|
391
|
+
if failed is not None:
|
|
392
|
+
if sent == 0:
|
|
393
|
+
raise ProtocolNotSent(failed.message, method=method) from failed
|
|
394
|
+
raise ProtocolTransportError(
|
|
395
|
+
"app-server channel failed after partial JSONL frame",
|
|
396
|
+
method=method,
|
|
397
|
+
) from failed
|
|
398
|
+
remaining = deadline - time.monotonic()
|
|
399
|
+
if remaining <= 0:
|
|
400
|
+
if sent > 0:
|
|
401
|
+
raise ProtocolTransportError(
|
|
402
|
+
"app-server write deadline exceeded after partial frame",
|
|
403
|
+
method=method,
|
|
404
|
+
)
|
|
405
|
+
if full_write_window:
|
|
406
|
+
# An unresponsive pipe over the entire normal write
|
|
407
|
+
# window is a shared transport failure, even at zero bytes.
|
|
408
|
+
raise ProtocolTransportError(
|
|
409
|
+
"app-server pipe failed to make progress for full write window",
|
|
410
|
+
method=method,
|
|
411
|
+
)
|
|
412
|
+
# This request's shorter admission budget expired before any
|
|
413
|
+
# native bytes escaped. No shared pipe corruption or proof of
|
|
414
|
+
# provider rejection: siblings may still use the live client.
|
|
415
|
+
raise ProtocolNotSent(
|
|
416
|
+
"app-server write deadline exceeded before first byte",
|
|
417
|
+
method=method,
|
|
418
|
+
)
|
|
419
|
+
fd = (self._ws.sock.fileno() if self._ws is not None
|
|
420
|
+
else self.process.stdin.fileno() if self.process.stdin is not None
|
|
421
|
+
else None)
|
|
422
|
+
if fd is None:
|
|
423
|
+
raise ProtocolTransportError(
|
|
424
|
+
"app-server write channel unavailable", method=method,
|
|
425
|
+
)
|
|
426
|
+
try:
|
|
427
|
+
written = os.write(fd, frame[sent:])
|
|
428
|
+
if written <= 0:
|
|
429
|
+
raise OSError(errno.EPIPE, "app-server pipe made no write progress")
|
|
430
|
+
sent += written
|
|
431
|
+
continue
|
|
432
|
+
except InterruptedError:
|
|
433
|
+
continue
|
|
434
|
+
except BlockingIOError:
|
|
435
|
+
if not pipe_blocked:
|
|
436
|
+
pipe_blocked = True
|
|
437
|
+
# Tolerate only scheduling/serialization jitter when the
|
|
438
|
+
# full normal window was allotted to this pipe write.
|
|
439
|
+
# A caller whose original <=2s admission was consumed by
|
|
440
|
+
# claim/marker fsync gets a local zero-byte timeout instead.
|
|
441
|
+
slack = min(.01, self._WRITE_TIMEOUT_S / 10)
|
|
442
|
+
full_write_window = (
|
|
443
|
+
deadline - time.monotonic() >= self._WRITE_TIMEOUT_S - slack
|
|
444
|
+
)
|
|
445
|
+
except (OSError, ValueError) as error:
|
|
446
|
+
# EPIPE / a genuinely broken fd is fatal regardless of byte
|
|
447
|
+
# count. Only a proven budget expiry has local semantics.
|
|
448
|
+
raise ProtocolTransportError(
|
|
449
|
+
f"app-server pipe write failed: {error}", method=method,
|
|
450
|
+
) from error
|
|
451
|
+
try:
|
|
452
|
+
# Check for reader failure at least once per tenth-second,
|
|
453
|
+
# without resetting the one absolute end-to-end deadline.
|
|
454
|
+
select.select([], [fd], [], min(.1, max(0., deadline - time.monotonic())))
|
|
455
|
+
except InterruptedError:
|
|
456
|
+
continue
|
|
457
|
+
except (OSError, ValueError) as error:
|
|
458
|
+
# A broken poll descriptor is a shared transport fault, not a
|
|
459
|
+
# caller-local timeout even if this frame sent no bytes.
|
|
460
|
+
raise ProtocolTransportError(
|
|
461
|
+
f"app-server pipe wait failed: {error}", method=method,
|
|
462
|
+
) from error
|
|
463
|
+
|
|
464
|
+
def _write(self, message: dict[str, Any]) -> None:
|
|
465
|
+
"""Bounded notification/response write; never share text buffering."""
|
|
466
|
+
self._write_bytes(self._wire_frame(message))
|
|
467
|
+
|
|
468
|
+
def _write_bytes(self, frame: bytes) -> None:
|
|
469
|
+
"""Bounded serialized write for JSON messages and WS pong frames."""
|
|
470
|
+
deadline = time.monotonic() + self._WRITE_TIMEOUT_S
|
|
471
|
+
if not self._lock.acquire(timeout=max(0., deadline - time.monotonic())):
|
|
472
|
+
# No bytes from this notification escaped; an unrelated writer
|
|
473
|
+
# owning the lock is not evidence that its channel is corrupt.
|
|
474
|
+
raise ProtocolNotSent("app-server writer lock deadline exceeded")
|
|
475
|
+
error = None
|
|
476
|
+
pending = []
|
|
477
|
+
failure = None
|
|
478
|
+
try:
|
|
479
|
+
try:
|
|
480
|
+
self._write_frame_locked(frame, deadline)
|
|
481
|
+
except ProtocolNotSent as exc:
|
|
482
|
+
# A short local write deadline cannot poison unrelated sends.
|
|
483
|
+
error = exc
|
|
484
|
+
except ProtocolTransportError as exc:
|
|
485
|
+
error = exc
|
|
486
|
+
failure = ProtocolTransportError(exc.message)
|
|
487
|
+
pending = self._latch_failure(failure)
|
|
488
|
+
finally:
|
|
489
|
+
self._lock.release()
|
|
490
|
+
if error is not None:
|
|
491
|
+
# Fatal state (if any) was latched before releasing the writer
|
|
492
|
+
# lock. A caller-local zero-byte timeout has no sibling callbacks.
|
|
493
|
+
# All actual failure callbacks remain outside both client locks.
|
|
494
|
+
if failure is not None:
|
|
495
|
+
self._deliver_failure(pending, failure)
|
|
496
|
+
raise error
|
|
497
|
+
|
|
498
|
+
def _latch_failure(self, failure: ProtocolTransportError) -> list[Any]:
|
|
499
|
+
"""Publish fatal channel state without acquiring the writer lock."""
|
|
500
|
+
with self._state_lock:
|
|
501
|
+
if self._failure is not None:
|
|
502
|
+
return []
|
|
503
|
+
self._failure = failure
|
|
504
|
+
self._closed.set()
|
|
505
|
+
pending = list(self._pending.values())
|
|
506
|
+
self._pending.clear()
|
|
507
|
+
return pending
|
|
508
|
+
|
|
509
|
+
@staticmethod
|
|
510
|
+
def _deliver_failure(pending: list[Any], failure: ProtocolTransportError) -> None:
|
|
511
|
+
"""Deliver outside both client locks, including registered callbacks."""
|
|
512
|
+
for result_queue in pending:
|
|
513
|
+
try:
|
|
514
|
+
result_queue.put_nowait(failure)
|
|
515
|
+
except queue.Full:
|
|
516
|
+
pass
|
|
517
|
+
|
|
518
|
+
def _fail(self, failure: ProtocolTransportError) -> None:
|
|
519
|
+
self._deliver_failure(self._latch_failure(failure), failure)
|
|
520
|
+
|
|
521
|
+
def _send(
|
|
522
|
+
self, method: str, params: dict[str, Any] | None, sink: Any,
|
|
523
|
+
*, write_deadline: float | None = None,
|
|
524
|
+
) -> int:
|
|
525
|
+
"""One JSONL request with bounded lock and pipe admission.
|
|
526
|
+
|
|
527
|
+
An unexpired, locally shortened write deadline can fail before any
|
|
528
|
+
byte without damaging the shared transport or siblings. Lock deadline
|
|
529
|
+
expiry has the same request-only semantics. A partial frame, EPIPE or
|
|
530
|
+
independently failed channel remains fatal and wakes all siblings.
|
|
531
|
+
None of these outcomes undo a CCX submitted-unknown activation claim.
|
|
532
|
+
"""
|
|
533
|
+
deadline = (time.monotonic() + self._WRITE_TIMEOUT_S
|
|
534
|
+
if write_deadline is None else write_deadline)
|
|
535
|
+
if not self._lock.acquire(timeout=max(0., deadline - time.monotonic())):
|
|
536
|
+
# Writer-lock contention proves zero bytes from this request.
|
|
537
|
+
# The existing pending siblings and physical JSONL stream survive.
|
|
538
|
+
raise ProtocolNotSent(
|
|
539
|
+
"app-server writer lock deadline exceeded", method=method,
|
|
540
|
+
)
|
|
541
|
+
error = None
|
|
542
|
+
pending = []
|
|
543
|
+
failure = None
|
|
544
|
+
try:
|
|
545
|
+
with self._state_lock:
|
|
546
|
+
if self._failure is not None:
|
|
547
|
+
raise ProtocolNotSent(
|
|
548
|
+
self._failure.message, method=method,
|
|
549
|
+
) from self._failure
|
|
550
|
+
request_id = self._next_id
|
|
551
|
+
self._next_id += 1
|
|
552
|
+
message = {"jsonrpc": "2.0", "id": request_id, "method": method}
|
|
553
|
+
if params is not None:
|
|
554
|
+
message["params"] = params
|
|
555
|
+
frame = self._wire_frame(message)
|
|
556
|
+
with self._state_lock:
|
|
557
|
+
if self._failure is not None:
|
|
558
|
+
raise ProtocolNotSent(
|
|
559
|
+
self._failure.message, method=method,
|
|
560
|
+
) from self._failure
|
|
561
|
+
self._pending[request_id] = sink
|
|
562
|
+
try:
|
|
563
|
+
self._write_frame_locked(frame, deadline, method=method)
|
|
564
|
+
except ProtocolNotSent as exc:
|
|
565
|
+
# The frame was not emitted: forget this request sink only.
|
|
566
|
+
# Another request's still-pending reply remains observable.
|
|
567
|
+
with self._state_lock:
|
|
568
|
+
if self._pending.get(request_id) is sink:
|
|
569
|
+
del self._pending[request_id]
|
|
570
|
+
error = exc
|
|
571
|
+
except ProtocolTransportError as exc:
|
|
572
|
+
error = exc
|
|
573
|
+
failure = ProtocolTransportError(exc.message, method=method)
|
|
574
|
+
pending = self._latch_failure(failure)
|
|
575
|
+
finally:
|
|
576
|
+
self._lock.release()
|
|
577
|
+
if error is not None:
|
|
578
|
+
# Shared transport failures were latched before unlocking, and
|
|
579
|
+
# callbacks may safely re-enter client methods. A zero-byte local
|
|
580
|
+
# deadline has no shared failure or pending-sibling deliveries.
|
|
581
|
+
if failure is not None:
|
|
582
|
+
self._deliver_failure(pending, failure)
|
|
583
|
+
raise error
|
|
584
|
+
return request_id
|
|
585
|
+
|
|
586
|
+
def begin_request(
|
|
587
|
+
self, method: str, params: dict[str, Any] | None = None,
|
|
588
|
+
*, write_deadline: float | None = None,
|
|
589
|
+
) -> "PendingRequest":
|
|
590
|
+
"""Send one request without waiting for it.
|
|
591
|
+
|
|
592
|
+
Unlike `request`, no caller timeout ever forgets it: the answer, or
|
|
593
|
+
the transport failure that ends the connection, always reaches the
|
|
594
|
+
returned `PendingRequest`. Use it for a mutation whose completion
|
|
595
|
+
something else must be able to wait for later.
|
|
596
|
+
"""
|
|
597
|
+
pending = PendingRequest(method)
|
|
598
|
+
pending.request_id = self._send(
|
|
599
|
+
method, params, pending, write_deadline=write_deadline,
|
|
600
|
+
)
|
|
601
|
+
return pending
|
|
602
|
+
|
|
603
|
+
def _request_once(
|
|
604
|
+
self,
|
|
605
|
+
method: str,
|
|
606
|
+
params: dict[str, Any] | None,
|
|
607
|
+
timeout: float,
|
|
608
|
+
) -> dict[str, Any]:
|
|
609
|
+
result_queue: queue.Queue = queue.Queue(maxsize=1)
|
|
610
|
+
# Preserve the response-read timeout as a separate observation wait
|
|
611
|
+
# after frame submission. Write admission has its own bounded budget,
|
|
612
|
+
# and a failed write is never a provider rejection or read timeout.
|
|
613
|
+
request_id = self._send(
|
|
614
|
+
method, params, result_queue,
|
|
615
|
+
write_deadline=time.monotonic() + self._WRITE_TIMEOUT_S,
|
|
616
|
+
)
|
|
617
|
+
|
|
618
|
+
try:
|
|
619
|
+
response = result_queue.get(timeout=max(0., timeout))
|
|
620
|
+
except queue.Empty as error:
|
|
621
|
+
with self._state_lock:
|
|
622
|
+
self._pending.pop(request_id, None)
|
|
623
|
+
raise ProtocolError(f"{method} timed out", method=method) from error
|
|
624
|
+
return _result_from_response(method, response, request_id)
|
|
625
|
+
|
|
626
|
+
def request(
|
|
627
|
+
self, method: str, params: dict[str, Any] | None = None, timeout: float = 30
|
|
628
|
+
) -> dict[str, Any]:
|
|
629
|
+
deadline = time.monotonic() + max(0.0, timeout)
|
|
630
|
+
delay = self._OVERLOAD_DELAY_S
|
|
631
|
+
last_overload: ServerOverloaded | None = None
|
|
632
|
+
for attempt in range(self._MAX_OVERLOAD_RETRIES + 1):
|
|
633
|
+
remaining = max(0.0, deadline - time.monotonic())
|
|
634
|
+
if attempt > 0 and remaining <= 0:
|
|
635
|
+
# Never write an automatic retry after its shared observation
|
|
636
|
+
# budget has expired: the overload rejection only proved that
|
|
637
|
+
# the previous request was not processed.
|
|
638
|
+
assert last_overload is not None
|
|
639
|
+
raise last_overload
|
|
640
|
+
try:
|
|
641
|
+
return self._request_once(method, params, remaining)
|
|
642
|
+
except ServerOverloaded as error:
|
|
643
|
+
last_overload = error
|
|
644
|
+
if attempt >= self._MAX_OVERLOAD_RETRIES:
|
|
645
|
+
raise
|
|
646
|
+
remaining = max(0.0, deadline - time.monotonic())
|
|
647
|
+
sleep_for = min(
|
|
648
|
+
remaining,
|
|
649
|
+
delay + random.uniform(0.0, delay * 0.25),
|
|
650
|
+
)
|
|
651
|
+
if sleep_for <= 0:
|
|
652
|
+
raise error
|
|
653
|
+
time.sleep(sleep_for)
|
|
654
|
+
delay = min(self._OVERLOAD_DELAY_CAP_S, delay * 2)
|
|
655
|
+
|
|
656
|
+
def notify(self, method: str, params: dict[str, Any] | None = None) -> None:
|
|
657
|
+
message = {"jsonrpc": "2.0", "method": method}
|
|
658
|
+
if params is not None:
|
|
659
|
+
message["params"] = params
|
|
660
|
+
self._write(message)
|
|
661
|
+
|
|
662
|
+
def respond(self, request_id: Any, result: dict[str, Any]) -> None:
|
|
663
|
+
self._write({"jsonrpc": "2.0", "id": request_id, "result": result})
|
|
664
|
+
|
|
665
|
+
def reject_request(self, request_id: Any, message: str) -> None:
|
|
666
|
+
self._write({
|
|
667
|
+
"jsonrpc": "2.0",
|
|
668
|
+
"id": request_id,
|
|
669
|
+
"error": {"code": -32601, "message": message},
|
|
670
|
+
})
|
|
671
|
+
|
|
672
|
+
def _messages(self):
|
|
673
|
+
if self._ws is None:
|
|
674
|
+
assert self.process.stdout is not None
|
|
675
|
+
yield from self.process.stdout
|
|
676
|
+
else:
|
|
677
|
+
while not self._closed.is_set():
|
|
678
|
+
yield self._ws.recv_text(
|
|
679
|
+
on_ping=lambda payload: self._write_bytes(_masked_frame(0xA, payload)))
|
|
680
|
+
|
|
681
|
+
def _read_loop(self) -> None:
|
|
682
|
+
try:
|
|
683
|
+
for raw in self._messages():
|
|
684
|
+
try:
|
|
685
|
+
message = json.loads(raw)
|
|
686
|
+
except ValueError as error:
|
|
687
|
+
excerpt = repr(raw[:200])
|
|
688
|
+
raise ProtocolTransportError(
|
|
689
|
+
f"invalid JSON from app-server stdout: {excerpt}"
|
|
690
|
+
) from error
|
|
691
|
+
if not isinstance(message, dict):
|
|
692
|
+
raise ProtocolTransportError(
|
|
693
|
+
"invalid app-server protocol message: expected object"
|
|
694
|
+
)
|
|
695
|
+
request_id = message.get("id")
|
|
696
|
+
if request_id is not None and (
|
|
697
|
+
"result" in message or "error" in message
|
|
698
|
+
):
|
|
699
|
+
with self._state_lock:
|
|
700
|
+
pending = self._pending.pop(request_id, None)
|
|
701
|
+
if pending:
|
|
702
|
+
pending.put(message)
|
|
703
|
+
continue
|
|
704
|
+
method = message.get("method")
|
|
705
|
+
if not method:
|
|
706
|
+
if request_id is not None:
|
|
707
|
+
raise ProtocolTransportError(
|
|
708
|
+
"invalid app-server response: missing result or error"
|
|
709
|
+
)
|
|
710
|
+
continue
|
|
711
|
+
params = message.get("params")
|
|
712
|
+
if not isinstance(params, dict):
|
|
713
|
+
params = {}
|
|
714
|
+
try:
|
|
715
|
+
if request_id is not None:
|
|
716
|
+
self.on_server_request(request_id, method, params)
|
|
717
|
+
else:
|
|
718
|
+
self.on_notification(method, params)
|
|
719
|
+
except Exception as error:
|
|
720
|
+
# A message whose processing raised cannot be treated as
|
|
721
|
+
# consumed, and skipping it would leave the daemon's view
|
|
722
|
+
# of a worker permanently behind the runtime. Fail the
|
|
723
|
+
# shared client with the real cause instead.
|
|
724
|
+
raise ProtocolTransportError(
|
|
725
|
+
f"app-server {method} handler failed: "
|
|
726
|
+
f"{error!r}"[:400]
|
|
727
|
+
) from error
|
|
728
|
+
except ProtocolTransportError as error:
|
|
729
|
+
self._fail(error)
|
|
730
|
+
except (OSError, EOFError, ValueError) as error:
|
|
731
|
+
self._fail(ProtocolTransportError(f"Codex app-server channel closed: {error}"))
|
|
732
|
+
finally:
|
|
733
|
+
self._fail(
|
|
734
|
+
ProtocolTransportError("Codex app-server exited")
|
|
735
|
+
)
|
|
736
|
+
|
|
737
|
+
def close(self) -> None:
|
|
738
|
+
if self._ws is not None or self.process.stdin is None:
|
|
739
|
+
if self._socket_shutdown:
|
|
740
|
+
return
|
|
741
|
+
self._socket_shutdown = True
|
|
742
|
+
if hasattr(self, "_closed"):
|
|
743
|
+
self._closed.set()
|
|
744
|
+
# The socket-mode launcher spawns the vendor binary. Terminating
|
|
745
|
+
# only the launcher can orphan it; the child has its own process
|
|
746
|
+
# group, separate from codexd and every unrelated Codex process.
|
|
747
|
+
ws = self._ws
|
|
748
|
+
if ws is not None:
|
|
749
|
+
try:
|
|
750
|
+
ws.sock.shutdown(socket.SHUT_RDWR)
|
|
751
|
+
except OSError:
|
|
752
|
+
pass
|
|
753
|
+
ws.sock.close()
|
|
754
|
+
watcher = getattr(self, "_parent_watcher", None)
|
|
755
|
+
if watcher is not None and watcher.stdin is not None and not watcher.stdin.closed:
|
|
756
|
+
watcher.stdin.close()
|
|
757
|
+
pgid = getattr(self, "_owned_pgid", None) or self.process.pid
|
|
758
|
+
try:
|
|
759
|
+
os.killpg(pgid, signal.SIGTERM)
|
|
760
|
+
except ProcessLookupError:
|
|
761
|
+
pass
|
|
762
|
+
try:
|
|
763
|
+
self.process.wait(timeout=2)
|
|
764
|
+
except subprocess.TimeoutExpired:
|
|
765
|
+
pass
|
|
766
|
+
# A vendor child can survive its launcher. The watcher anchors
|
|
767
|
+
# this group until it is terminated/reaped even if the leader
|
|
768
|
+
# app-server exited first.
|
|
769
|
+
try:
|
|
770
|
+
os.killpg(pgid, signal.SIGKILL)
|
|
771
|
+
except ProcessLookupError:
|
|
772
|
+
pass
|
|
773
|
+
try:
|
|
774
|
+
self.process.wait(timeout=2)
|
|
775
|
+
except subprocess.TimeoutExpired:
|
|
776
|
+
pass
|
|
777
|
+
if watcher is not None:
|
|
778
|
+
try:
|
|
779
|
+
watcher.wait(timeout=2)
|
|
780
|
+
except subprocess.TimeoutExpired:
|
|
781
|
+
watcher.kill()
|
|
782
|
+
watcher.wait()
|
|
783
|
+
return
|
|
784
|
+
if self.process.poll() is not None:
|
|
785
|
+
return
|
|
786
|
+
# stdin EOF is the app-server's clean shutdown signal and the only one
|
|
787
|
+
# the `codex` launcher reliably propagates: SIGTERM kills the launcher
|
|
788
|
+
# while orphaning the vendor-binary grandchild, which then keeps
|
|
789
|
+
# polling its backing service from under init.
|
|
790
|
+
try:
|
|
791
|
+
if self.process.stdin is not None:
|
|
792
|
+
self.process.stdin.close()
|
|
793
|
+
except OSError:
|
|
794
|
+
pass
|
|
795
|
+
try:
|
|
796
|
+
self.process.wait(timeout=3)
|
|
797
|
+
return
|
|
798
|
+
except subprocess.TimeoutExpired:
|
|
799
|
+
pass
|
|
800
|
+
self.process.terminate()
|
|
801
|
+
try:
|
|
802
|
+
self.process.wait(timeout=3)
|
|
803
|
+
except subprocess.TimeoutExpired:
|
|
804
|
+
self.process.kill()
|
|
805
|
+
self.process.wait(timeout=3)
|