@vibe-x/dsh-html-workbench 0.3.0 → 0.3.2
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/README.md +68 -5
- package/assets/workbench.html +51 -14
- package/dsh.plugin.json +1 -1
- package/lib/client.js +527 -175
- package/package.json +2 -1
- package/scripts/workbench.py +486 -68
- package/src/client.js +513 -175
- package/src/host.js +515 -114
- package/src/index.js +96 -11
package/scripts/workbench.py
CHANGED
|
@@ -14,6 +14,7 @@ import logging
|
|
|
14
14
|
import mimetypes
|
|
15
15
|
import os
|
|
16
16
|
import re
|
|
17
|
+
import secrets
|
|
17
18
|
import signal
|
|
18
19
|
import subprocess
|
|
19
20
|
import sys
|
|
@@ -21,6 +22,7 @@ import tarfile
|
|
|
21
22
|
import tempfile
|
|
22
23
|
import threading
|
|
23
24
|
import time
|
|
25
|
+
import traceback
|
|
24
26
|
import urllib.error
|
|
25
27
|
import urllib.parse
|
|
26
28
|
import urllib.request
|
|
@@ -34,7 +36,7 @@ from typing import Any
|
|
|
34
36
|
|
|
35
37
|
|
|
36
38
|
SERVICE_NAME = "html-workbench"
|
|
37
|
-
SERVICE_VERSION = "2.
|
|
39
|
+
SERVICE_VERSION = "2.2.0"
|
|
38
40
|
|
|
39
41
|
DEFAULT_PORT = 4317
|
|
40
42
|
# Capability names advertised by /api/health. `command_open` refuses to reuse a
|
|
@@ -113,8 +115,10 @@ def default_vendor_cache() -> Path:
|
|
|
113
115
|
return base / "html-workbench" / "vendor" / "grapesjs" / GRAPESJS_VERSION
|
|
114
116
|
|
|
115
117
|
|
|
116
|
-
LOG_MAX_BYTES =
|
|
118
|
+
LOG_MAX_BYTES = 1024 * 1024
|
|
117
119
|
LOG_BACKUP_COUNT = 5
|
|
120
|
+
LOG_RECORD_MAX_BYTES = 16 * 1024
|
|
121
|
+
BOOTSTRAP_MAX_BYTES = 256 * 1024
|
|
118
122
|
|
|
119
123
|
|
|
120
124
|
def default_log_dir() -> Path:
|
|
@@ -126,6 +130,218 @@ def default_log_dir() -> Path:
|
|
|
126
130
|
return base / "html-workbench" / "logs"
|
|
127
131
|
|
|
128
132
|
|
|
133
|
+
# Name of the runtime-state folder this process keeps beside its working
|
|
134
|
+
# directory. The DSH plugin resolves the same name host-side, so both halves
|
|
135
|
+
# agree on one location instead of scattering `.html-workbench` and
|
|
136
|
+
# `html-workbench-dsh` through the user's workspace.
|
|
137
|
+
RUNTIME_DIR_NAME = ".html-workbench"
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def runtime_directory() -> Path:
|
|
141
|
+
"""Return this process's runtime-state root: `<cwd>/<RUNTIME_DIR_NAME>`.
|
|
142
|
+
|
|
143
|
+
Under DSH the working directory is the session workspace, which is one of the
|
|
144
|
+
only two trees the subprocess sandbox grants write access to — the other is a
|
|
145
|
+
private temp directory it hands the child through TEMP/TMP. Everywhere else
|
|
146
|
+
the working directory is simply a sensible, self-cleaning location. Logs and
|
|
147
|
+
the GrapesJS cache are disposable, so either is acceptable; what is NOT
|
|
148
|
+
acceptable is a path the sandbox refuses (see `pick_writable_dir`).
|
|
149
|
+
"""
|
|
150
|
+
return Path.cwd() / RUNTIME_DIR_NAME
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def make_temp_file(directory: Path, prefix: str, suffix: str = "") -> tuple[int, Path]:
|
|
154
|
+
"""Create a uniquely named temporary file in `directory`; return (fd, path).
|
|
155
|
+
|
|
156
|
+
Deliberately NOT `tempfile.mkstemp`. On Windows CPython's `_mkstemp_inner`
|
|
157
|
+
treats a `PermissionError` as "that name is already taken": it re-checks the
|
|
158
|
+
directory with `os.access(dir, os.W_OK)` and then retries with a NEW random
|
|
159
|
+
name, up to TMP_MAX (10 000) times. A real DACL always looks writable to
|
|
160
|
+
`os.access`, which cannot see the restricted token DSH's sandbox runs the
|
|
161
|
+
child under — so a directory the sandbox refuses makes `mkstemp` spin for
|
|
162
|
+
minutes (one denied `os.open` per iteration, ~4 ms each) instead of failing
|
|
163
|
+
in milliseconds. A single O_EXCL open turns that into an immediate error the
|
|
164
|
+
caller can catch and recover from.
|
|
165
|
+
"""
|
|
166
|
+
for attempt in range(64):
|
|
167
|
+
candidate = Path(directory) / f"{prefix}{os.getpid()}-{attempt}-{os.urandom(6).hex()}{suffix}"
|
|
168
|
+
try:
|
|
169
|
+
descriptor = os.open(
|
|
170
|
+
candidate,
|
|
171
|
+
os.O_CREAT | os.O_EXCL | os.O_WRONLY | getattr(os, "O_BINARY", 0),
|
|
172
|
+
0o600,
|
|
173
|
+
)
|
|
174
|
+
except FileExistsError:
|
|
175
|
+
continue
|
|
176
|
+
return descriptor, candidate
|
|
177
|
+
raise FileExistsError(f"无法在 {directory} 创建临时文件:连续的文件名冲突")
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def self_ignore(directory: Path) -> None:
|
|
181
|
+
"""Make a runtime directory invisible to git, best effort.
|
|
182
|
+
|
|
183
|
+
The runtime root usually lands inside the session workspace, which for most
|
|
184
|
+
users is a repository: without this, every start dirties `git status` with a
|
|
185
|
+
folder holding nothing but logs and a re-downloadable cache, and the noise
|
|
186
|
+
lands in the user's own project rather than ours. A `.gitignore` containing
|
|
187
|
+
`*` excludes the tree including that file, so nothing has to be added to the
|
|
188
|
+
repository's own ignore rules.
|
|
189
|
+
|
|
190
|
+
Only the runtime root is marked — never an arbitrary directory the caller
|
|
191
|
+
happened to pass, which may be the user's source tree (`--log-dir .`).
|
|
192
|
+
"""
|
|
193
|
+
root = next((parent for parent in (directory, *directory.parents) if parent.name == RUNTIME_DIR_NAME), None)
|
|
194
|
+
if root is None:
|
|
195
|
+
return
|
|
196
|
+
marker = root / ".gitignore"
|
|
197
|
+
if marker.exists():
|
|
198
|
+
return
|
|
199
|
+
try:
|
|
200
|
+
marker.write_text("# Created by html-workbench: disposable logs and cache.\n*\n", encoding="utf-8")
|
|
201
|
+
except OSError:
|
|
202
|
+
pass
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def pick_writable_dir(*candidates: Path, failures: list[str] | None = None) -> Path:
|
|
206
|
+
"""Return the first candidate directory this process can really write into.
|
|
207
|
+
|
|
208
|
+
Existence proves nothing: the sandbox DACL looks writable on paper while the
|
|
209
|
+
restricted token is denied, and a folder the unconfined parent created may
|
|
210
|
+
reject every file creation here with EACCES / WinError 5. The only reliable
|
|
211
|
+
test is an actual create, which `make_temp_file` performs and fails fast.
|
|
212
|
+
Falls back to the first candidate so a caller with nothing writable at all
|
|
213
|
+
still gets a deterministic path (and a clear error at the real write).
|
|
214
|
+
"""
|
|
215
|
+
fallback: Path | None = None
|
|
216
|
+
for candidate in dict.fromkeys(candidates):
|
|
217
|
+
if fallback is None:
|
|
218
|
+
fallback = candidate
|
|
219
|
+
try:
|
|
220
|
+
candidate.mkdir(parents=True, exist_ok=True)
|
|
221
|
+
descriptor, probe = make_temp_file(candidate, prefix=".wb-write-probe.")
|
|
222
|
+
except OSError as caught:
|
|
223
|
+
if failures is not None:
|
|
224
|
+
failures.append(f"{candidate}: {caught}")
|
|
225
|
+
continue
|
|
226
|
+
os.close(descriptor)
|
|
227
|
+
try:
|
|
228
|
+
probe.unlink()
|
|
229
|
+
except OSError:
|
|
230
|
+
pass
|
|
231
|
+
self_ignore(candidate)
|
|
232
|
+
return candidate
|
|
233
|
+
return fallback if fallback is not None else Path.cwd()
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def log_candidates(log_dir: Path | str) -> tuple[Path, ...]:
|
|
237
|
+
"""Log directories to try, best first: the caller's, then writable fallbacks."""
|
|
238
|
+
return (
|
|
239
|
+
Path(log_dir).expanduser(),
|
|
240
|
+
runtime_directory() / "logs",
|
|
241
|
+
temporary_runtime_directory() / "logs",
|
|
242
|
+
)
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def vendor_candidates(vendor_cache: Path | str, log_dir: Path) -> tuple[Path, ...]:
|
|
246
|
+
"""Vendor-cache directories to try, best first.
|
|
247
|
+
|
|
248
|
+
The cache sits beside whichever directory accepted the log: both are
|
|
249
|
+
re-creatable, and the log's parent already proved it is writable.
|
|
250
|
+
"""
|
|
251
|
+
return (
|
|
252
|
+
Path(vendor_cache).expanduser(),
|
|
253
|
+
Path(log_dir).parent / "vendor",
|
|
254
|
+
runtime_directory() / "vendor",
|
|
255
|
+
temporary_runtime_directory() / "vendor",
|
|
256
|
+
)
|
|
257
|
+
|
|
258
|
+
def temporary_runtime_directory() -> Path:
|
|
259
|
+
try:
|
|
260
|
+
return Path(tempfile.gettempdir()) / RUNTIME_DIR_NAME
|
|
261
|
+
except OSError:
|
|
262
|
+
# A denied TEMP must not prevent trying the explicitly supplied directory.
|
|
263
|
+
return runtime_directory()
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def bounded_utf8(text: str, limit: int) -> bytes:
|
|
267
|
+
data = text.encode("utf-8", errors="backslashreplace")
|
|
268
|
+
if len(data) <= limit:
|
|
269
|
+
return data
|
|
270
|
+
marker = b"\n...[truncated]...\n"
|
|
271
|
+
remaining = limit - len(marker)
|
|
272
|
+
head = data[:remaining // 3].decode("utf-8", errors="ignore").encode("utf-8")
|
|
273
|
+
tail = data[-(remaining - remaining // 3):].decode("utf-8", errors="ignore").encode("utf-8")
|
|
274
|
+
return head + marker + tail
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
class BoundedRotatingFileHandler(RotatingFileHandler):
|
|
278
|
+
"""Bound UTF-8 bytes, including exception stacks; back off on disk failures."""
|
|
279
|
+
|
|
280
|
+
last_error: str | None = None
|
|
281
|
+
dropped_records = 0
|
|
282
|
+
retry_at = 0.0
|
|
283
|
+
|
|
284
|
+
def emit(self, record: logging.LogRecord) -> None:
|
|
285
|
+
if time.monotonic() < self.retry_at:
|
|
286
|
+
self.dropped_records += 1
|
|
287
|
+
return
|
|
288
|
+
try:
|
|
289
|
+
data = bounded_utf8(self.format(record), min(LOG_RECORD_MAX_BYTES, self.maxBytes) - 1) + b"\n"
|
|
290
|
+
if self.stream is None:
|
|
291
|
+
self.stream = self._open()
|
|
292
|
+
if os.fstat(self.stream.fileno()).st_size + len(data) > self.maxBytes:
|
|
293
|
+
self.doRollover()
|
|
294
|
+
# Binary writes avoid Windows newline translation and count CJK bytes.
|
|
295
|
+
self.stream.buffer.write(data)
|
|
296
|
+
self.stream.flush()
|
|
297
|
+
self.last_error = None
|
|
298
|
+
except Exception as caught:
|
|
299
|
+
self.last_error = str(caught)[:500]
|
|
300
|
+
self.dropped_records += 1
|
|
301
|
+
self.retry_at = time.monotonic() + 60
|
|
302
|
+
try:
|
|
303
|
+
print(json.dumps({"event": "log_write_failed", "message": self.last_error}), file=sys.stderr, flush=True)
|
|
304
|
+
except OSError:
|
|
305
|
+
pass
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
class BoundedOutput(io.TextIOBase):
|
|
309
|
+
"""Cap the detached child's combined stdout/stderr, including library output."""
|
|
310
|
+
|
|
311
|
+
def __init__(self, output: Any) -> None:
|
|
312
|
+
self.output = output
|
|
313
|
+
self.lock = threading.Lock()
|
|
314
|
+
self.full = False
|
|
315
|
+
|
|
316
|
+
@property
|
|
317
|
+
def encoding(self) -> str:
|
|
318
|
+
return "utf-8"
|
|
319
|
+
|
|
320
|
+
def write(self, text: str) -> int:
|
|
321
|
+
with self.lock:
|
|
322
|
+
if self.full:
|
|
323
|
+
return len(text)
|
|
324
|
+
try:
|
|
325
|
+
remaining = BOOTSTRAP_MAX_BYTES - os.fstat(self.output.fileno()).st_size
|
|
326
|
+
data = text.encode("utf-8", errors="backslashreplace")
|
|
327
|
+
marker = b"\n[bootstrap output limit reached]\n"
|
|
328
|
+
if len(data) + len(marker) > remaining:
|
|
329
|
+
self.full = True
|
|
330
|
+
data = data[:max(0, remaining - len(marker))].decode("utf-8", errors="ignore").encode("utf-8")
|
|
331
|
+
data += marker[:max(0, remaining - len(data))]
|
|
332
|
+
self.output.write(data)
|
|
333
|
+
self.output.flush()
|
|
334
|
+
except OSError:
|
|
335
|
+
self.full = True
|
|
336
|
+
return len(text)
|
|
337
|
+
|
|
338
|
+
def flush(self) -> None:
|
|
339
|
+
try:
|
|
340
|
+
self.output.flush()
|
|
341
|
+
except (OSError, ValueError):
|
|
342
|
+
pass
|
|
343
|
+
|
|
344
|
+
|
|
129
345
|
_logger: logging.Logger | None = None
|
|
130
346
|
|
|
131
347
|
|
|
@@ -144,27 +360,48 @@ def get_logger() -> logging.Logger:
|
|
|
144
360
|
return _logger
|
|
145
361
|
|
|
146
362
|
|
|
147
|
-
def setup_logging(log_dir: Path | str, port: int | None = None) -> logging.Logger:
|
|
148
|
-
"""Attach the rotating file handler
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
363
|
+
def setup_logging(log_dir: Path | str, port: int | None = None) -> tuple[logging.Logger, Path]:
|
|
364
|
+
"""Attach the rotating file handler; return the logger and the directory used.
|
|
365
|
+
|
|
366
|
+
The requested `--log-dir` can be unusable even when it already exists — the
|
|
367
|
+
sandbox denies writes to a folder whose DACL looks writable (see
|
|
368
|
+
`pick_writable_dir`). The directory is therefore probed and handed back to
|
|
369
|
+
the caller, so the host can report where the log really is instead of echoing
|
|
370
|
+
the path it merely asked for. A missing service log is never worth failing
|
|
371
|
+
the whole start: when EVERY candidate refuses writes, `pick_writable_dir`
|
|
372
|
+
returns a deterministic path that the handler cannot open, so the failure is
|
|
373
|
+
swallowed and the service runs without a file log rather than never binding
|
|
374
|
+
its port.
|
|
375
|
+
"""
|
|
376
|
+
failures: list[str] = []
|
|
377
|
+
directory = pick_writable_dir(*log_candidates(log_dir), failures=failures)
|
|
152
378
|
filename = f"workbench-{port}.log" if port is not None else "workbench.log"
|
|
153
|
-
handler = RotatingFileHandler(
|
|
154
|
-
directory / filename,
|
|
155
|
-
maxBytes=LOG_MAX_BYTES,
|
|
156
|
-
backupCount=LOG_BACKUP_COUNT,
|
|
157
|
-
encoding="utf-8",
|
|
158
|
-
)
|
|
159
|
-
handler.setFormatter(logging.Formatter("%(asctime)s %(levelname)s %(message)s"))
|
|
160
379
|
logger = get_logger()
|
|
161
380
|
for existing in list(logger.handlers):
|
|
162
381
|
if isinstance(existing, RotatingFileHandler):
|
|
163
382
|
existing.close()
|
|
164
383
|
logger.removeHandler(existing)
|
|
165
|
-
logger.addHandler(handler)
|
|
166
384
|
logger.setLevel(logging.INFO)
|
|
167
|
-
|
|
385
|
+
try:
|
|
386
|
+
handler = BoundedRotatingFileHandler(
|
|
387
|
+
directory / filename,
|
|
388
|
+
maxBytes=LOG_MAX_BYTES,
|
|
389
|
+
backupCount=LOG_BACKUP_COUNT,
|
|
390
|
+
encoding="utf-8",
|
|
391
|
+
)
|
|
392
|
+
except OSError as caught:
|
|
393
|
+
print(json.dumps({
|
|
394
|
+
"level": "warning", "event": "log_file_unavailable",
|
|
395
|
+
"message": str(caught), "attempts": failures,
|
|
396
|
+
}, ensure_ascii=False), file=sys.stderr, flush=True)
|
|
397
|
+
return logger, directory
|
|
398
|
+
formatter = logging.Formatter("%(asctime)s.%(msecs)03dZ %(levelname)s pid=%(process)d %(message)s", "%Y-%m-%dT%H:%M:%S")
|
|
399
|
+
formatter.converter = time.gmtime
|
|
400
|
+
handler.setFormatter(formatter)
|
|
401
|
+
logger.addHandler(handler)
|
|
402
|
+
for failure in failures:
|
|
403
|
+
logger.warning("log_directory_rejected %s", failure)
|
|
404
|
+
return logger, directory
|
|
168
405
|
|
|
169
406
|
|
|
170
407
|
def sha256_bytes(content: bytes) -> str:
|
|
@@ -218,7 +455,7 @@ def vendor_from_cdn(base_url: str) -> dict[str, bytes]:
|
|
|
218
455
|
def write_vendor_cache(cache_dir: Path, files: dict[str, bytes]) -> None:
|
|
219
456
|
cache_dir.mkdir(parents=True, exist_ok=True)
|
|
220
457
|
for name, content in files.items():
|
|
221
|
-
descriptor, temporary_name =
|
|
458
|
+
descriptor, temporary_name = make_temp_file(cache_dir, prefix=f".{name}.")
|
|
222
459
|
try:
|
|
223
460
|
with os.fdopen(descriptor, "wb") as output:
|
|
224
461
|
output.write(content)
|
|
@@ -231,13 +468,15 @@ def write_vendor_cache(cache_dir: Path, files: dict[str, bytes]) -> None:
|
|
|
231
468
|
|
|
232
469
|
|
|
233
470
|
def ensure_vendor_assets(cache_dir: Path) -> dict[str, Path]:
|
|
471
|
+
logger = get_logger()
|
|
234
472
|
paths = {name: cache_dir / name for name in VENDOR_ASSETS}
|
|
235
473
|
try:
|
|
236
474
|
for name, path in paths.items():
|
|
237
475
|
verify_vendor_content(name, path.read_bytes())
|
|
476
|
+
logger.info("vendor_cache_ready grapesjs=%s", GRAPESJS_VERSION)
|
|
238
477
|
return paths
|
|
239
|
-
except (OSError, ValueError):
|
|
240
|
-
|
|
478
|
+
except (OSError, ValueError) as caught:
|
|
479
|
+
logger.info("vendor_cache_miss grapesjs=%s reason=%s", GRAPESJS_VERSION, caught)
|
|
241
480
|
|
|
242
481
|
failures: list[str] = []
|
|
243
482
|
sources = [
|
|
@@ -245,12 +484,23 @@ def ensure_vendor_assets(cache_dir: Path) -> dict[str, Path]:
|
|
|
245
484
|
*((name, url, vendor_from_cdn) for name, url in VENDOR_CDNS),
|
|
246
485
|
]
|
|
247
486
|
for source_name, url, loader in sources:
|
|
487
|
+
started = time.monotonic()
|
|
248
488
|
try:
|
|
489
|
+
logger.info("vendor_download_start source=%s", source_name)
|
|
249
490
|
files = loader(url)
|
|
250
|
-
write_vendor_cache(cache_dir, files)
|
|
251
|
-
return paths
|
|
252
491
|
except (OSError, ValueError, tarfile.TarError, urllib.error.URLError) as caught:
|
|
253
492
|
failures.append(f"{source_name}: {caught}")
|
|
493
|
+
logger.warning("vendor_download_failed source=%s elapsed_ms=%.0f reason=%s",
|
|
494
|
+
source_name, (time.monotonic() - started) * 1000, caught)
|
|
495
|
+
continue
|
|
496
|
+
try:
|
|
497
|
+
write_vendor_cache(cache_dir, files)
|
|
498
|
+
except (OSError, ValueError) as caught:
|
|
499
|
+
failures.append(f"{source_name} cache write: {caught}")
|
|
500
|
+
logger.warning("vendor_cache_write_failed source=%s reason=%s", source_name, caught)
|
|
501
|
+
continue
|
|
502
|
+
logger.info("vendor_download_ready source=%s elapsed_ms=%.0f", source_name, (time.monotonic() - started) * 1000)
|
|
503
|
+
return paths
|
|
254
504
|
raise WorkbenchError(
|
|
255
505
|
"VENDOR_DOWNLOAD_FAILED",
|
|
256
506
|
"首次启动需要下载约 1.2 MB 的 GrapesJS,但所有下载源均不可用。请检查网络后重试。",
|
|
@@ -982,9 +1232,9 @@ def promote_identities(target: Path, descriptors: list[dict[str, Any]], base_rev
|
|
|
982
1232
|
|
|
983
1233
|
|
|
984
1234
|
def write_atomic(target: Path, content: str) -> None:
|
|
985
|
-
temporary_name:
|
|
1235
|
+
temporary_name: Path | None = None
|
|
986
1236
|
try:
|
|
987
|
-
descriptor, temporary_name =
|
|
1237
|
+
descriptor, temporary_name = make_temp_file(target.parent, prefix=f".{target.name}.workbench-", suffix=".tmp")
|
|
988
1238
|
with os.fdopen(descriptor, "w", encoding="utf-8", newline="") as output:
|
|
989
1239
|
output.write(content)
|
|
990
1240
|
output.flush()
|
|
@@ -1231,7 +1481,7 @@ def save_document(target: Path, body: dict[str, Any]) -> dict[str, Any]:
|
|
|
1231
1481
|
if latest["revision"] != base_revision:
|
|
1232
1482
|
raise WorkbenchError("REVISION_CONFLICT", "保存期间文件发生外部修改,未覆盖最新版。", 409, document=latest)
|
|
1233
1483
|
|
|
1234
|
-
descriptor, temporary_name =
|
|
1484
|
+
descriptor, temporary_name = make_temp_file(target.parent, prefix=f".{target.name}.workbench-", suffix=".tmp")
|
|
1235
1485
|
try:
|
|
1236
1486
|
with os.fdopen(descriptor, "w", encoding="utf-8", newline="") as output:
|
|
1237
1487
|
output.write(source)
|
|
@@ -1249,11 +1499,26 @@ class WorkbenchServer(ThreadingHTTPServer):
|
|
|
1249
1499
|
daemon_threads = True
|
|
1250
1500
|
allow_reuse_address = True
|
|
1251
1501
|
|
|
1252
|
-
def __init__(
|
|
1502
|
+
def __init__(
|
|
1503
|
+
self,
|
|
1504
|
+
address: tuple[str, int],
|
|
1505
|
+
asset_file: Path,
|
|
1506
|
+
editor_root: Path,
|
|
1507
|
+
vendor_files: dict[str, Path],
|
|
1508
|
+
log_dir: Path | None = None,
|
|
1509
|
+
vendor_cache: Path | None = None,
|
|
1510
|
+
log_file: Path | None = None,
|
|
1511
|
+
) -> None:
|
|
1253
1512
|
self.asset_file = asset_file.resolve(strict=True)
|
|
1254
1513
|
self.editor_root = editor_root.resolve(strict=True)
|
|
1255
1514
|
self.vendor_files = {name: path.resolve(strict=True) for name, path in vendor_files.items()}
|
|
1256
1515
|
self.vendor_routes = {str(metadata["route"]): name for name, metadata in VENDOR_ASSETS.items()}
|
|
1516
|
+
# Advertised by /api/health so the host can report the directories actually
|
|
1517
|
+
# in use — including when it REUSES a service it did not start and has no
|
|
1518
|
+
# startup output to read them from.
|
|
1519
|
+
self.log_dir = Path(log_dir) if log_dir is not None else None
|
|
1520
|
+
self.vendor_cache = Path(vendor_cache) if vendor_cache is not None else None
|
|
1521
|
+
self.log_file = log_file
|
|
1257
1522
|
super().__init__(address, WorkbenchHandler)
|
|
1258
1523
|
|
|
1259
1524
|
|
|
@@ -1261,10 +1526,42 @@ class WorkbenchHandler(BaseHTTPRequestHandler):
|
|
|
1261
1526
|
server: WorkbenchServer
|
|
1262
1527
|
protocol_version = "HTTP/1.1"
|
|
1263
1528
|
|
|
1264
|
-
def
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1529
|
+
def handle_one_request(self) -> None:
|
|
1530
|
+
self.request_id = secrets.token_hex(6)
|
|
1531
|
+
self.request_started = time.monotonic()
|
|
1532
|
+
self.error_code = None
|
|
1533
|
+
self.file_ref = "-"
|
|
1534
|
+
super().handle_one_request()
|
|
1535
|
+
|
|
1536
|
+
def parse_request(self) -> bool:
|
|
1537
|
+
# The connection may sit idle before its next request line arrives.
|
|
1538
|
+
self.request_started = time.monotonic()
|
|
1539
|
+
return super().parse_request()
|
|
1540
|
+
|
|
1541
|
+
def log_request(self, code: int | str = "-", size: int | str = "-") -> None:
|
|
1542
|
+
route = getattr(self, "path", "").partition("?")[0]
|
|
1543
|
+
if route == "/api/health" and code == 200:
|
|
1544
|
+
return
|
|
1545
|
+
if route.startswith("/assets/"):
|
|
1546
|
+
route = "/assets/*"
|
|
1547
|
+
elif route not in {"/", "/api/health", "/api/document", "/api/events", "/api/context", "/api/anchor", "/api/shutdown"}:
|
|
1548
|
+
route = route if route in self.server.vendor_routes else "other"
|
|
1549
|
+
status = int(code) if str(code).isdigit() else 0
|
|
1550
|
+
level = logging.ERROR if status >= 500 else logging.WARNING if status >= 400 else logging.INFO
|
|
1551
|
+
method = self.command if self.command in {"GET", "PUT", "POST", "HEAD", "OPTIONS"} else "other"
|
|
1552
|
+
get_logger().log(level, "http_request id=%s method=%s route=%s status=%s elapsed_ms=%.1f code=%s file_ref=%s",
|
|
1553
|
+
self.request_id, method, route, code,
|
|
1554
|
+
(time.monotonic() - self.request_started) * 1000, self.error_code or "-", self.file_ref)
|
|
1555
|
+
|
|
1556
|
+
def log_error(self, format_string: str, *args: Any) -> None:
|
|
1557
|
+
# Base HTTP errors also pass through log_request. Skip the raw message,
|
|
1558
|
+
# which can contain user-controlled request text.
|
|
1559
|
+
if format_string.startswith("Request timed out"):
|
|
1560
|
+
get_logger().warning("http_timeout id=%s", self.request_id)
|
|
1561
|
+
|
|
1562
|
+
def end_headers(self) -> None:
|
|
1563
|
+
self.send_header("X-Workbench-Request-Id", self.request_id)
|
|
1564
|
+
super().end_headers()
|
|
1268
1565
|
|
|
1269
1566
|
def send_bytes(self, status: int, content: bytes, content_type: str, cache: str = "no-store") -> None:
|
|
1270
1567
|
self.send_response(status)
|
|
@@ -1281,33 +1578,52 @@ class WorkbenchHandler(BaseHTTPRequestHandler):
|
|
|
1281
1578
|
self.wfile.write(content)
|
|
1282
1579
|
|
|
1283
1580
|
def send_json(self, status: int, payload: dict[str, Any]) -> None:
|
|
1581
|
+
if status >= 400:
|
|
1582
|
+
self.error_code = payload.get("error")
|
|
1284
1583
|
content = json.dumps(payload, ensure_ascii=False, separators=(",", ":")).encode("utf-8")
|
|
1285
1584
|
self.send_bytes(status, content, "application/json; charset=utf-8")
|
|
1286
1585
|
|
|
1287
1586
|
def send_error_payload(self, caught: Exception) -> None:
|
|
1288
1587
|
if isinstance(caught, WorkbenchError):
|
|
1289
|
-
get_logger().warning("
|
|
1588
|
+
get_logger().warning("request_failed id=%s code=%s reason=%s", self.request_id, caught.code, caught)
|
|
1290
1589
|
self.send_json(caught.status, {"error": caught.code, "message": str(caught), **caught.extra})
|
|
1291
1590
|
else:
|
|
1292
|
-
get_logger().exception("
|
|
1591
|
+
get_logger().exception("request_failed id=%s code=SERVER_ERROR", self.request_id)
|
|
1293
1592
|
self.send_json(500, {"error": "SERVER_ERROR", "message": str(caught)})
|
|
1294
1593
|
|
|
1295
1594
|
def query_file(self, query: dict[str, list[str]]) -> Path:
|
|
1296
|
-
|
|
1595
|
+
raw = query.get("file", [""])[0]
|
|
1596
|
+
self.file_ref = hashlib.sha256(raw.encode("utf-8")).hexdigest()[:12]
|
|
1597
|
+
return resolve_html_file(raw)
|
|
1297
1598
|
|
|
1298
1599
|
def do_GET(self) -> None:
|
|
1299
|
-
parsed = urllib.parse.urlsplit(self.path)
|
|
1300
|
-
query = urllib.parse.parse_qs(parsed.query)
|
|
1301
1600
|
try:
|
|
1601
|
+
parsed = urllib.parse.urlsplit(self.path)
|
|
1602
|
+
query = urllib.parse.parse_qs(parsed.query)
|
|
1302
1603
|
if parsed.path == "/api/health":
|
|
1303
|
-
|
|
1604
|
+
payload: dict[str, Any] = {
|
|
1304
1605
|
"ok": True,
|
|
1305
1606
|
"service": SERVICE_NAME,
|
|
1306
1607
|
"version": SERVICE_VERSION,
|
|
1608
|
+
"pythonVersion": ".".join(map(str, sys.version_info[:3])),
|
|
1609
|
+
"pythonExecutable": sys.executable,
|
|
1610
|
+
"platform": sys.platform,
|
|
1611
|
+
"pid": os.getpid(),
|
|
1612
|
+
"logging": logging_status(),
|
|
1307
1613
|
"port": self.server.server_port,
|
|
1308
1614
|
"editorRoot": str(self.server.editor_root),
|
|
1309
1615
|
"capabilities": list(SERVICE_CAPABILITIES),
|
|
1310
|
-
}
|
|
1616
|
+
}
|
|
1617
|
+
# Report where this process really writes. The host shows these in
|
|
1618
|
+
# its diagnostics, so a REUSED service describes its own
|
|
1619
|
+
# directories instead of the ones the caller asked for — which may
|
|
1620
|
+
# be exactly what the sandbox refused.
|
|
1621
|
+
if self.server.log_dir is not None:
|
|
1622
|
+
payload["logDir"] = str(self.server.log_dir)
|
|
1623
|
+
payload["logFile"] = str(self.server.log_file) if self.server.log_file is not None else None
|
|
1624
|
+
if self.server.vendor_cache is not None:
|
|
1625
|
+
payload["vendorCache"] = str(self.server.vendor_cache)
|
|
1626
|
+
self.send_json(200, payload)
|
|
1311
1627
|
return
|
|
1312
1628
|
if parsed.path == "/api/document":
|
|
1313
1629
|
self.send_json(200, read_document(self.query_file(query)))
|
|
@@ -1330,23 +1646,25 @@ class WorkbenchHandler(BaseHTTPRequestHandler):
|
|
|
1330
1646
|
self.send_error_payload(caught)
|
|
1331
1647
|
|
|
1332
1648
|
def do_PUT(self) -> None:
|
|
1333
|
-
parsed = urllib.parse.urlsplit(self.path)
|
|
1334
|
-
if parsed.path != "/api/document":
|
|
1335
|
-
self.send_json(404, {"error": "NOT_FOUND", "message": "接口不存在。"})
|
|
1336
|
-
return
|
|
1337
1649
|
try:
|
|
1650
|
+
parsed = urllib.parse.urlsplit(self.path)
|
|
1651
|
+
if parsed.path != "/api/document":
|
|
1652
|
+
self.send_json(404, {"error": "NOT_FOUND", "message": "接口不存在。"})
|
|
1653
|
+
return
|
|
1338
1654
|
body = self.read_json_body()
|
|
1339
1655
|
query = urllib.parse.parse_qs(parsed.query)
|
|
1340
1656
|
self.send_json(200, save_document(self.query_file(query), body))
|
|
1657
|
+
except (BrokenPipeError, ConnectionResetError):
|
|
1658
|
+
return
|
|
1341
1659
|
except json.JSONDecodeError as caught:
|
|
1342
1660
|
self.send_error_payload(WorkbenchError("INVALID_SAVE", f"保存请求不是有效 JSON:{caught}", 400))
|
|
1343
1661
|
except Exception as caught:
|
|
1344
1662
|
self.send_error_payload(caught)
|
|
1345
1663
|
|
|
1346
1664
|
def do_POST(self) -> None:
|
|
1347
|
-
parsed = urllib.parse.urlsplit(self.path)
|
|
1348
|
-
query = urllib.parse.parse_qs(parsed.query)
|
|
1349
1665
|
try:
|
|
1666
|
+
parsed = urllib.parse.urlsplit(self.path)
|
|
1667
|
+
query = urllib.parse.parse_qs(parsed.query)
|
|
1350
1668
|
if parsed.path == "/api/shutdown":
|
|
1351
1669
|
# Lets a newer `open` retire this process cleanly. The service
|
|
1352
1670
|
# holds no state of its own, so exiting is always safe.
|
|
@@ -1369,6 +1687,8 @@ class WorkbenchHandler(BaseHTTPRequestHandler):
|
|
|
1369
1687
|
self.send_json(200, promote_identities(target, selections, str(body.get("baseRevision") or "")))
|
|
1370
1688
|
return
|
|
1371
1689
|
self.send_json(200, build_selection_context(target, selections))
|
|
1690
|
+
except (BrokenPipeError, ConnectionResetError):
|
|
1691
|
+
return
|
|
1372
1692
|
except json.JSONDecodeError as caught:
|
|
1373
1693
|
self.send_error_payload(WorkbenchError("INVALID_ANCHOR", f"请求不是有效 JSON:{caught}", 400))
|
|
1374
1694
|
except Exception as caught:
|
|
@@ -1401,15 +1721,20 @@ class WorkbenchHandler(BaseHTTPRequestHandler):
|
|
|
1401
1721
|
self.send_header("Connection", "keep-alive")
|
|
1402
1722
|
self.end_headers()
|
|
1403
1723
|
last_revision = ""
|
|
1724
|
+
missing = False
|
|
1404
1725
|
while True:
|
|
1405
1726
|
try:
|
|
1406
1727
|
document = read_document(target)
|
|
1728
|
+
missing = False
|
|
1407
1729
|
if document["revision"] != last_revision:
|
|
1408
1730
|
last_revision = document["revision"]
|
|
1409
1731
|
payload = json.dumps({"revision": last_revision, "mtimeMs": document["mtimeMs"]}, separators=(",", ":"))
|
|
1410
1732
|
self.wfile.write(f"data: {payload}\n\n".encode("utf-8"))
|
|
1411
1733
|
self.wfile.flush()
|
|
1412
1734
|
except FileNotFoundError:
|
|
1735
|
+
if not missing:
|
|
1736
|
+
get_logger().warning("file_watch_missing id=%s code=FILE_NOT_FOUND", self.request_id)
|
|
1737
|
+
missing = True
|
|
1413
1738
|
self.wfile.write(b'event: error\ndata: {"error":"FILE_NOT_FOUND"}\n\n')
|
|
1414
1739
|
self.wfile.flush()
|
|
1415
1740
|
time.sleep(0.5)
|
|
@@ -1422,12 +1747,19 @@ def default_asset_file(script_file: Path) -> Path:
|
|
|
1422
1747
|
raise WorkbenchError("ASSET_NOT_FOUND", f"找不到前端资源:{packaged}", 500)
|
|
1423
1748
|
|
|
1424
1749
|
|
|
1425
|
-
def health(port: int, timeout: float = 0.5) -> dict[str, Any] | None:
|
|
1750
|
+
def health(port: int, timeout: float = 0.5, failures: list[str] | None = None) -> dict[str, Any] | None:
|
|
1426
1751
|
try:
|
|
1427
1752
|
with urllib.request.urlopen(f"http://127.0.0.1:{port}/api/health", timeout=timeout) as response:
|
|
1428
|
-
|
|
1429
|
-
|
|
1430
|
-
|
|
1753
|
+
content = response.read(64 * 1024 + 1)
|
|
1754
|
+
if len(content) > 64 * 1024:
|
|
1755
|
+
raise ValueError("health response exceeds 64 KiB")
|
|
1756
|
+
payload = json.loads(content.decode("utf-8"))
|
|
1757
|
+
if not isinstance(payload, dict) or payload.get("service") != SERVICE_NAME:
|
|
1758
|
+
raise ValueError("port responded with a different service identity")
|
|
1759
|
+
return payload
|
|
1760
|
+
except (OSError, ValueError, urllib.error.URLError) as caught:
|
|
1761
|
+
if failures is not None:
|
|
1762
|
+
failures.append(type(caught).__name__ + ": " + str(caught)[:1000])
|
|
1431
1763
|
return None
|
|
1432
1764
|
|
|
1433
1765
|
|
|
@@ -1536,11 +1868,16 @@ def terminate_process(pid: int) -> None:
|
|
|
1536
1868
|
pass
|
|
1537
1869
|
|
|
1538
1870
|
|
|
1539
|
-
def start_detached(script_file: Path, port: int, editor_root: Path, asset_file: Path | None, vendor_cache: Path, log_dir: Path) -> tuple[int, Path]:
|
|
1540
|
-
|
|
1541
|
-
|
|
1542
|
-
|
|
1543
|
-
|
|
1871
|
+
def start_detached(script_file: Path, port: int, editor_root: Path, asset_file: Path | None, vendor_cache: Path, log_dir: Path) -> tuple[int, Path | None]:
|
|
1872
|
+
"""Spawn a detached `serve` and return (pid, path of its captured output).
|
|
1873
|
+
|
|
1874
|
+
The path is None when no candidate directory accepted the file: a service
|
|
1875
|
+
whose console output cannot be captured must still start, so the output is
|
|
1876
|
+
discarded rather than the spawn abandoned (see `setup_logging`).
|
|
1877
|
+
"""
|
|
1878
|
+
directory = pick_writable_dir(*log_candidates(log_dir))
|
|
1879
|
+
log_file: Path | None = directory / f"html-workbench-{port}.log"
|
|
1880
|
+
command = [sys.executable, str(script_file), "serve", "--bounded-output", "--port", str(port), "--editor-root", str(editor_root), "--vendor-cache", str(vendor_cache), "--log-dir", str(directory)]
|
|
1544
1881
|
if asset_file is not None:
|
|
1545
1882
|
command.extend(["--asset", str(asset_file)])
|
|
1546
1883
|
flags = 0
|
|
@@ -1549,26 +1886,66 @@ def start_detached(script_file: Path, port: int, editor_root: Path, asset_file:
|
|
|
1549
1886
|
flags = subprocess.CREATE_NEW_PROCESS_GROUP | subprocess.DETACHED_PROCESS # type: ignore[attr-defined]
|
|
1550
1887
|
kwargs["creationflags"] = flags
|
|
1551
1888
|
kwargs.pop("start_new_session", None)
|
|
1552
|
-
|
|
1553
|
-
|
|
1889
|
+
try:
|
|
1890
|
+
if log_file.is_file():
|
|
1891
|
+
# Migrate historical unbounded output using a bounded tail read.
|
|
1892
|
+
with log_file.open("rb") as previous:
|
|
1893
|
+
previous.seek(max(0, os.fstat(previous.fileno()).st_size - BOOTSTRAP_MAX_BYTES))
|
|
1894
|
+
# A tail offset can bisect a multibyte character in old logs.
|
|
1895
|
+
tail = previous.read(BOOTSTRAP_MAX_BYTES).decode("utf-8", errors="replace")
|
|
1896
|
+
log_file.with_suffix(".log.1").write_bytes(bounded_utf8(tail, BOOTSTRAP_MAX_BYTES))
|
|
1897
|
+
log = log_file.open("wb")
|
|
1898
|
+
except OSError:
|
|
1899
|
+
log_file = None
|
|
1900
|
+
log = None
|
|
1901
|
+
try:
|
|
1902
|
+
process = subprocess.Popen(
|
|
1903
|
+
command,
|
|
1904
|
+
stdout=log if log is not None else subprocess.DEVNULL,
|
|
1905
|
+
stderr=subprocess.STDOUT,
|
|
1906
|
+
close_fds=True,
|
|
1907
|
+
**kwargs,
|
|
1908
|
+
)
|
|
1909
|
+
finally:
|
|
1910
|
+
if log is not None:
|
|
1911
|
+
log.close()
|
|
1554
1912
|
return process.pid, log_file
|
|
1555
1913
|
|
|
1556
1914
|
|
|
1557
1915
|
def command_serve(args: argparse.Namespace, script_file: Path) -> int:
|
|
1558
|
-
logger = setup_logging(args.log_dir, args.port)
|
|
1916
|
+
logger, log_directory = setup_logging(args.log_dir, args.port)
|
|
1917
|
+
log_file = service_log_file(logger)
|
|
1918
|
+
logger.info("service_starting version=%s python=%s platform=%s port=%s executable=%s cwd=%s",
|
|
1919
|
+
SERVICE_VERSION, sys.version.split()[0], sys.platform, args.port, sys.executable, Path.cwd())
|
|
1559
1920
|
asset_file = Path(args.asset).expanduser().resolve(strict=True) if args.asset else default_asset_file(script_file)
|
|
1560
1921
|
editor_root = Path(args.editor_root).expanduser().resolve(strict=True)
|
|
1561
|
-
|
|
1562
|
-
|
|
1563
|
-
|
|
1564
|
-
|
|
1922
|
+
# Logs and cache are both disposable, so they follow the writable location the
|
|
1923
|
+
# sandbox really granted instead of the path the parent requested.
|
|
1924
|
+
failures: list[str] = []
|
|
1925
|
+
vendor_cache = pick_writable_dir(*vendor_candidates(args.vendor_cache, log_directory), failures=failures)
|
|
1926
|
+
for failure in failures:
|
|
1927
|
+
logger.warning("vendor_directory_rejected %s", failure)
|
|
1928
|
+
vendor_files = ensure_vendor_assets(vendor_cache)
|
|
1929
|
+
server = WorkbenchServer(("127.0.0.1", args.port), asset_file, editor_root, vendor_files, log_directory, vendor_cache, log_file)
|
|
1930
|
+
logger.info("service_ready port=%s editorRoot=%s logFile=%s vendorCache=%s", args.port, editor_root, log_file, vendor_cache)
|
|
1931
|
+
# The host surfaces these two paths in its diagnostics, so the panel shows the
|
|
1932
|
+
# directories actually in use rather than echoing the ones it asked for — the
|
|
1933
|
+
# requested ones may be exactly what the sandbox refused.
|
|
1934
|
+
print(json.dumps({
|
|
1935
|
+
"ok": True,
|
|
1936
|
+
"url": f"http://127.0.0.1:{args.port}",
|
|
1937
|
+
"editorRoot": str(editor_root),
|
|
1938
|
+
"logDir": str(log_directory),
|
|
1939
|
+
"logFile": str(log_file) if log_file is not None else None,
|
|
1940
|
+
"vendorCache": str(vendor_cache),
|
|
1941
|
+
}, ensure_ascii=False), flush=True)
|
|
1565
1942
|
try:
|
|
1566
1943
|
server.serve_forever(poll_interval=0.25)
|
|
1567
1944
|
except KeyboardInterrupt:
|
|
1568
1945
|
pass
|
|
1569
1946
|
finally:
|
|
1570
1947
|
server.server_close()
|
|
1571
|
-
logger.info("
|
|
1948
|
+
logger.info("service_stopped port=%s", args.port)
|
|
1572
1949
|
return 0
|
|
1573
1950
|
|
|
1574
1951
|
|
|
@@ -1600,7 +1977,8 @@ def command_open(args: argparse.Namespace, script_file: Path) -> int:
|
|
|
1600
1977
|
break
|
|
1601
1978
|
time.sleep(0.1)
|
|
1602
1979
|
else:
|
|
1603
|
-
|
|
1980
|
+
where = f",请查看日志:{log_file}" if log_file is not None else "(没有可写的日志目录,输出已丢弃)"
|
|
1981
|
+
raise WorkbenchError("SERVER_UNAVAILABLE", f"服务启动失败{where}", 500)
|
|
1604
1982
|
query = urllib.parse.urlencode({"file": str(target)})
|
|
1605
1983
|
print(json.dumps({
|
|
1606
1984
|
"ok": True,
|
|
@@ -1621,6 +1999,7 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
1621
1999
|
serve.add_argument("--asset", help="override the bundled workbench.html path")
|
|
1622
2000
|
serve.add_argument("--vendor-cache", default=str(default_vendor_cache()), help="directory for verified GrapesJS files")
|
|
1623
2001
|
serve.add_argument("--log-dir", default=str(default_log_dir()), help="directory for rotating service logs")
|
|
2002
|
+
serve.add_argument("--bounded-output", action="store_true", help=argparse.SUPPRESS)
|
|
1624
2003
|
|
|
1625
2004
|
open_command = subparsers.add_parser("open", help="start or reuse the service and print an editor URL")
|
|
1626
2005
|
open_command.add_argument("file")
|
|
@@ -1655,11 +2034,48 @@ def command_stop(args: argparse.Namespace) -> int:
|
|
|
1655
2034
|
raise WorkbenchError("SERVER_OUTDATED", f"端口 {args.port} 上的进程无法停止,请手动结束后重试。", 500)
|
|
1656
2035
|
|
|
1657
2036
|
|
|
2037
|
+
def service_log_file(logger: logging.Logger) -> Path | None:
|
|
2038
|
+
for handler in logger.handlers:
|
|
2039
|
+
if isinstance(handler, RotatingFileHandler):
|
|
2040
|
+
return Path(handler.baseFilename)
|
|
2041
|
+
return None
|
|
2042
|
+
|
|
2043
|
+
|
|
2044
|
+
def logging_status() -> dict[str, Any]:
|
|
2045
|
+
for handler in get_logger().handlers:
|
|
2046
|
+
if isinstance(handler, BoundedRotatingFileHandler):
|
|
2047
|
+
return {
|
|
2048
|
+
"file": handler.baseFilename, "error": handler.last_error,
|
|
2049
|
+
"droppedRecords": handler.dropped_records,
|
|
2050
|
+
"maxBytes": LOG_MAX_BYTES, "backups": LOG_BACKUP_COUNT,
|
|
2051
|
+
"recordMaxBytes": LOG_RECORD_MAX_BYTES,
|
|
2052
|
+
}
|
|
2053
|
+
return {"file": None, "error": "file logging unavailable"}
|
|
2054
|
+
|
|
2055
|
+
|
|
2056
|
+
def report_cli_error(command: str, code: str, caught: Exception, **details: Any) -> int:
|
|
2057
|
+
payload = {"ok": False, "error": code, "message": str(caught), **details}
|
|
2058
|
+
if command == "serve":
|
|
2059
|
+
logger = get_logger()
|
|
2060
|
+
log_file = service_log_file(logger)
|
|
2061
|
+
payload["logFile"] = str(log_file) if log_file is not None else None
|
|
2062
|
+
logger.exception("service_failed code=%s", code)
|
|
2063
|
+
if log_file is None or logging_status().get("error"):
|
|
2064
|
+
print(bounded_utf8(traceback.format_exc(), LOG_RECORD_MAX_BYTES).decode("utf-8"), file=sys.stderr)
|
|
2065
|
+
print(json.dumps(payload, ensure_ascii=False), file=sys.stderr, flush=True)
|
|
2066
|
+
return 1
|
|
2067
|
+
|
|
2068
|
+
|
|
1658
2069
|
def main() -> int:
|
|
2070
|
+
for stream in (sys.stdout, sys.stderr):
|
|
2071
|
+
if hasattr(stream, "reconfigure"):
|
|
2072
|
+
stream.reconfigure(encoding="utf-8", errors="backslashreplace")
|
|
1659
2073
|
if sys.version_info < (3, 9):
|
|
1660
2074
|
print(json.dumps({"ok": False, "error": "PYTHON_TOO_OLD", "message": "需要 Python 3.9 或更高版本。"}, ensure_ascii=False), file=sys.stderr)
|
|
1661
2075
|
return 2
|
|
1662
2076
|
args = build_parser().parse_args()
|
|
2077
|
+
if getattr(args, "bounded_output", False):
|
|
2078
|
+
sys.stdout = sys.stderr = BoundedOutput(sys.stdout.buffer)
|
|
1663
2079
|
script_file = Path(__file__).resolve()
|
|
1664
2080
|
try:
|
|
1665
2081
|
if args.command == "serve":
|
|
@@ -1668,19 +2084,21 @@ def main() -> int:
|
|
|
1668
2084
|
return command_open(args, script_file)
|
|
1669
2085
|
if args.command == "stop":
|
|
1670
2086
|
return command_stop(args)
|
|
1671
|
-
|
|
1672
|
-
|
|
2087
|
+
failures: list[str] = []
|
|
2088
|
+
payload = health(args.port, failures=failures)
|
|
2089
|
+
print(json.dumps(payload or {"ok": False, "error": "SERVER_UNAVAILABLE", "detail": failures}, ensure_ascii=False))
|
|
1673
2090
|
return 0 if payload else 1
|
|
1674
2091
|
except WorkbenchError as caught:
|
|
1675
|
-
|
|
1676
|
-
return 1
|
|
2092
|
+
return report_cli_error(args.command, caught.code, caught, **caught.extra)
|
|
1677
2093
|
except FileNotFoundError as caught:
|
|
1678
|
-
|
|
1679
|
-
return 1
|
|
2094
|
+
return report_cli_error(args.command, "FILE_NOT_FOUND", caught)
|
|
1680
2095
|
except OSError as caught:
|
|
1681
2096
|
code = "PORT_IN_USE" if getattr(caught, "errno", None) in {48, 98, 10048} else "SERVER_UNAVAILABLE"
|
|
1682
|
-
|
|
1683
|
-
|
|
2097
|
+
return report_cli_error(args.command, code, caught)
|
|
2098
|
+
except Exception as caught:
|
|
2099
|
+
if args.command != "serve":
|
|
2100
|
+
raise
|
|
2101
|
+
return report_cli_error(args.command, "SERVER_ERROR", caught)
|
|
1684
2102
|
|
|
1685
2103
|
|
|
1686
2104
|
if __name__ == "__main__":
|