@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.
@@ -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.1.0"
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 = 1_000_000
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 and return the module logger."""
149
- global _logger
150
- directory = Path(log_dir).expanduser()
151
- directory.mkdir(parents=True, exist_ok=True)
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
- return logger
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 = tempfile.mkstemp(prefix=f".{name}.", dir=cache_dir)
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
- pass
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: str | None = None
1235
+ temporary_name: Path | None = None
986
1236
  try:
987
- descriptor, temporary_name = tempfile.mkstemp(prefix=f".{target.name}.workbench-", suffix=".tmp", dir=target.parent)
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 = tempfile.mkstemp(prefix=f".{target.name}.workbench-", suffix=".tmp", dir=target.parent)
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__(self, address: tuple[str, int], asset_file: Path, editor_root: Path, vendor_files: dict[str, Path]) -> None:
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 log_message(self, format_string: str, *args: Any) -> None:
1265
- message = format_string % args
1266
- get_logger().info("%s %s", self.log_date_time_string(), message)
1267
- sys.stderr.write(f"{self.log_date_time_string()} {message}\n")
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("request failed: %s (%s)", caught.code, caught)
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("unhandled request error")
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
- return resolve_html_file(query.get("file", [""])[0])
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
- self.send_json(200, {
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
- payload = json.loads(response.read().decode("utf-8"))
1429
- return payload if payload.get("service") == SERVICE_NAME else None
1430
- except (OSError, ValueError, urllib.error.URLError):
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
- directory = Path(log_dir).expanduser()
1541
- directory.mkdir(parents=True, exist_ok=True)
1542
- log_file = directory / f"html-workbench-{port}.log"
1543
- command = [sys.executable, str(script_file), "serve", "--port", str(port), "--editor-root", str(editor_root), "--vendor-cache", str(vendor_cache), "--log-dir", str(directory)]
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
- with log_file.open("ab") as log:
1553
- process = subprocess.Popen(command, stdout=log, stderr=subprocess.STDOUT, close_fds=True, **kwargs)
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
- vendor_files = ensure_vendor_assets(Path(args.vendor_cache).expanduser())
1562
- server = WorkbenchServer(("127.0.0.1", args.port), asset_file, editor_root, vendor_files)
1563
- logger.info("service started: port=%s editorRoot=%s logDir=%s", args.port, editor_root, Path(args.log_dir).expanduser())
1564
- print(json.dumps({"ok": True, "url": f"http://127.0.0.1:{args.port}", "editorRoot": str(editor_root)}, ensure_ascii=False), flush=True)
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("service stopped: port=%s", args.port)
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
- raise WorkbenchError("SERVER_UNAVAILABLE", f"服务启动失败,请查看日志:{log_file}", 500)
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
- payload = health(args.port)
1672
- print(json.dumps(payload or {"ok": False, "error": "SERVER_UNAVAILABLE"}, ensure_ascii=False))
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
- print(json.dumps({"ok": False, "error": caught.code, "message": str(caught)}, ensure_ascii=False), file=sys.stderr)
1676
- return 1
2092
+ return report_cli_error(args.command, caught.code, caught, **caught.extra)
1677
2093
  except FileNotFoundError as caught:
1678
- print(json.dumps({"ok": False, "error": "FILE_NOT_FOUND", "message": str(caught)}, ensure_ascii=False), file=sys.stderr)
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
- print(json.dumps({"ok": False, "error": code, "message": str(caught)}, ensure_ascii=False), file=sys.stderr)
1683
- return 1
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__":