fix: 收敛 WebProbe 物理内存与资源上限
This commit is contained in:
@@ -0,0 +1,123 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { test } from "bun:test";
|
||||
|
||||
import { rootPath } from "./config";
|
||||
|
||||
const webObserveSource = readFileSync(rootPath("scripts/src/gc-remote-web-observe.py"), "utf8");
|
||||
const runnerSource = readFileSync(rootPath("scripts/src/gc-remote-runner.py"), "utf8");
|
||||
|
||||
function runPythonFixture(body: string): Record<string, unknown> {
|
||||
const fixture = [
|
||||
"import json, os, re, signal, time",
|
||||
"PROVIDER_ID = 'NC01'",
|
||||
"def safe_int(value, fallback=0):",
|
||||
" try: return int(value)",
|
||||
" except (TypeError, ValueError): return fallback",
|
||||
"def config_list(config, key, fallback):",
|
||||
" value = config.get(key, fallback)",
|
||||
" return value if isinstance(value, list) else fallback",
|
||||
"def config_int(config, key, fallback, minimum=None, maximum=None):",
|
||||
" value = config.get(key, fallback)",
|
||||
" return value if isinstance(value, int) and not isinstance(value, bool) else fallback",
|
||||
"def config_float(config, key, fallback, minimum=None, maximum=None): return float(config.get(key, fallback))",
|
||||
"def fmt_bytes(value): return str(value)",
|
||||
"def redact_command_preview(value): return str(value)[:200]",
|
||||
"def observe_run_record(path, stale_hours): return {}",
|
||||
webObserveSource,
|
||||
body,
|
||||
].join("\n");
|
||||
const result = spawnSync("python3", ["-"], { input: fixture, encoding: "utf8" });
|
||||
assert.equal(result.status, 0, result.stderr || result.stdout);
|
||||
return JSON.parse(result.stdout.trim()) as Record<string, unknown>;
|
||||
}
|
||||
|
||||
test("memory-pressure candidate fixtures protect active observers and classify only closed stale/orphan trees", () => {
|
||||
const result = runPythonFixture(String.raw`
|
||||
MEMORY_CONFIG = {
|
||||
"observeStateRoots": ["/tmp/observe"], "minimumReadableObserveRoots": 1,
|
||||
"observeRunDepth": 6, "observeScanLimit": 100, "staleRunMaxAgeHours": 1,
|
||||
"processScanLimit": 100, "processStaleMinAgeSeconds": 3600,
|
||||
"maxMemoryReclaimCandidates": 20, "terminationGraceSeconds": 1,
|
||||
"terminationPollMilliseconds": 50, "observerRootPatterns": ["observer-runner.mjs"],
|
||||
"browserRootPatterns": ["observer-runner.mjs", "cliDaemon.mjs"],
|
||||
"browserProcessPatterns": ["chromium", "/usr/bin/chromium"]
|
||||
}
|
||||
def row(pid, ppid, sid, start, age, command, rss=1024):
|
||||
return {"pid": pid, "ppid": ppid, "sid": sid, "startTicks": start, "ageSeconds": age, "rssBytes": rss, "commandLine": command, "commandPreview": command}
|
||||
processes = {
|
||||
100: row(100, 1, 100, 1000, 7200, "node observer-runner.mjs"),
|
||||
101: row(101, 100, 100, 1001, 7200, "/usr/bin/chromium --type=browser"),
|
||||
200: row(200, 1, 200, 2000, 7200, "node observer-runner.mjs"),
|
||||
300: row(300, 1, 300, 3000, 7200, "node cliDaemon.mjs"),
|
||||
301: row(301, 300, 300, 3001, 7200, "/usr/bin/chromium --type=browser"),
|
||||
}
|
||||
read_proc_process_table = lambda: processes
|
||||
observer_run_records = lambda: [
|
||||
{"pid": 100, "pidAlive": True, "staleLiveSignal": False},
|
||||
{"pid": 200, "pidAlive": True, "staleLiveSignal": True},
|
||||
]
|
||||
candidates = collect_memory_reclaim_candidates()
|
||||
print(json.dumps({"kinds": sorted(item["kind"] for item in candidates), "roots": sorted(item["rootPid"] for item in candidates), "diagnostics": memory_reclaim_scan_diagnostics()}))
|
||||
`);
|
||||
assert.deepEqual(result.kinds, ["browser-orphan-process-tree", "stale-web-observer-process-tree"]);
|
||||
assert.deepEqual(result.roots, [200, 300]);
|
||||
assert.equal((result.diagnostics as Record<string, unknown>).blocked, false);
|
||||
});
|
||||
|
||||
test("memory-pressure process scans fail closed on empty or truncated process tables", () => {
|
||||
const result = runPythonFixture(String.raw`
|
||||
MEMORY_CONFIG = {
|
||||
"observeStateRoots": ["/tmp/observe"], "minimumReadableObserveRoots": 1,
|
||||
"observeRunDepth": 6, "observeScanLimit": 100, "staleRunMaxAgeHours": 1,
|
||||
"processScanLimit": 100, "processStaleMinAgeSeconds": 3600,
|
||||
"maxMemoryReclaimCandidates": 20, "terminationGraceSeconds": 1,
|
||||
"terminationPollMilliseconds": 50, "observerRootPatterns": ["observer-runner.mjs"],
|
||||
"browserRootPatterns": ["cliDaemon.mjs"], "browserProcessPatterns": ["chromium"]
|
||||
}
|
||||
observer_run_records = lambda: []
|
||||
read_proc_process_table = lambda: {}
|
||||
empty = collect_memory_reclaim_candidates()
|
||||
empty_diag = memory_reclaim_scan_diagnostics()
|
||||
def truncated_scan():
|
||||
global _PROCESS_SCAN_TRUNCATED
|
||||
_PROCESS_SCAN_TRUNCATED = True
|
||||
return {1: {"pid": 1, "ppid": 0, "sid": 1, "startTicks": 1, "ageSeconds": 7200, "rssBytes": 1, "commandLine": "node cliDaemon.mjs", "commandPreview": "node cliDaemon.mjs"}}
|
||||
read_proc_process_table = truncated_scan
|
||||
truncated = collect_memory_reclaim_candidates()
|
||||
truncated_diag = memory_reclaim_scan_diagnostics()
|
||||
print(json.dumps({"emptyCount": len(empty), "emptyBlocked": empty_diag["blocked"], "emptyError": empty_diag["processScanError"], "truncatedCount": len(truncated), "truncatedBlocked": truncated_diag["blocked"], "truncatedFlag": truncated_diag["processScanTruncated"]}))
|
||||
`);
|
||||
assert.equal(result.emptyCount, 0);
|
||||
assert.equal(result.emptyBlocked, true);
|
||||
assert.equal(result.emptyError, "proc-process-table-empty");
|
||||
assert.equal(result.truncatedCount, 0);
|
||||
assert.equal(result.truncatedBlocked, true);
|
||||
assert.equal(result.truncatedFlag, true);
|
||||
});
|
||||
|
||||
test("post-TERM identity matching tolerates reparenting but never PID startTicks reuse", () => {
|
||||
const result = runPythonFixture(String.raw`
|
||||
identity = {"pid": 41, "ppid": 10, "sid": 10, "startTicks": 1234}
|
||||
reparented = {41: {"pid": 41, "ppid": 1, "sid": 41, "startTicks": 1234}}
|
||||
reused = {41: {"pid": 41, "ppid": 10, "sid": 10, "startTicks": 9999}}
|
||||
print(json.dumps({
|
||||
"preTermReparented": matching_live_identity(identity, reparented) is not None,
|
||||
"postTermReparented": matching_surviving_identity(identity, reparented) is not None,
|
||||
"postTermReused": matching_surviving_identity(identity, reused) is not None,
|
||||
}))
|
||||
`);
|
||||
assert.equal(result.preTermReparented, false);
|
||||
assert.equal(result.postTermReparented, true);
|
||||
assert.equal(result.postTermReused, false);
|
||||
});
|
||||
|
||||
test("memory-pressure async runner uses a parent-child barrier and locked worker state", () => {
|
||||
assert.match(runnerSource, /os\.pipe\(\)/u);
|
||||
assert.match(runnerSource, /os\.read\(read_descriptor/u);
|
||||
assert.match(runnerSource, /workerStartTicks/u);
|
||||
assert.match(runnerSource, /write_memory_job_state\(/u);
|
||||
assert.match(runnerSource, /workerAlive/u);
|
||||
assert.match(runnerSource, /worker-identity-unavailable|worker-exited-without-terminal-state/u);
|
||||
});
|
||||
+357
-10
@@ -1,8 +1,10 @@
|
||||
import base64
|
||||
import calendar
|
||||
import fcntl
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import signal
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
@@ -207,8 +209,41 @@ def job_paths(job_id):
|
||||
return {
|
||||
"state": os.path.join(REMOTE_GC_JOB_DIR, "%s.json" % job_id),
|
||||
"log": os.path.join(REMOTE_GC_JOB_DIR, "%s.log" % job_id),
|
||||
"lock": os.path.join(REMOTE_GC_JOB_DIR, "%s.lock" % job_id),
|
||||
}
|
||||
|
||||
def process_identity(pid):
|
||||
try:
|
||||
with open("/proc/%s/stat" % int(pid), "r", encoding="utf-8") as handle:
|
||||
text = handle.read().strip()
|
||||
close = text.rfind(")")
|
||||
fields = text[close + 2:].split() if close >= 0 else []
|
||||
return {"state": fields[0], "startTicks": int(fields[19])} if len(fields) >= 20 else None
|
||||
except (OSError, ValueError, IndexError):
|
||||
return None
|
||||
|
||||
def process_start_ticks(pid):
|
||||
identity = process_identity(pid)
|
||||
return identity.get("startTicks") if identity else None
|
||||
|
||||
def acquire_job_lock(paths):
|
||||
descriptor = os.open(paths["lock"], os.O_CREAT | os.O_RDWR, 0o600)
|
||||
fcntl.flock(descriptor, fcntl.LOCK_EX)
|
||||
return descriptor
|
||||
|
||||
def release_job_lock(descriptor):
|
||||
try:
|
||||
fcntl.flock(descriptor, fcntl.LOCK_UN)
|
||||
finally:
|
||||
os.close(descriptor)
|
||||
|
||||
def write_memory_job_state(paths, payload):
|
||||
descriptor = acquire_job_lock(paths)
|
||||
try:
|
||||
write_json_atomic(paths["state"], payload)
|
||||
finally:
|
||||
release_job_lock(descriptor)
|
||||
|
||||
def status_command(job_id):
|
||||
return "bun scripts/cli.ts gc remote %s status --job-id %s" % (PROVIDER_ID, job_id)
|
||||
|
||||
@@ -326,9 +361,31 @@ def remote_gc_job_status():
|
||||
"statePath": paths["state"],
|
||||
"logTail": read_file_tail(paths["log"]),
|
||||
}
|
||||
descriptor = None
|
||||
try:
|
||||
descriptor = acquire_job_lock(paths)
|
||||
with open(paths["state"], "r", encoding="utf-8") as handle:
|
||||
payload = json.load(handle)
|
||||
if payload.get("scope") == "memory-pressure-only" and payload.get("status") == "running":
|
||||
worker_pid = safe_int(payload.get("workerPid"))
|
||||
expected_start_ticks = safe_int(payload.get("workerStartTicks"))
|
||||
actual_identity = process_identity(worker_pid) if worker_pid > 0 else None
|
||||
actual_start_ticks = actual_identity.get("startTicks") if actual_identity else None
|
||||
worker_alive = actual_identity is not None and actual_identity.get("state") not in set(["Z", "X"]) \
|
||||
and actual_start_ticks == expected_start_ticks
|
||||
if not worker_alive:
|
||||
payload.update({
|
||||
"ok": False,
|
||||
"status": "failed",
|
||||
"outcome": "memory-reclaim-worker-exited-without-terminal-state",
|
||||
"workerAlive": False,
|
||||
"workerObservedState": actual_identity.get("state") if actual_identity else None,
|
||||
"workerObservedStartTicks": actual_start_ticks,
|
||||
"finishedAt": now_iso(),
|
||||
})
|
||||
write_json_atomic(paths["state"], payload)
|
||||
else:
|
||||
payload["workerAlive"] = True
|
||||
except Exception as exc:
|
||||
return {
|
||||
"ok": False,
|
||||
@@ -338,6 +395,9 @@ def remote_gc_job_status():
|
||||
"statePath": paths["state"],
|
||||
"logTail": read_file_tail(paths["log"]),
|
||||
}
|
||||
finally:
|
||||
if descriptor is not None:
|
||||
release_job_lock(descriptor)
|
||||
payload["logTail"] = read_file_tail(paths["log"])
|
||||
return payload
|
||||
|
||||
@@ -634,6 +694,14 @@ def observe_run_record(path, stale_hours):
|
||||
pass
|
||||
if pid is not None:
|
||||
break
|
||||
live_freshness_timestamp = None
|
||||
for source in [heartbeat, manifest]:
|
||||
for key in ["updatedAt", "completedAt", "finishedAt", "stoppedAt"]:
|
||||
live_freshness_timestamp = iso_or_epoch_to_epoch(source.get(key))
|
||||
if live_freshness_timestamp is not None:
|
||||
break
|
||||
if live_freshness_timestamp is not None:
|
||||
break
|
||||
timestamp = None
|
||||
for source in [heartbeat, manifest]:
|
||||
for key in ["updatedAt", "completedAt", "finishedAt", "stoppedAt", "startedAt", "createdAt"]:
|
||||
@@ -648,7 +716,8 @@ def observe_run_record(path, stale_hours):
|
||||
status = heartbeat.get("status") or manifest.get("status") or manifest.get("state")
|
||||
alive = pid_alive(pid)
|
||||
terminal = str(status or "").lower() in set(["done", "completed", "complete", "failed", "blocked", "timeout", "timed-out", "stopped", "exited"])
|
||||
stale_signal = (not alive) and age_hours >= float(stale_hours) and (terminal or status is None)
|
||||
age_stale = age_hours >= float(stale_hours)
|
||||
stale_signal = (not alive) and age_stale and (terminal or status is None)
|
||||
return {
|
||||
"id": os.path.basename(path),
|
||||
"path": path,
|
||||
@@ -658,6 +727,9 @@ def observe_run_record(path, stale_hours):
|
||||
"ageHours": round(age_hours, 2),
|
||||
"timestampBasis": "manifest-or-heartbeat" if heartbeat or manifest else "directory-mtime-fallback",
|
||||
"staleSignal": stale_signal,
|
||||
"staleLiveSignal": alive and age_stale and live_freshness_timestamp is not None,
|
||||
"activeSignal": alive and not (age_stale and live_freshness_timestamp is not None),
|
||||
"liveStaleEvidence": "heartbeat-or-manifest-update" if live_freshness_timestamp is not None else None,
|
||||
"classification": "review-only",
|
||||
}
|
||||
|
||||
@@ -673,6 +745,7 @@ def collect_web_observe_summary():
|
||||
}
|
||||
root_rows = []
|
||||
stale_rows = []
|
||||
stale_live_rows = []
|
||||
active_rows = []
|
||||
run_count = 0
|
||||
total_bytes = 0
|
||||
@@ -691,22 +764,18 @@ def collect_web_observe_summary():
|
||||
"activeSignalCount": 0,
|
||||
}
|
||||
if exists:
|
||||
try:
|
||||
children = [os.path.join(root, name) for name in os.listdir(root)]
|
||||
except OSError:
|
||||
children = []
|
||||
for child in children:
|
||||
if not os.path.isdir(child):
|
||||
continue
|
||||
for child in configured_observe_runs(root):
|
||||
try:
|
||||
record = observe_run_record(child, stale_hours)
|
||||
except OSError:
|
||||
continue
|
||||
row["runCount"] += 1
|
||||
run_count += 1
|
||||
if record.get("pidAlive"):
|
||||
if record.get("activeSignal"):
|
||||
row["activeSignalCount"] += 1
|
||||
active_rows.append(record)
|
||||
if record.get("staleLiveSignal"):
|
||||
stale_live_rows.append(record)
|
||||
if record.get("staleSignal"):
|
||||
row["staleSignalCount"] += 1
|
||||
stale_rows.append(record)
|
||||
@@ -723,9 +792,11 @@ def collect_web_observe_summary():
|
||||
"runCount": run_count,
|
||||
"activeSignalCount": len(active_rows),
|
||||
"staleSignalCount": len(stale_rows),
|
||||
"staleLiveSignalCount": len(stale_live_rows),
|
||||
"roots": root_rows,
|
||||
"activeSignals": active_rows[:int(OPTIONS.get("limit") or 50)],
|
||||
"staleSignals": stale_rows[:int(OPTIONS.get("limit") or 50)],
|
||||
"staleLiveSignals": stale_live_rows[:int(OPTIONS.get("limit") or 50)],
|
||||
"policy": "analysis-only; active or stale observe runs must be stopped/retained through controlled observer lifecycle commands, not raw process kill or directory deletion",
|
||||
}
|
||||
|
||||
@@ -993,6 +1064,8 @@ def collect_protected():
|
||||
return result
|
||||
|
||||
def collect_candidates(observed_at):
|
||||
if bool(OPTIONS.get("memoryPressureOnly")):
|
||||
return collect_memory_reclaim_candidates()
|
||||
candidates = []
|
||||
if OPTIONS.get("journal", True):
|
||||
usage = command(["journalctl", "--disk-usage"], 5)
|
||||
@@ -1340,6 +1413,29 @@ def summarize(candidates, returned, disk=None):
|
||||
"target": target_assessment(disk, total),
|
||||
}
|
||||
|
||||
def summarize_memory_candidates(candidates, returned):
|
||||
total_memory = sum(safe_int(item.get("expectedMemoryRssBytes")) for item in candidates)
|
||||
returned_memory = sum(safe_int(item.get("expectedMemoryRssBytes")) for item in returned)
|
||||
by_kind = {}
|
||||
for item in candidates:
|
||||
kind = item.get("kind") or "unknown"
|
||||
bucket = by_kind.setdefault(kind, {"count": 0, "expectedMemoryRssBytes": 0})
|
||||
bucket["count"] += 1
|
||||
bucket["expectedMemoryRssBytes"] += safe_int(item.get("expectedMemoryRssBytes"))
|
||||
bucket["expectedMemoryRss"] = fmt_bytes(bucket["expectedMemoryRssBytes"])
|
||||
return {
|
||||
"scope": "memory-pressure-only",
|
||||
"candidateCount": len(candidates),
|
||||
"returnedCandidateCount": len(returned),
|
||||
"expectedMemoryRssBytes": total_memory,
|
||||
"expectedMemoryRss": fmt_bytes(total_memory),
|
||||
"returnedExpectedMemoryRssBytes": returned_memory,
|
||||
"returnedExpectedMemoryRss": fmt_bytes(returned_memory),
|
||||
"estimatedDiskBytes": 0,
|
||||
"outcome": "memory-reclaim-candidates" if candidates else "no-memory-reclaim",
|
||||
"byKind": by_kind,
|
||||
}
|
||||
|
||||
def assert_tmp_candidate(path):
|
||||
resolved = os.path.abspath(path)
|
||||
if not resolved.startswith("/tmp/"):
|
||||
@@ -1426,6 +1522,8 @@ def execute(candidate):
|
||||
before = du_size(path, 15) or path_size(path)
|
||||
shutil.rmtree(path, ignore_errors=True)
|
||||
return {"reclaimedBytes": before, "stale": record}
|
||||
if kind in set(["browser-orphan-process-tree", "stale-web-observer-process-tree"]):
|
||||
return execute_memory_process_candidate(candidate)
|
||||
if kind == "k3s-cri-image-prune":
|
||||
return execute_k3s_image_cache_prune()
|
||||
if kind == "host-containerd-cache-prune":
|
||||
@@ -1476,6 +1574,13 @@ def returned_results(results):
|
||||
"path",
|
||||
"status",
|
||||
"estimatedReclaimBytes",
|
||||
"estimatedDiskBytes",
|
||||
"expectedMemoryRssBytes",
|
||||
"reclaimedMemoryBytes",
|
||||
"processCount",
|
||||
"termSignalCount",
|
||||
"killSignalCount",
|
||||
"remainingIdentityCount",
|
||||
"reclaimedBytes",
|
||||
"orphanCount",
|
||||
"selectedOrphanCount",
|
||||
@@ -1494,6 +1599,38 @@ def returned_results(results):
|
||||
return [compact_result(item) for item in (failed + started + succeeded)[:int(OPTIONS.get("resultLimit") or 50)]]
|
||||
|
||||
def plan_payload(observed_at, preflight, protected, candidates, visible):
|
||||
if bool(OPTIONS.get("memoryPressureOnly")):
|
||||
scan = memory_reclaim_scan_diagnostics()
|
||||
summary = summarize_memory_candidates(candidates, visible)
|
||||
if scan.get("blocked"):
|
||||
summary["outcome"] = "memory-reclaim-scan-blocked"
|
||||
return {
|
||||
"ok": scan.get("ok") is True,
|
||||
"action": "gc remote plan",
|
||||
"providerId": PROVIDER_ID,
|
||||
"scope": "memory-pressure-only",
|
||||
"dryRun": True,
|
||||
"mutation": False,
|
||||
"observedAt": observed_at,
|
||||
"options": OPTIONS,
|
||||
"memoryBefore": collect_memory_snapshot(),
|
||||
"summary": summary,
|
||||
"candidateScan": scan,
|
||||
"candidates": visible,
|
||||
"protected": {
|
||||
"activeObserverIntersection": "excluded" if scan.get("ok") is True else "unknown-protected-all",
|
||||
"processIdentity": "pre-term:pid-ppid-sid-startTicks;post-term:pid-startTicks",
|
||||
"treeClosure": "unique-root-ppid-sid-closed",
|
||||
},
|
||||
"policy": {
|
||||
"requiresRunConfirm": True,
|
||||
"planCommand": "bun scripts/cli.ts gc remote %s plan --memory-pressure-only" % PROVIDER_ID,
|
||||
"runCommand": "bun scripts/cli.ts gc remote %s run --confirm --memory-pressure-only" % PROVIDER_ID,
|
||||
"candidateKinds": ["browser-orphan-process-tree", "stale-web-observer-process-tree"],
|
||||
"neverTouches": ["disk candidates", "apt cache", "journal", "tmp", "container images", "active observer process trees"],
|
||||
"termination": "SIGTERM, bounded wait, identity reclose, SIGKILL only same-tree residual",
|
||||
},
|
||||
}
|
||||
disk = df_snapshot()
|
||||
ci_storage = ci_storage_snapshot()
|
||||
memory_pressure = collect_memory_pressure()
|
||||
@@ -1819,6 +1956,205 @@ def remote_policy_install_payload(observed_at):
|
||||
},
|
||||
}
|
||||
|
||||
def update_memory_pressure_job_progress(paths, completed_count, candidate_count, worker_pid, worker_start_ticks):
|
||||
descriptor = acquire_job_lock(paths)
|
||||
try:
|
||||
with open(paths["state"], "r", encoding="utf-8") as handle:
|
||||
payload = json.load(handle)
|
||||
if payload.get("status") != "running" or safe_int(payload.get("workerPid")) != worker_pid \
|
||||
or safe_int(payload.get("workerStartTicks")) != worker_start_ticks:
|
||||
raise RuntimeError("memory pressure job state no longer belongs to this worker")
|
||||
payload.update({
|
||||
"workerHeartbeatAt": now_iso(),
|
||||
"progress": {"completedCount": completed_count, "candidateCount": candidate_count},
|
||||
})
|
||||
write_json_atomic(paths["state"], payload)
|
||||
finally:
|
||||
release_job_lock(descriptor)
|
||||
|
||||
def finish_memory_pressure_job(job_id, candidates, observed_at, worker_pid, worker_start_ticks):
|
||||
paths = job_paths(job_id)
|
||||
memory_before = collect_memory_snapshot()
|
||||
results = []
|
||||
update_memory_pressure_job_progress(paths, 0, len(candidates), worker_pid, worker_start_ticks)
|
||||
for index, candidate in enumerate(candidates):
|
||||
try:
|
||||
execution = execute(candidate)
|
||||
item = dict(candidate)
|
||||
item.update({
|
||||
"status": execution.get("status") or "succeeded",
|
||||
"reclaimedBytes": 0,
|
||||
"reclaimedMemoryBytes": execution.get("reclaimedMemoryBytes"),
|
||||
"termSignalCount": execution.get("termSignalCount"),
|
||||
"killSignalCount": execution.get("killSignalCount"),
|
||||
"remainingIdentityCount": execution.get("remainingIdentityCount"),
|
||||
"remainingIdentities": execution.get("remainingIdentities"),
|
||||
"lateAllowlistedIdentityCount": execution.get("lateAllowlistedIdentityCount"),
|
||||
"memoryBefore": execution.get("memoryBefore"),
|
||||
"memoryAfter": execution.get("memoryAfter"),
|
||||
})
|
||||
results.append(item)
|
||||
except Exception as exc:
|
||||
item = dict(candidate)
|
||||
item.update({"status": "failed", "reclaimedBytes": 0, "reclaimedMemoryBytes": None, "error": str(exc)})
|
||||
results.append(item)
|
||||
update_memory_pressure_job_progress(paths, index + 1, len(candidates), worker_pid, worker_start_ticks)
|
||||
memory_after = collect_memory_snapshot()
|
||||
failed = [item for item in results if item.get("status") == "failed"]
|
||||
returned = returned_results(results)
|
||||
summary = summarize_memory_candidates(candidates, returned)
|
||||
summary.update({
|
||||
"attemptedCount": len(results),
|
||||
"succeededCount": len([item for item in results if item.get("status") == "succeeded"]),
|
||||
"failedCount": len(failed),
|
||||
"actualMemoryAvailableDeltaBytes": (
|
||||
safe_int((memory_after.get("memory") or {}).get("availableBytes"))
|
||||
- safe_int((memory_before.get("memory") or {}).get("availableBytes"))
|
||||
) if memory_before.get("ok") and memory_after.get("ok") else None,
|
||||
})
|
||||
payload = {
|
||||
"ok": len(failed) == 0,
|
||||
"action": "gc remote run",
|
||||
"providerId": PROVIDER_ID,
|
||||
"scope": "memory-pressure-only",
|
||||
"status": "succeeded" if len(failed) == 0 else "failed",
|
||||
"jobId": job_id,
|
||||
"statusCommand": status_command(job_id),
|
||||
"statePath": paths["state"],
|
||||
"dryRun": False,
|
||||
"mutation": len(results) > 0,
|
||||
"observedAt": observed_at,
|
||||
"startedAt": observed_at,
|
||||
"finishedAt": now_iso(),
|
||||
"workerPid": worker_pid,
|
||||
"workerStartTicks": worker_start_ticks,
|
||||
"workerAlive": False,
|
||||
"workerCompleted": True,
|
||||
"workerHeartbeatAt": now_iso(),
|
||||
"progress": {"completedCount": len(candidates), "candidateCount": len(candidates)},
|
||||
"memoryBefore": memory_before,
|
||||
"memoryAfter": memory_after,
|
||||
"summary": summary,
|
||||
"results": returned,
|
||||
}
|
||||
write_memory_job_state(paths, payload)
|
||||
|
||||
def start_memory_pressure_job(observed_at, candidates):
|
||||
job_id = "%s-memory-pressure-%s-%s" % (
|
||||
re.sub(r"[^A-Za-z0-9._-]+", "-", PROVIDER_ID.lower()).strip("-") or "provider",
|
||||
int(time.time()),
|
||||
os.getpid(),
|
||||
)
|
||||
paths = job_paths(job_id)
|
||||
summary = summarize_memory_candidates(candidates, candidates)
|
||||
scan = memory_reclaim_scan_diagnostics()
|
||||
base = {
|
||||
"ok": scan.get("ok") is True,
|
||||
"action": "gc remote run",
|
||||
"providerId": PROVIDER_ID,
|
||||
"scope": "memory-pressure-only",
|
||||
"jobId": job_id,
|
||||
"statusCommand": status_command(job_id),
|
||||
"statePath": paths["state"],
|
||||
"dryRun": False,
|
||||
"mutation": len(candidates) > 0,
|
||||
"observedAt": observed_at,
|
||||
"startedAt": observed_at,
|
||||
"summary": summary,
|
||||
"candidateScan": scan,
|
||||
}
|
||||
if scan.get("blocked"):
|
||||
terminal = dict(base)
|
||||
terminal.update({
|
||||
"status": "blocked",
|
||||
"outcome": "memory-reclaim-scan-blocked",
|
||||
"mutation": False,
|
||||
"finishedAt": now_iso(),
|
||||
"results": [],
|
||||
})
|
||||
write_json_atomic(paths["state"], terminal)
|
||||
return terminal
|
||||
if not candidates:
|
||||
terminal = dict(base)
|
||||
terminal.update({
|
||||
"status": "blocked",
|
||||
"outcome": "no-memory-reclaim-candidates",
|
||||
"mutation": False,
|
||||
"finishedAt": now_iso(),
|
||||
"results": [],
|
||||
})
|
||||
write_json_atomic(paths["state"], terminal)
|
||||
return terminal
|
||||
read_descriptor, write_descriptor = os.pipe()
|
||||
try:
|
||||
child_pid = os.fork()
|
||||
except OSError as exc:
|
||||
os.close(read_descriptor)
|
||||
os.close(write_descriptor)
|
||||
failed = dict(base)
|
||||
failed.update({"ok": False, "status": "failed", "outcome": "memory-reclaim-fork-failed", "error": str(exc), "finishedAt": now_iso()})
|
||||
write_memory_job_state(paths, failed)
|
||||
return failed
|
||||
if child_pid == 0:
|
||||
try:
|
||||
os.close(write_descriptor)
|
||||
released = os.read(read_descriptor, 1)
|
||||
os.close(read_descriptor)
|
||||
if released != b"1":
|
||||
os._exit(1)
|
||||
os.setsid()
|
||||
with open("/dev/null", "rb", buffering=0) as stdin_handle, open(paths["log"], "ab", buffering=0) as log_handle:
|
||||
os.dup2(stdin_handle.fileno(), 0)
|
||||
os.dup2(log_handle.fileno(), 1)
|
||||
os.dup2(log_handle.fileno(), 2)
|
||||
finish_memory_pressure_job(job_id, candidates, observed_at, os.getpid(), process_start_ticks(os.getpid()))
|
||||
except Exception as exc:
|
||||
failed = dict(base)
|
||||
failed.update({"ok": False, "status": "failed", "outcome": "memory-reclaim-worker-failed", "workerPid": os.getpid(), "workerStartTicks": process_start_ticks(os.getpid()), "error": str(exc), "finishedAt": now_iso()})
|
||||
try:
|
||||
write_memory_job_state(paths, failed)
|
||||
except Exception:
|
||||
pass
|
||||
finally:
|
||||
os._exit(0)
|
||||
os.close(read_descriptor)
|
||||
worker_start_ticks = process_start_ticks(child_pid)
|
||||
if worker_start_ticks is None:
|
||||
os.close(write_descriptor)
|
||||
try:
|
||||
os.kill(child_pid, signal.SIGKILL)
|
||||
except ProcessLookupError:
|
||||
pass
|
||||
failed = dict(base)
|
||||
failed.update({"ok": False, "status": "failed", "outcome": "memory-reclaim-worker-identity-unavailable", "workerPid": child_pid, "finishedAt": now_iso()})
|
||||
write_memory_job_state(paths, failed)
|
||||
return failed
|
||||
running = dict(base)
|
||||
running.update({
|
||||
"status": "running",
|
||||
"outcome": "memory-reclaim-started",
|
||||
"workerPid": child_pid,
|
||||
"workerStartTicks": worker_start_ticks,
|
||||
"workerAlive": True,
|
||||
"workerHeartbeatAt": now_iso(),
|
||||
"progress": {"completedCount": 0, "candidateCount": len(candidates)},
|
||||
})
|
||||
try:
|
||||
write_memory_job_state(paths, running)
|
||||
os.write(write_descriptor, b"1")
|
||||
except Exception as exc:
|
||||
try:
|
||||
os.kill(child_pid, signal.SIGKILL)
|
||||
except ProcessLookupError:
|
||||
pass
|
||||
failed = dict(base)
|
||||
failed.update({"ok": False, "status": "failed", "outcome": "memory-reclaim-worker-release-failed", "workerPid": child_pid, "workerStartTicks": worker_start_ticks, "error": str(exc), "finishedAt": now_iso()})
|
||||
write_memory_job_state(paths, failed)
|
||||
return failed
|
||||
finally:
|
||||
os.close(write_descriptor)
|
||||
return running
|
||||
|
||||
def main():
|
||||
observed_at = now_iso()
|
||||
if ACTION == "memory":
|
||||
@@ -1838,6 +2174,17 @@ def main():
|
||||
"valuesRedacted": True,
|
||||
}, persist_large=False)
|
||||
return 0
|
||||
if ACTION == "status" and job_id_or_none():
|
||||
emit_json(remote_gc_job_status(), persist_large=False)
|
||||
return 0
|
||||
if bool(OPTIONS.get("memoryPressureOnly")) and ACTION in set(["plan", "run"]):
|
||||
candidates = collect_memory_reclaim_candidates()
|
||||
visible = visible_items(candidates)
|
||||
if ACTION == "plan":
|
||||
emit_json(plan_payload(observed_at, {"skipped": True, "reason": "memory-pressure-only"}, [], candidates, visible), persist_large=True)
|
||||
else:
|
||||
emit_json(start_memory_pressure_job(observed_at, visible), persist_large=True)
|
||||
return 0
|
||||
preflight = cluster_preflight()
|
||||
if ACTION == "policy-plan":
|
||||
emit_json(remote_policy_plan_payload(observed_at), persist_large=False)
|
||||
@@ -1897,7 +2244,7 @@ def main():
|
||||
})
|
||||
emit_json(snapshot, persist_large=True)
|
||||
return 0
|
||||
protected = collect_protected()
|
||||
protected = [] if bool(OPTIONS.get("memoryPressureOnly")) else collect_protected()
|
||||
candidates = collect_candidates(observed_at)
|
||||
visible = visible_items(candidates)
|
||||
if ACTION == "plan":
|
||||
|
||||
@@ -1,14 +1,63 @@
|
||||
_OBSERVE_RUN_CACHE = {}
|
||||
_OBSERVE_SCAN_TRUNCATED = False
|
||||
_PROCESS_SCAN_TRUNCATED = False
|
||||
_OBSERVE_SCAN_ERROR = None
|
||||
_PROCESS_SCAN_ERROR = None
|
||||
_MEMORY_RECLAIM_CONFIG_ERROR = None
|
||||
_OBSERVE_ROOT_DIAGNOSTICS = {"configured": [], "readable": [], "missing": []}
|
||||
|
||||
def configured_observe_roots():
|
||||
roots = config_list(MEMORY_CONFIG, "observeStateRoots", config_list(MEMORY_CONFIG, "webObserveRoots", []))
|
||||
return [os.path.abspath(item) for item in roots if isinstance(item, str) and item.startswith("/")]
|
||||
|
||||
def is_direct_observe_run_path(path):
|
||||
def configured_observe_runs(root):
|
||||
global _OBSERVE_SCAN_TRUNCATED, _OBSERVE_SCAN_ERROR
|
||||
resolved_root = os.path.abspath(root)
|
||||
if resolved_root in _OBSERVE_RUN_CACHE:
|
||||
return list(_OBSERVE_RUN_CACHE[resolved_root])
|
||||
max_depth = config_int(MEMORY_CONFIG, "observeRunDepth", 1, minimum=1, maximum=12)
|
||||
scan_limit = config_int(MEMORY_CONFIG, "observeScanLimit", 1000, minimum=1, maximum=100000)
|
||||
markers = set(["manifest.json", "heartbeat.json", "pid", "observer.pid", "runner.pid"])
|
||||
runs = []
|
||||
scanned = 0
|
||||
if not os.path.isdir(resolved_root) or os.path.islink(resolved_root):
|
||||
return runs
|
||||
def record_walk_error(error):
|
||||
global _OBSERVE_SCAN_ERROR
|
||||
_OBSERVE_SCAN_ERROR = "observe-state-walk-failed:%s" % type(error).__name__
|
||||
|
||||
for current, directories, files in os.walk(resolved_root, topdown=True, followlinks=False, onerror=record_walk_error):
|
||||
relative = os.path.relpath(current, resolved_root)
|
||||
depth = 0 if relative == "." else relative.count(os.sep) + 1
|
||||
directories[:] = sorted(name for name in directories if not os.path.islink(os.path.join(current, name)))
|
||||
if depth >= max_depth:
|
||||
directories[:] = []
|
||||
if depth == 0:
|
||||
continue
|
||||
scanned += 1
|
||||
if scanned > scan_limit:
|
||||
_OBSERVE_SCAN_TRUNCATED = True
|
||||
break
|
||||
if markers.intersection(files):
|
||||
runs.append(os.path.abspath(current))
|
||||
directories[:] = []
|
||||
if not _OBSERVE_SCAN_TRUNCATED:
|
||||
_OBSERVE_RUN_CACHE[resolved_root] = list(runs)
|
||||
return runs
|
||||
|
||||
def is_observe_run_path(path):
|
||||
resolved = os.path.abspath(path)
|
||||
for root in configured_observe_roots():
|
||||
if os.path.dirname(resolved) == root and resolved.startswith(root.rstrip("/") + "/"):
|
||||
prefix = root.rstrip("/") + "/"
|
||||
if resolved.startswith(prefix) and resolved in set(configured_observe_runs(root)):
|
||||
return True
|
||||
return False
|
||||
|
||||
def is_direct_observe_run_path(path):
|
||||
# Kept as a compatibility name for generic artifact retention. Validation is
|
||||
# now marker/depth based because the configured roots contain dated trees.
|
||||
return is_observe_run_path(path)
|
||||
|
||||
def path_has_open_fd(path):
|
||||
resolved = os.path.realpath(path)
|
||||
prefix = resolved.rstrip("/") + "/"
|
||||
@@ -42,8 +91,8 @@ def path_has_open_fd(path):
|
||||
|
||||
def assert_web_observe_candidate(path):
|
||||
resolved = os.path.abspath(path)
|
||||
if not is_direct_observe_run_path(resolved):
|
||||
raise RuntimeError("refusing to remove web-observe path outside configured direct run roots: %s" % path)
|
||||
if not is_observe_run_path(resolved):
|
||||
raise RuntimeError("refusing to remove web-observe path outside configured marker/depth run roots: %s" % path)
|
||||
if os.path.islink(resolved) or not os.path.isdir(resolved):
|
||||
raise RuntimeError("refusing to remove non-directory or symlink web-observe path: %s" % path)
|
||||
stale_hours = config_float(MEMORY_CONFIG, "staleRunMaxAgeHours", 6.0, minimum=0.0)
|
||||
@@ -55,3 +104,423 @@ def assert_web_observe_candidate(path):
|
||||
if path_has_open_fd(resolved):
|
||||
raise RuntimeError("refusing to remove web-observe run with open fd/cwd reference: %s" % path)
|
||||
return record
|
||||
|
||||
def read_proc_process_table():
|
||||
global _PROCESS_SCAN_TRUNCATED, _PROCESS_SCAN_ERROR
|
||||
scan_limit = config_int(MEMORY_CONFIG, "processScanLimit", 16384, minimum=1, maximum=1000000)
|
||||
try:
|
||||
clock_ticks = int(os.sysconf("SC_CLK_TCK"))
|
||||
uptime = float(open("/proc/uptime", "r", encoding="utf-8").read().split()[0])
|
||||
page_size = int(os.sysconf("SC_PAGE_SIZE"))
|
||||
pid_names = sorted((name for name in os.listdir("/proc") if name.isdigit()), key=int)
|
||||
if len(pid_names) > scan_limit:
|
||||
_PROCESS_SCAN_TRUNCATED = True
|
||||
return {}
|
||||
except Exception as exc:
|
||||
_PROCESS_SCAN_ERROR = "proc-process-table-read-failed:%s" % type(exc).__name__
|
||||
return {}
|
||||
rows = {}
|
||||
stat_read_failures = 0
|
||||
for pid_name in pid_names:
|
||||
base = os.path.join("/proc", pid_name)
|
||||
try:
|
||||
with open(os.path.join(base, "stat"), "r", encoding="utf-8") as handle:
|
||||
stat_text = handle.read().strip()
|
||||
close = stat_text.rfind(")")
|
||||
if close < 0:
|
||||
continue
|
||||
fields = stat_text[close + 2:].split()
|
||||
if len(fields) < 22:
|
||||
continue
|
||||
pid = int(pid_name)
|
||||
ppid = int(fields[1])
|
||||
sid = int(fields[3])
|
||||
start_ticks = int(fields[19])
|
||||
try:
|
||||
with open(os.path.join(base, "cmdline"), "rb") as handle:
|
||||
command_line = handle.read(65536).replace(b"\0", b" ").decode("utf-8", errors="replace").strip()
|
||||
except OSError:
|
||||
command_line = ""
|
||||
try:
|
||||
with open(os.path.join(base, "statm"), "r", encoding="utf-8") as handle:
|
||||
rss_bytes = safe_int(handle.read().split()[1]) * page_size
|
||||
except Exception:
|
||||
rss_bytes = 0
|
||||
rows[pid] = {
|
||||
"pid": pid,
|
||||
"ppid": ppid,
|
||||
"sid": sid,
|
||||
"startTicks": start_ticks,
|
||||
"ageSeconds": max(0, int(uptime - (float(start_ticks) / float(clock_ticks)))),
|
||||
"rssBytes": rss_bytes,
|
||||
"commandPreview": redact_command_preview(command_line),
|
||||
"commandLine": command_line,
|
||||
}
|
||||
except (OSError, ValueError, IndexError):
|
||||
stat_read_failures += 1
|
||||
continue
|
||||
if not rows:
|
||||
_PROCESS_SCAN_ERROR = "proc-process-table-empty"
|
||||
elif stat_read_failures > max(32, len(pid_names) // 2):
|
||||
_PROCESS_SCAN_ERROR = "proc-process-stat-read-failure-threshold:%s-of-%s" % (stat_read_failures, len(pid_names))
|
||||
return rows
|
||||
|
||||
def validate_observe_run_markers(path):
|
||||
for name in ["heartbeat.json", "manifest.json"]:
|
||||
marker = os.path.join(path, name)
|
||||
if not os.path.lexists(marker):
|
||||
continue
|
||||
if os.path.islink(marker) or not os.path.isfile(marker):
|
||||
raise OSError("observe marker is not a regular file: %s" % marker)
|
||||
with open(marker, "r", encoding="utf-8") as handle:
|
||||
value = json.load(handle)
|
||||
if not isinstance(value, dict):
|
||||
raise OSError("observe JSON marker is not an object: %s" % marker)
|
||||
for name in ["pid", "observer.pid", "browser.pid", "runner.pid"]:
|
||||
marker = os.path.join(path, name)
|
||||
if not os.path.lexists(marker):
|
||||
continue
|
||||
if os.path.islink(marker) or not os.path.isfile(marker):
|
||||
raise OSError("observe pid marker is not a regular file: %s" % marker)
|
||||
with open(marker, "r", encoding="utf-8") as handle:
|
||||
raw = handle.read().strip()
|
||||
if not re.match(r"^[1-9]\d*$", raw):
|
||||
raise OSError("observe pid marker is invalid: %s" % marker)
|
||||
|
||||
def observer_run_records():
|
||||
global _OBSERVE_SCAN_ERROR, _OBSERVE_ROOT_DIAGNOSTICS
|
||||
stale_hours = config_float(MEMORY_CONFIG, "staleRunMaxAgeHours", 6.0, minimum=0.0)
|
||||
records = []
|
||||
roots = configured_observe_roots()
|
||||
accessible_roots = []
|
||||
missing_roots = []
|
||||
for root in roots:
|
||||
try:
|
||||
root_stat = os.stat(root)
|
||||
if os.path.isdir(root) and not os.path.islink(root):
|
||||
accessible_roots.append(root)
|
||||
elif root_stat:
|
||||
_OBSERVE_SCAN_ERROR = "observe-state-root-not-directory"
|
||||
except FileNotFoundError:
|
||||
missing_roots.append(root)
|
||||
continue
|
||||
except OSError as exc:
|
||||
_OBSERVE_SCAN_ERROR = "observe-state-root-read-failed:%s" % type(exc).__name__
|
||||
minimum_readable = config_int(MEMORY_CONFIG, "minimumReadableObserveRoots", 1, minimum=1, maximum=100)
|
||||
_OBSERVE_ROOT_DIAGNOSTICS = {"configured": list(roots), "readable": list(accessible_roots), "missing": missing_roots}
|
||||
if len(accessible_roots) < minimum_readable:
|
||||
_OBSERVE_SCAN_ERROR = _OBSERVE_SCAN_ERROR or "minimum-readable-observe-roots-not-met:%s-of-%s" % (len(accessible_roots), minimum_readable)
|
||||
for root in accessible_roots:
|
||||
for path in configured_observe_runs(root):
|
||||
try:
|
||||
validate_observe_run_markers(path)
|
||||
records.append(observe_run_record(path, stale_hours))
|
||||
except (OSError, ValueError, json.JSONDecodeError) as exc:
|
||||
_OBSERVE_SCAN_ERROR = "observe-run-marker-read-failed:%s" % type(exc).__name__
|
||||
continue
|
||||
return records
|
||||
|
||||
def process_descendants(processes, root_pid):
|
||||
selected = set([int(root_pid)])
|
||||
changed = True
|
||||
while changed:
|
||||
changed = False
|
||||
for pid, row in processes.items():
|
||||
if pid not in selected and safe_int(row.get("ppid")) in selected:
|
||||
selected.add(pid)
|
||||
changed = True
|
||||
return selected
|
||||
|
||||
def session_closed_tree(processes, sid):
|
||||
members = {pid: row for pid, row in processes.items() if safe_int(row.get("sid")) == int(sid)}
|
||||
if not members:
|
||||
return None
|
||||
roots = [pid for pid, row in members.items() if safe_int(row.get("ppid")) not in members]
|
||||
if len(roots) != 1:
|
||||
return None
|
||||
root_pid = roots[0]
|
||||
if process_descendants(members, root_pid) != set(members.keys()):
|
||||
return None
|
||||
return {"rootPid": root_pid, "members": members}
|
||||
|
||||
def command_matches(row, patterns):
|
||||
haystack = str(row.get("commandLine") or "").lower()
|
||||
return any(str(pattern).lower() in haystack for pattern in patterns if str(pattern))
|
||||
|
||||
def memory_process_candidate_id(kind, root):
|
||||
return "%s:%s:%s:%s" % (kind, safe_int(root.get("sid")), safe_int(root.get("pid")), safe_int(root.get("startTicks")))
|
||||
|
||||
def collect_memory_reclaim_candidates():
|
||||
global _OBSERVE_SCAN_TRUNCATED, _PROCESS_SCAN_TRUNCATED, _OBSERVE_SCAN_ERROR, _PROCESS_SCAN_ERROR, _MEMORY_RECLAIM_CONFIG_ERROR, _OBSERVE_ROOT_DIAGNOSTICS
|
||||
_OBSERVE_SCAN_TRUNCATED = False
|
||||
_PROCESS_SCAN_TRUNCATED = False
|
||||
_OBSERVE_SCAN_ERROR = None
|
||||
_PROCESS_SCAN_ERROR = None
|
||||
_MEMORY_RECLAIM_CONFIG_ERROR = None
|
||||
_OBSERVE_ROOT_DIAGNOSTICS = {"configured": [], "readable": [], "missing": []}
|
||||
_OBSERVE_RUN_CACHE.clear()
|
||||
required_keys = [
|
||||
"observeStateRoots", "minimumReadableObserveRoots", "observeRunDepth", "observeScanLimit", "staleRunMaxAgeHours",
|
||||
"processScanLimit", "processStaleMinAgeSeconds", "maxMemoryReclaimCandidates",
|
||||
"terminationGraceSeconds", "terminationPollMilliseconds", "observerRootPatterns",
|
||||
"browserRootPatterns", "browserProcessPatterns",
|
||||
]
|
||||
if any(key not in MEMORY_CONFIG for key in required_keys):
|
||||
_MEMORY_RECLAIM_CONFIG_ERROR = "required-yaml-memory-pressure-policy-missing"
|
||||
return []
|
||||
list_keys = ["observeStateRoots", "observerRootPatterns", "browserRootPatterns", "browserProcessPatterns"]
|
||||
integer_keys = [
|
||||
"observeRunDepth", "observeScanLimit", "processScanLimit", "processStaleMinAgeSeconds",
|
||||
"maxMemoryReclaimCandidates", "terminationGraceSeconds", "terminationPollMilliseconds",
|
||||
]
|
||||
if any(not isinstance(MEMORY_CONFIG.get(key), list) or not MEMORY_CONFIG.get(key) for key in list_keys):
|
||||
_MEMORY_RECLAIM_CONFIG_ERROR = "required-yaml-memory-pressure-list-invalid"
|
||||
return []
|
||||
if any(any(not isinstance(item, str) or not item.strip() or "\0" in item for item in MEMORY_CONFIG.get(key)) for key in list_keys):
|
||||
_MEMORY_RECLAIM_CONFIG_ERROR = "required-yaml-memory-pressure-list-entry-invalid"
|
||||
return []
|
||||
if any(not os.path.isabs(item) for item in MEMORY_CONFIG.get("observeStateRoots")):
|
||||
_MEMORY_RECLAIM_CONFIG_ERROR = "required-yaml-observe-root-not-absolute"
|
||||
return []
|
||||
integer_ranges = {
|
||||
"observeRunDepth": (1, 12),
|
||||
"minimumReadableObserveRoots": (1, 100),
|
||||
"observeScanLimit": (1, 100000),
|
||||
"processScanLimit": (1, 1000000),
|
||||
"processStaleMinAgeSeconds": (60, 604800),
|
||||
"maxMemoryReclaimCandidates": (1, 100),
|
||||
"terminationGraceSeconds": (1, 30),
|
||||
"terminationPollMilliseconds": (50, 1000),
|
||||
}
|
||||
if any(isinstance(MEMORY_CONFIG.get(key), bool) or not isinstance(MEMORY_CONFIG.get(key), int)
|
||||
or MEMORY_CONFIG.get(key) < minimum or MEMORY_CONFIG.get(key) > maximum
|
||||
for key, (minimum, maximum) in integer_ranges.items()):
|
||||
_MEMORY_RECLAIM_CONFIG_ERROR = "required-yaml-memory-pressure-integer-invalid"
|
||||
return []
|
||||
if MEMORY_CONFIG.get("minimumReadableObserveRoots") > len(MEMORY_CONFIG.get("observeStateRoots")):
|
||||
_MEMORY_RECLAIM_CONFIG_ERROR = "minimum-readable-observe-roots-exceeds-configured-roots"
|
||||
return []
|
||||
stale_hours = MEMORY_CONFIG.get("staleRunMaxAgeHours")
|
||||
if isinstance(stale_hours, bool) or not isinstance(stale_hours, (int, float)) or stale_hours <= 0 or stale_hours > 8760:
|
||||
_MEMORY_RECLAIM_CONFIG_ERROR = "required-yaml-stale-age-invalid"
|
||||
return []
|
||||
browser_patterns = config_list(MEMORY_CONFIG, "browserProcessPatterns", [])
|
||||
root_patterns = config_list(MEMORY_CONFIG, "browserRootPatterns", [])
|
||||
observer_patterns = config_list(MEMORY_CONFIG, "observerRootPatterns", [])
|
||||
stale_seconds = config_int(MEMORY_CONFIG, "processStaleMinAgeSeconds", 3600, minimum=60, maximum=604800)
|
||||
if not browser_patterns or not root_patterns or not observer_patterns:
|
||||
return []
|
||||
processes = read_proc_process_table()
|
||||
records = observer_run_records()
|
||||
if not processes and _PROCESS_SCAN_ERROR is None:
|
||||
_PROCESS_SCAN_ERROR = "proc-process-table-empty"
|
||||
if _PROCESS_SCAN_TRUNCATED or _OBSERVE_SCAN_TRUNCATED or _PROCESS_SCAN_ERROR or _OBSERVE_SCAN_ERROR:
|
||||
return []
|
||||
fresh_observer_pids = set()
|
||||
stale_observer_pids = set()
|
||||
for record in records:
|
||||
pid = safe_int(record.get("pid"))
|
||||
if pid <= 0 or pid not in processes:
|
||||
continue
|
||||
if record.get("staleLiveSignal"):
|
||||
stale_observer_pids.add(pid)
|
||||
else:
|
||||
fresh_observer_pids.add(pid)
|
||||
protected_pids = set()
|
||||
for pid in fresh_observer_pids:
|
||||
protected_pids.update(process_descendants(processes, pid))
|
||||
candidates = []
|
||||
for sid in sorted(set(safe_int(row.get("sid")) for row in processes.values() if safe_int(row.get("sid")) > 0)):
|
||||
closed = session_closed_tree(processes, sid)
|
||||
if closed is None:
|
||||
continue
|
||||
members = closed["members"]
|
||||
member_pids = set(members.keys())
|
||||
if member_pids.intersection(protected_pids):
|
||||
continue
|
||||
root = members[closed["rootPid"]]
|
||||
if safe_int(root.get("ageSeconds")) < stale_seconds:
|
||||
continue
|
||||
has_browser = any(command_matches(row, browser_patterns) for row in members.values())
|
||||
intersecting_stale = member_pids.intersection(stale_observer_pids)
|
||||
kind = None
|
||||
reason = None
|
||||
if intersecting_stale and command_matches(root, observer_patterns):
|
||||
kind = "stale-web-observer-process-tree"
|
||||
reason = "stale-observer-heartbeat"
|
||||
elif not member_pids.intersection(set(safe_int(item.get("pid")) for item in records if item.get("pidAlive"))):
|
||||
if command_matches(root, root_patterns) and has_browser:
|
||||
kind = "browser-orphan-process-tree"
|
||||
reason = "browser-session-without-observer-record"
|
||||
if kind is None:
|
||||
continue
|
||||
identities = [
|
||||
{
|
||||
"pid": pid,
|
||||
"ppid": safe_int(row.get("ppid")),
|
||||
"sid": safe_int(row.get("sid")),
|
||||
"startTicks": safe_int(row.get("startTicks")),
|
||||
"rssBytes": safe_int(row.get("rssBytes")),
|
||||
"commandPreview": row.get("commandPreview"),
|
||||
}
|
||||
for pid, row in sorted(members.items())
|
||||
]
|
||||
expected_memory = sum(safe_int(item.get("rssBytes")) for item in identities)
|
||||
candidates.append({
|
||||
"id": memory_process_candidate_id(kind, root),
|
||||
"kind": kind,
|
||||
"risk": "medium",
|
||||
"description": "Terminate a YAML-allowlisted stale WebProbe process session after identity and active-observer revalidation",
|
||||
"reason": reason,
|
||||
"rootPid": safe_int(root.get("pid")),
|
||||
"sid": sid,
|
||||
"rootStartTicks": safe_int(root.get("startTicks")),
|
||||
"processCount": len(identities),
|
||||
"processIdentities": identities,
|
||||
"expectedMemoryRssBytes": expected_memory,
|
||||
"expectedMemoryRss": fmt_bytes(expected_memory),
|
||||
"estimatedDiskBytes": 0,
|
||||
"estimatedReclaimBytes": 0,
|
||||
"activeObserverIntersection": False,
|
||||
"treeClosure": "unique-root-ppid-sid-closed",
|
||||
"action": {"op": "term-wait-kill-exact-process-tree", "allowlist": "yaml-web-probe-memory-pressure"},
|
||||
})
|
||||
max_candidates = config_int(MEMORY_CONFIG, "maxMemoryReclaimCandidates", 20, minimum=1, maximum=100)
|
||||
return sorted(candidates, key=lambda item: safe_int(item.get("expectedMemoryRssBytes")), reverse=True)[:max_candidates]
|
||||
|
||||
def memory_reclaim_scan_diagnostics():
|
||||
blocked = bool(_MEMORY_RECLAIM_CONFIG_ERROR or _OBSERVE_SCAN_TRUNCATED or _PROCESS_SCAN_TRUNCATED or _OBSERVE_SCAN_ERROR or _PROCESS_SCAN_ERROR)
|
||||
return {
|
||||
"ok": not blocked,
|
||||
"blocked": blocked,
|
||||
"configError": _MEMORY_RECLAIM_CONFIG_ERROR,
|
||||
"observeScanTruncated": bool(_OBSERVE_SCAN_TRUNCATED),
|
||||
"processScanTruncated": bool(_PROCESS_SCAN_TRUNCATED),
|
||||
"observeScanError": _OBSERVE_SCAN_ERROR,
|
||||
"processScanError": _PROCESS_SCAN_ERROR,
|
||||
"observeRoots": {
|
||||
"configuredCount": len(_OBSERVE_ROOT_DIAGNOSTICS.get("configured") or []),
|
||||
"readableCount": len(_OBSERVE_ROOT_DIAGNOSTICS.get("readable") or []),
|
||||
"missingCount": len(_OBSERVE_ROOT_DIAGNOSTICS.get("missing") or []),
|
||||
"minimumReadable": safe_int(MEMORY_CONFIG.get("minimumReadableObserveRoots")),
|
||||
"configured": _OBSERVE_ROOT_DIAGNOSTICS.get("configured") or [],
|
||||
"readable": _OBSERVE_ROOT_DIAGNOSTICS.get("readable") or [],
|
||||
"missing": _OBSERVE_ROOT_DIAGNOSTICS.get("missing") or [],
|
||||
},
|
||||
"policySource": "config/unidesk-cli.yaml#gc.remote.targets.%s.memoryPressure" % PROVIDER_ID,
|
||||
}
|
||||
|
||||
def matching_live_identity(identity, processes):
|
||||
pid = safe_int(identity.get("pid"))
|
||||
row = processes.get(pid)
|
||||
return row if row \
|
||||
and safe_int(row.get("startTicks")) == safe_int(identity.get("startTicks")) \
|
||||
and safe_int(row.get("ppid")) == safe_int(identity.get("ppid")) \
|
||||
and safe_int(row.get("sid")) == safe_int(identity.get("sid")) else None
|
||||
|
||||
def matching_surviving_identity(identity, processes):
|
||||
pid = safe_int(identity.get("pid"))
|
||||
row = processes.get(pid)
|
||||
return row if row \
|
||||
and safe_int(row.get("startTicks")) == safe_int(identity.get("startTicks")) else None
|
||||
|
||||
def require_complete_process_scan(processes, phase):
|
||||
if not processes or _PROCESS_SCAN_ERROR or _PROCESS_SCAN_TRUNCATED:
|
||||
raise RuntimeError("memory process identity scan blocked during %s: %s" % (
|
||||
phase,
|
||||
_PROCESS_SCAN_ERROR or "process-scan-truncated",
|
||||
))
|
||||
return processes
|
||||
|
||||
def extend_with_allowlisted_session_members(identities, processes, sid, browser_patterns):
|
||||
known = {(safe_int(item.get("pid")), safe_int(item.get("startTicks"))) for item in identities}
|
||||
added = []
|
||||
for pid, row in processes.items():
|
||||
identity_key = (safe_int(pid), safe_int(row.get("startTicks")))
|
||||
if identity_key in known or safe_int(row.get("sid")) != safe_int(sid) or not command_matches(row, browser_patterns):
|
||||
continue
|
||||
item = {
|
||||
"pid": safe_int(pid),
|
||||
"ppid": safe_int(row.get("ppid")),
|
||||
"sid": safe_int(row.get("sid")),
|
||||
"startTicks": safe_int(row.get("startTicks")),
|
||||
"rssBytes": safe_int(row.get("rssBytes")),
|
||||
"commandPreview": row.get("commandPreview"),
|
||||
"discoveredAfterTerm": True,
|
||||
}
|
||||
identities.append(item)
|
||||
added.append(item)
|
||||
known.add(identity_key)
|
||||
return added
|
||||
|
||||
def execute_memory_process_candidate(candidate):
|
||||
current = next((item for item in collect_memory_reclaim_candidates() if item.get("id") == candidate.get("id")), None)
|
||||
if current is None:
|
||||
raise RuntimeError("memory candidate no longer has the same closed identity or is now protected")
|
||||
before = collect_memory_snapshot()
|
||||
identities = [dict(item) for item in (current.get("processIdentities") or [])]
|
||||
candidate_sid = safe_int(current.get("sid"))
|
||||
browser_patterns = config_list(MEMORY_CONFIG, "browserProcessPatterns", [])
|
||||
late_members = []
|
||||
processes = require_complete_process_scan(read_proc_process_table(), "pre-term-revalidation")
|
||||
live = [item for item in identities if matching_live_identity(item, processes)]
|
||||
for item in sorted(live, key=lambda value: safe_int(value.get("pid")), reverse=True):
|
||||
try:
|
||||
os.kill(safe_int(item.get("pid")), signal.SIGTERM)
|
||||
except ProcessLookupError:
|
||||
pass
|
||||
grace_seconds = config_int(MEMORY_CONFIG, "terminationGraceSeconds", 5, minimum=1, maximum=30)
|
||||
poll_ms = config_int(MEMORY_CONFIG, "terminationPollMilliseconds", 200, minimum=50, maximum=1000)
|
||||
deadline = time.time() + grace_seconds
|
||||
residual = []
|
||||
while time.time() < deadline:
|
||||
processes = require_complete_process_scan(read_proc_process_table(), "term-grace-poll")
|
||||
late_members.extend(extend_with_allowlisted_session_members(identities, processes, candidate_sid, browser_patterns))
|
||||
residual = [item for item in identities if matching_surviving_identity(item, processes)]
|
||||
if not residual:
|
||||
break
|
||||
time.sleep(float(poll_ms) / 1000.0)
|
||||
processes = require_complete_process_scan(read_proc_process_table(), "pre-kill-revalidation")
|
||||
late_members.extend(extend_with_allowlisted_session_members(identities, processes, candidate_sid, browser_patterns))
|
||||
residual = [item for item in identities if matching_surviving_identity(item, processes)]
|
||||
for item in sorted(residual, key=lambda value: safe_int(value.get("pid")), reverse=True):
|
||||
try:
|
||||
os.kill(safe_int(item.get("pid")), signal.SIGKILL)
|
||||
except ProcessLookupError:
|
||||
pass
|
||||
final_deadline = time.time() + min(2.0, float(grace_seconds))
|
||||
remaining = residual
|
||||
while time.time() < final_deadline:
|
||||
processes = require_complete_process_scan(read_proc_process_table(), "kill-grace-poll")
|
||||
late_members.extend(extend_with_allowlisted_session_members(identities, processes, candidate_sid, browser_patterns))
|
||||
remaining = [item for item in identities if matching_surviving_identity(item, processes)]
|
||||
if not remaining:
|
||||
break
|
||||
time.sleep(float(poll_ms) / 1000.0)
|
||||
after = collect_memory_snapshot()
|
||||
before_available = safe_int((before.get("memory") or {}).get("availableBytes"), None)
|
||||
after_available = safe_int((after.get("memory") or {}).get("availableBytes"), None)
|
||||
return {
|
||||
"status": "failed" if remaining else "succeeded",
|
||||
"reclaimedBytes": 0,
|
||||
"reclaimedMemoryBytes": max(0, after_available - before_available) if before_available is not None and after_available is not None else None,
|
||||
"expectedMemoryRssBytes": current.get("expectedMemoryRssBytes"),
|
||||
"termSignalCount": len(live),
|
||||
"killSignalCount": len(residual),
|
||||
"remainingIdentityCount": len(remaining),
|
||||
"lateAllowlistedIdentityCount": len(late_members),
|
||||
"remainingIdentities": [
|
||||
{
|
||||
"pid": safe_int(item.get("pid")),
|
||||
"startTicks": safe_int(item.get("startTicks")),
|
||||
"sid": safe_int(item.get("sid")),
|
||||
"originalPpid": safe_int(item.get("ppid")),
|
||||
"observedPpid": safe_int((matching_surviving_identity(item, processes) or {}).get("ppid")),
|
||||
}
|
||||
for item in remaining
|
||||
],
|
||||
"memoryBefore": before,
|
||||
"memoryAfter": after,
|
||||
"identityRevalidated": True,
|
||||
"activeObserverIntersection": False,
|
||||
}
|
||||
|
||||
@@ -36,6 +36,7 @@ interface RemoteGcOptions {
|
||||
full: boolean;
|
||||
historyLimit: number;
|
||||
saveSnapshot: boolean;
|
||||
memoryPressureOnly: boolean;
|
||||
}
|
||||
|
||||
const DEFAULT_REMOTE_OPTIONS: RemoteGcOptions = {
|
||||
@@ -65,6 +66,7 @@ const DEFAULT_REMOTE_OPTIONS: RemoteGcOptions = {
|
||||
full: false,
|
||||
historyLimit: 12,
|
||||
saveSnapshot: true,
|
||||
memoryPressureOnly: false,
|
||||
};
|
||||
|
||||
const GC_CONFIG_RELATIVE_PATH = "config/unidesk-cli.yaml";
|
||||
@@ -124,6 +126,16 @@ export async function runRemoteGcCommand(config: UniDeskConfig, providerId: stri
|
||||
};
|
||||
}
|
||||
const options = parseRemoteGcOptions(args);
|
||||
if (options.memoryPressureOnly && subaction !== "plan" && subaction !== "run") {
|
||||
return {
|
||||
ok: false,
|
||||
error: "gc-remote-memory-pressure-only-action-invalid",
|
||||
action: subaction,
|
||||
supportedActions: ["plan", "run"],
|
||||
planCommand: `bun scripts/cli.ts gc remote ${providerId} plan --memory-pressure-only`,
|
||||
runCommand: `bun scripts/cli.ts gc remote ${providerId} run --confirm --memory-pressure-only`,
|
||||
};
|
||||
}
|
||||
if (subaction === "memory") return await runRemoteGc(config, providerId, "memory", options);
|
||||
if (subaction === "plan" || subaction === "dry-run") return await runRemoteGc(config, providerId, "plan", options);
|
||||
if (subaction === "snapshot" || subaction === "growth") return await runRemoteGc(config, providerId, "snapshot", options);
|
||||
@@ -137,8 +149,8 @@ export async function runRemoteGcCommand(config: UniDeskConfig, providerId: stri
|
||||
dryRun: true,
|
||||
mutation: false,
|
||||
requiredFlag: "--confirm",
|
||||
planCommand: `bun scripts/cli.ts gc remote ${providerId} plan`,
|
||||
runCommand: `bun scripts/cli.ts gc remote ${providerId} run --confirm`,
|
||||
planCommand: `bun scripts/cli.ts gc remote ${providerId} plan${options.memoryPressureOnly ? " --memory-pressure-only" : ""}`,
|
||||
runCommand: `bun scripts/cli.ts gc remote ${providerId} run --confirm${options.memoryPressureOnly ? " --memory-pressure-only" : ""}`,
|
||||
};
|
||||
}
|
||||
return await runRemoteGc(config, providerId, "run", options);
|
||||
@@ -239,6 +251,8 @@ function parseRemoteGcOptions(args: string[]): RemoteGcOptions {
|
||||
options.full = true;
|
||||
} else if (arg === "--no-save" || arg === "--no-snapshot-save") {
|
||||
options.saveSnapshot = false;
|
||||
} else if (arg === "--memory-pressure-only") {
|
||||
options.memoryPressureOnly = true;
|
||||
} else {
|
||||
throw new Error(`unknown gc remote option: ${arg}`);
|
||||
}
|
||||
|
||||
@@ -517,6 +517,10 @@ function gcHelp(): unknown {
|
||||
"bun scripts/cli.ts gc policy plan",
|
||||
"bun scripts/cli.ts gc policy install",
|
||||
"bun scripts/cli.ts gc remote G14 plan",
|
||||
"bun scripts/cli.ts gc remote NC01 memory",
|
||||
"bun scripts/cli.ts gc remote NC01 plan --memory-pressure-only",
|
||||
"bun scripts/cli.ts gc remote NC01 run --confirm --memory-pressure-only",
|
||||
"bun scripts/cli.ts gc remote NC01 status --job-id <job-id>",
|
||||
"bun scripts/cli.ts gc remote G14 plan --target-use-percent 50 --include-hwlab-registry",
|
||||
"bun scripts/cli.ts gc remote G14 run --confirm",
|
||||
"bun scripts/cli.ts gc remote G14 status --job-id <id>",
|
||||
@@ -563,6 +567,7 @@ function gcHelp(): unknown {
|
||||
"db-trace run --vacuum-full": "rewrite public.oa_events after deletion so df can reclaim disk; requires maintenance window",
|
||||
"policy plan|install": "render or install journald caps and a daily low-risk gc systemd timer from config/unidesk-cli.yaml#gc.policyTimer",
|
||||
"remote <providerId> plan|run": "run bounded GC through UniDesk SSH passthrough on a provider host; G14 protects HWLAB k3s/runtime/PVC/workspace paths, and HWLAB registry retention is explicit opt-in with workload-ref, digest-closure, recent-tag and per-repo tag protection",
|
||||
"remote <providerId> memory": "只读输出目标节点 /proc/meminfo 的 MemAvailable;不合并 swap、cgroup 或 virtual memory,也不执行 GC mutation",
|
||||
"--no-file-logs|--no-docker-logs|--no-journal|--no-build-cache|--no-tmp|--no-db-summary": "disable one collector",
|
||||
},
|
||||
reference: "docs/reference/gc.md",
|
||||
|
||||
@@ -167,6 +167,10 @@ export interface HwlabRuntimeWebProbeMemoryStartGuardSpec {
|
||||
readonly manualComparator: "less-than-or-equal";
|
||||
readonly sentinelComparator: "less-than";
|
||||
readonly snapshotTimeoutSeconds: number;
|
||||
readonly launchLockPath: string;
|
||||
readonly launchLockTimeoutSeconds: number;
|
||||
readonly launchReadyTimeoutSeconds: number;
|
||||
readonly launchReadyPollMilliseconds: number;
|
||||
}
|
||||
|
||||
export interface HwlabRuntimeWebProbeObserverLifecycleSpec {
|
||||
@@ -176,8 +180,24 @@ export interface HwlabRuntimeWebProbeObserverLifecycleSpec {
|
||||
readonly maxPerformanceEntries: number;
|
||||
readonly maxBrowserProcessHistoryEntries: number;
|
||||
readonly maxBrowserFreezeSignalHistoryEntries: number;
|
||||
readonly maxRealtimeEventEntries: number;
|
||||
readonly maxRealtimeEventBodyBytes: number;
|
||||
readonly maxDomEvidenceRows: number;
|
||||
readonly maxProcessScanEntries: number;
|
||||
readonly maxPerformanceCaptureSeconds: number;
|
||||
readonly maxResponseBodyBytes: number;
|
||||
readonly maxWebPerformanceBodyBytes: number;
|
||||
readonly maxJsonlQueueEntries: number;
|
||||
readonly maxJsonlQueueBytes: number;
|
||||
readonly maxJsonlLineBytes: number;
|
||||
readonly jsonlFlushTimeoutMs: number;
|
||||
readonly maxResponseBodyInFlight: number;
|
||||
readonly maxPassiveListenerTasks: number;
|
||||
readonly passiveTaskFlushTimeoutMs: number;
|
||||
readonly closeStepTimeoutMs: number;
|
||||
readonly maxProcessReadConcurrency: number;
|
||||
readonly maxRealtimeTotalEntries: number;
|
||||
readonly maxRealtimeTotalBodyBytes: number;
|
||||
}
|
||||
|
||||
export interface HwlabRuntimeWebProbeResourcePolicySpec {
|
||||
@@ -1471,6 +1491,10 @@ function webProbeResourcePolicyConfig(value: unknown, path: string): HwlabRuntim
|
||||
manualComparator: enumStringField(memory, "manualComparator", `${path}.memoryStartGuard`, ["less-than-or-equal"] as const),
|
||||
sentinelComparator: enumStringField(memory, "sentinelComparator", `${path}.memoryStartGuard`, ["less-than"] as const),
|
||||
snapshotTimeoutSeconds: boundedIntegerField(memory, "snapshotTimeoutSeconds", `${path}.memoryStartGuard`, 5, 120),
|
||||
launchLockPath: absoluteHostPathField(stringField(memory, "launchLockPath", `${path}.memoryStartGuard`), `${path}.memoryStartGuard.launchLockPath`),
|
||||
launchLockTimeoutSeconds: boundedIntegerField(memory, "launchLockTimeoutSeconds", `${path}.memoryStartGuard`, 1, 60),
|
||||
launchReadyTimeoutSeconds: boundedIntegerField(memory, "launchReadyTimeoutSeconds", `${path}.memoryStartGuard`, 1, 30),
|
||||
launchReadyPollMilliseconds: boundedIntegerField(memory, "launchReadyPollMilliseconds", `${path}.memoryStartGuard`, 50, 1000),
|
||||
},
|
||||
observerLifecycle: {
|
||||
maxRunSeconds: boundedIntegerField(lifecycle, "maxRunSeconds", `${path}.observerLifecycle`, 1, 86_400),
|
||||
@@ -1479,8 +1503,24 @@ function webProbeResourcePolicyConfig(value: unknown, path: string): HwlabRuntim
|
||||
maxPerformanceEntries: boundedIntegerField(lifecycle, "maxPerformanceEntries", `${path}.observerLifecycle`, 1, 100_000),
|
||||
maxBrowserProcessHistoryEntries: boundedIntegerField(lifecycle, "maxBrowserProcessHistoryEntries", `${path}.observerLifecycle`, 1, 100_000),
|
||||
maxBrowserFreezeSignalHistoryEntries: boundedIntegerField(lifecycle, "maxBrowserFreezeSignalHistoryEntries", `${path}.observerLifecycle`, 1, 100_000),
|
||||
maxRealtimeEventEntries: boundedIntegerField(lifecycle, "maxRealtimeEventEntries", `${path}.observerLifecycle`, 1, 100_000),
|
||||
maxRealtimeEventBodyBytes: boundedIntegerField(lifecycle, "maxRealtimeEventBodyBytes", `${path}.observerLifecycle`, 1, 16 * 1024 * 1024),
|
||||
maxDomEvidenceRows: boundedIntegerField(lifecycle, "maxDomEvidenceRows", `${path}.observerLifecycle`, 1, 100_000),
|
||||
maxProcessScanEntries: boundedIntegerField(lifecycle, "maxProcessScanEntries", `${path}.observerLifecycle`, 1, 1_000_000),
|
||||
maxPerformanceCaptureSeconds: boundedIntegerField(lifecycle, "maxPerformanceCaptureSeconds", `${path}.observerLifecycle`, 1, 600),
|
||||
maxResponseBodyBytes: boundedIntegerField(lifecycle, "maxResponseBodyBytes", `${path}.observerLifecycle`, 1, 16 * 1024 * 1024),
|
||||
maxWebPerformanceBodyBytes: boundedIntegerField(lifecycle, "maxWebPerformanceBodyBytes", `${path}.observerLifecycle`, 1, 16 * 1024 * 1024),
|
||||
maxJsonlQueueEntries: boundedIntegerField(lifecycle, "maxJsonlQueueEntries", `${path}.observerLifecycle`, 1, 100_000),
|
||||
maxJsonlQueueBytes: boundedIntegerField(lifecycle, "maxJsonlQueueBytes", `${path}.observerLifecycle`, 1024, 64 * 1024 * 1024),
|
||||
maxJsonlLineBytes: boundedIntegerField(lifecycle, "maxJsonlLineBytes", `${path}.observerLifecycle`, 1024, 16 * 1024 * 1024),
|
||||
jsonlFlushTimeoutMs: boundedIntegerField(lifecycle, "jsonlFlushTimeoutMs", `${path}.observerLifecycle`, 100, 60_000),
|
||||
maxResponseBodyInFlight: boundedIntegerField(lifecycle, "maxResponseBodyInFlight", `${path}.observerLifecycle`, 1, 64),
|
||||
maxPassiveListenerTasks: boundedIntegerField(lifecycle, "maxPassiveListenerTasks", `${path}.observerLifecycle`, 1, 10_000),
|
||||
passiveTaskFlushTimeoutMs: boundedIntegerField(lifecycle, "passiveTaskFlushTimeoutMs", `${path}.observerLifecycle`, 100, 60_000),
|
||||
closeStepTimeoutMs: boundedIntegerField(lifecycle, "closeStepTimeoutMs", `${path}.observerLifecycle`, 100, 60_000),
|
||||
maxProcessReadConcurrency: boundedIntegerField(lifecycle, "maxProcessReadConcurrency", `${path}.observerLifecycle`, 1, 256),
|
||||
maxRealtimeTotalEntries: boundedIntegerField(lifecycle, "maxRealtimeTotalEntries", `${path}.observerLifecycle`, 1, 100_000),
|
||||
maxRealtimeTotalBodyBytes: boundedIntegerField(lifecycle, "maxRealtimeTotalBodyBytes", `${path}.observerLifecycle`, 1024, 64 * 1024 * 1024),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -379,7 +379,7 @@ async function authenticate(browserContext) {
|
||||
await writeHeartbeat({ status: terminalStatus, auth: { phase: "api-login", lastRetryLabel: item?.retryLabel || retryLabel, retryAttempt: attempt, retryMaxAttempts: maxAttempts, retryDelayMs: item?.retryDelayMs ?? 0, lastStatus: item?.status ?? 0, lastStatusText: item?.statusText ?? "request-error", retryable, cookiePresent: false, retryExhausted: false, lastError: item?.error || null, valuesRedacted: true } }).catch(() => {});
|
||||
if (!retryable) break;
|
||||
}
|
||||
if (attempt < maxAttempts && attempts[attempts.length - 1]?.retryable === true) await sleep(retryDelayMs);
|
||||
if (attempt < maxAttempts && attempts[attempts.length - 1]?.retryable === true) await interruptibleRunnerSleep(retryDelayMs);
|
||||
}
|
||||
const cookieState = await readAuthCookieState(browserContext);
|
||||
const last = attempts[attempts.length - 1] || null;
|
||||
@@ -414,12 +414,13 @@ async function pageAuthLogin(browserContext, loginUrl, credential = { username,
|
||||
headers: { accept: "application/json", "content-type": "application/json" },
|
||||
timeout: authLoginRequestTimeoutMs,
|
||||
});
|
||||
await response.text().catch(() => "");
|
||||
return {
|
||||
const result = {
|
||||
ok: response.ok(),
|
||||
status: response.status(),
|
||||
statusText: response.statusText() || "",
|
||||
};
|
||||
if (typeof response.dispose === "function") await response.dispose().catch(() => {});
|
||||
return result;
|
||||
}
|
||||
|
||||
async function loginAccount(command) {
|
||||
@@ -479,7 +480,7 @@ async function loginAccount(command) {
|
||||
cookieState = await readAuthCookieState(context).catch(() => ({ cookiePresent: false, cookieNames: [] }));
|
||||
if (!retryable) break;
|
||||
}
|
||||
if (attempt < maxAttempts && attempts[attempts.length - 1]?.retryable === true) await sleep(retryDelayMs);
|
||||
if (attempt < maxAttempts && attempts[attempts.length - 1]?.retryable === true) await interruptibleRunnerSleep(retryDelayMs);
|
||||
}
|
||||
response = response ?? { ok: false, status: 0, statusText: "api-login-failed" };
|
||||
cookieState = cookieState ?? await readAuthCookieState(context);
|
||||
@@ -514,7 +515,6 @@ async function logoutAccount(command = {}) {
|
||||
const logoutUrl = new URL("/logout", baseUrl).toString();
|
||||
const response = await page.evaluate(async (input) => {
|
||||
const res = await fetch(input.logoutUrl, { method: "POST", headers: { accept: "application/json" }, credentials: "include" });
|
||||
await res.text().catch(() => "");
|
||||
return { ok: res.ok, status: res.status, statusText: res.statusText || "" };
|
||||
}, { logoutUrl });
|
||||
await context.clearCookies().catch(() => {});
|
||||
@@ -1295,11 +1295,9 @@ async function createSessionFromUi() {
|
||||
const createStatus = createResponse.status();
|
||||
let createPayload = null;
|
||||
let createPayloadError = null;
|
||||
try {
|
||||
createPayload = await createResponse.json();
|
||||
} catch (error) {
|
||||
createPayloadError = errorSummary(error);
|
||||
}
|
||||
const createBody = await boundedResponseJson(createResponse);
|
||||
createPayload = createBody.value;
|
||||
createPayloadError = createBody.error;
|
||||
const createdSessionId = sessionIdFromAgentSessionPayload(createPayload);
|
||||
if (createStatus < 200 || createStatus >= 300 || !createdSessionId) {
|
||||
const error = new Error("newSession did not receive an authoritative session id from POST /v1/agent/sessions");
|
||||
@@ -1753,11 +1751,9 @@ async function sendPrompt(text, options = {}) {
|
||||
}
|
||||
let chatPayload = null;
|
||||
let chatPayloadError = null;
|
||||
try {
|
||||
chatPayload = await chatResponse.json();
|
||||
} catch (error) {
|
||||
chatPayloadError = errorSummary(error);
|
||||
}
|
||||
const chatBody = await boundedResponseJson(chatResponse);
|
||||
chatPayload = chatBody.value;
|
||||
chatPayloadError = chatBody.error;
|
||||
const payloadText = chatPayload ? JSON.stringify(chatPayload) : "";
|
||||
const traceId = payloadText.match(/\btrc_[A-Za-z0-9_-]+\b/u)?.[0] || null;
|
||||
const otelTraceId = typeof chatPayload?.otelTrace?.traceId === "string" && /^[0-9a-f]{32}$/u.test(chatPayload.otelTrace.traceId)
|
||||
|
||||
@@ -148,6 +148,7 @@ async function validateWorkbenchKafkaDebugReplay(command) {
|
||||
try { url = new URL(request.url()); } catch { return; }
|
||||
if (url.pathname === config.productEventsPath) {
|
||||
productEventSourceRequests.push({ method: request.method(), path: url.pathname, phase: requestPhase, valuesRedacted: true });
|
||||
if (productEventSourceRequests.length > maxRealtimeEventEntries) productEventSourceRequests.splice(0, productEventSourceRequests.length - maxRealtimeEventEntries);
|
||||
return;
|
||||
}
|
||||
if (url.pathname !== config.debugEventsPath) return;
|
||||
@@ -160,6 +161,7 @@ async function validateWorkbenchKafkaDebugReplay(command) {
|
||||
phase: requestPhase,
|
||||
valuesRedacted: true
|
||||
});
|
||||
if (requests.length > maxRealtimeEventEntries) requests.splice(0, requests.length - maxRealtimeEventEntries);
|
||||
};
|
||||
try {
|
||||
const controlRecovery = await ensureControlPageResponsiveForCommand("validateWorkbenchKafkaDebugReplay");
|
||||
@@ -386,7 +388,7 @@ async function workbenchKafkaDebugReplayWaitForCleared(panel, countsNode, timeou
|
||||
if (status === "idle" && counts?.appliedCount === 0 && counts?.receivedCount === 0) {
|
||||
return { status, ...counts, valuesRedacted: true };
|
||||
}
|
||||
await sleep(100);
|
||||
await interruptibleRunnerSleep(100);
|
||||
}
|
||||
throw new Error("Workbench Kafka debug panel did not clear to idle 0/0");
|
||||
}
|
||||
@@ -397,7 +399,7 @@ async function workbenchKafkaDebugReplayWaitForTerminal(panel, timeoutMs) {
|
||||
const status = await panel.getAttribute("data-replay-status");
|
||||
if (status === "completed") return status;
|
||||
if (status === "incomplete" || status === "error" || status === "closed") return status;
|
||||
await sleep(200);
|
||||
await interruptibleRunnerSleep(200);
|
||||
}
|
||||
throw new Error("Workbench Kafka debug replay did not reach a terminal panel status within " + timeoutMs + "ms");
|
||||
}
|
||||
@@ -406,7 +408,7 @@ async function workbenchKafkaDebugReplayWaitForTabSelected(tab, timeoutMs) {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (Date.now() <= deadline) {
|
||||
if (await tab.getAttribute("aria-selected") === "true") return true;
|
||||
await sleep(50);
|
||||
await interruptibleRunnerSleep(50);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
@@ -514,7 +516,7 @@ async function workbenchKafkaDebugReplayWaitForStablePanel(panel, countsNode, ti
|
||||
stableSinceMs = null;
|
||||
}
|
||||
previousSignature = signature;
|
||||
await sleep(100);
|
||||
await interruptibleRunnerSleep(100);
|
||||
}
|
||||
return {
|
||||
...latest,
|
||||
@@ -586,14 +588,14 @@ async function workbenchKafkaDebugReplayInspectRawWindow(input) {
|
||||
await rawCountsNode.waitFor({ state: "visible", timeout: 5000 });
|
||||
await rawTextNode.waitFor({ state: "visible", timeout: 5000 });
|
||||
await rawContractNode.waitFor({ state: "visible", timeout: 5000 });
|
||||
await sleep(input.config.requestObservationQuietMs);
|
||||
await interruptibleRunnerSleep(input.config.requestObservationQuietMs);
|
||||
const counts = workbenchKafkaDebugReplayRawCounts(await rawCountsNode.textContent());
|
||||
const rawText = await rawTextNode.inputValue();
|
||||
const contractText = String(await rawContractNode.textContent() || "").replace(/\s+/gu, " ").trim();
|
||||
input.setRequestPhase("trace-tab-restore");
|
||||
await traceTab.click({ timeout: 10000 });
|
||||
if (!await workbenchKafkaDebugReplayWaitForTabSelected(traceTab, 5000)) throw new Error("Workbench Trace debug tab did not restore after raw inspection");
|
||||
await sleep(input.config.requestObservationQuietMs);
|
||||
await interruptibleRunnerSleep(input.config.requestObservationQuietMs);
|
||||
input.setRequestPhase("complete");
|
||||
const pageUrlAfter = currentPageUrl();
|
||||
const productRequests = input.productEventSourceRequests.slice(requestStart);
|
||||
|
||||
@@ -227,7 +227,7 @@ function compactPerformanceAttribution(item) {
|
||||
}
|
||||
|
||||
async function capturePerformanceProfile(command) {
|
||||
const durationMs = boundedInteger(command.durationMs, 5000, 100, 600000);
|
||||
const durationMs = boundedInteger(command.durationMs, Math.min(5000, maxPerformanceCaptureMs), 100, maxPerformanceCaptureMs);
|
||||
const timeoutMs = Math.max(3000, Math.min(15000, Number(alertThresholds.playwrightResponsivenessRedMs) || 5000));
|
||||
const captureId = safeId(command.label || command.id || ("perf-" + Date.now().toString(36))) || ("perf-" + Date.now().toString(36));
|
||||
const targetPage = page && !page.isClosed() ? page : observerPage && !observerPage.isClosed() ? observerPage : null;
|
||||
@@ -264,7 +264,7 @@ async function capturePerformanceProfile(command) {
|
||||
beforeDrain,
|
||||
valuesRedacted: true,
|
||||
}));
|
||||
await sleep(durationMs);
|
||||
await interruptibleRunnerSleep(durationMs);
|
||||
stopped = await withHardTimeout(session.send("Profiler.stop"), Math.max(timeoutMs, 5000), "Profiler.stop exceeded " + Math.max(timeoutMs, 5000) + "ms");
|
||||
} catch (error) {
|
||||
captureError = error;
|
||||
|
||||
@@ -8,14 +8,26 @@ test("realtime fanout keeps the YAML terminal timeout when durationMs is absent"
|
||||
const build = new Function(
|
||||
"boundedInteger",
|
||||
"sanitize",
|
||||
"maxRealtimeTotalEntries",
|
||||
"maxRealtimeTotalBodyBytes",
|
||||
"maxRealtimeEventEntries",
|
||||
"maxRealtimeEventBodyBytes",
|
||||
`${source}\nreturn realtimeFanoutEffectiveProfile;`,
|
||||
) as (
|
||||
boundedInteger: (value: unknown, fallback: number, min: number, max: number) => number,
|
||||
sanitize: <T>(value: T) => T,
|
||||
maxRealtimeTotalEntries: number,
|
||||
maxRealtimeTotalBodyBytes: number,
|
||||
maxRealtimeEventEntries: number,
|
||||
maxRealtimeEventBodyBytes: number,
|
||||
) => (profile: Record<string, unknown>, durationMs: unknown) => Record<string, unknown>;
|
||||
const effectiveProfile = build(
|
||||
(value, fallback, min, max) => Math.min(max, Math.max(min, Number.isFinite(Number(value)) ? Number(value) : fallback)),
|
||||
(value) => value,
|
||||
512,
|
||||
4 * 1024 * 1024,
|
||||
256,
|
||||
64 * 1024,
|
||||
);
|
||||
const profile = {
|
||||
subscriberCount: 2,
|
||||
@@ -48,6 +60,9 @@ test("realtime fanout keeps the YAML terminal timeout when durationMs is absent"
|
||||
assert.equal(effectiveProfile(profile, undefined).terminalTimeoutMs, 480_000);
|
||||
assert.equal(effectiveProfile(profile, 5_000).terminalTimeoutMs, 5_000);
|
||||
assert.deepEqual(effectiveProfile(profile, null).conditionalEventTypePairs, profile.conditionalEventTypePairs);
|
||||
const budget = effectiveProfile(profile, null).aggregateBudget as Record<string, number>;
|
||||
assert.ok(budget.perArrayEntries * budget.entrySlots <= budget.maxTotalEntries);
|
||||
assert.ok(budget.perEventBodyBytes * budget.bodyRetentionSlots <= budget.maxTotalBodyBytes);
|
||||
});
|
||||
|
||||
test("realtime fanout terminal completion does not require absent optional tool families", () => {
|
||||
@@ -137,12 +152,12 @@ test("realtime fanout validates optional tool events only when they occur", () =
|
||||
test("realtime fanout retries a YAML-bounded transient health navigation", async () => {
|
||||
const source = nodeWebObserveRunnerRealtimeSource();
|
||||
const build = new Function(
|
||||
"sleep",
|
||||
"interruptibleRunnerSleep",
|
||||
"isRetryableNavigationError",
|
||||
"baseUrl",
|
||||
`${source}\nreturn realtimeGotoHealth;`,
|
||||
) as (
|
||||
sleep: (milliseconds: number) => Promise<void>,
|
||||
interruptibleRunnerSleep: (milliseconds: number) => Promise<void>,
|
||||
isRetryableNavigationError: (message: string) => boolean,
|
||||
baseUrl: string,
|
||||
) => (page: { goto: () => Promise<{ status: () => number }> }, timeoutMs: number, profile: Record<string, number>, label: string) => Promise<Record<string, unknown>>;
|
||||
@@ -433,11 +448,11 @@ test("realtime replay joint quiet waits for a late formal Kafka envelope before
|
||||
let kafkaRows = [first];
|
||||
const productRows = [first, late];
|
||||
const build = new Function(
|
||||
"sleep",
|
||||
"interruptibleRunnerSleep",
|
||||
"Date",
|
||||
`${source}\nreturn realtimeWaitReplayedTraceJointQuiet;`,
|
||||
) as (
|
||||
sleep: () => Promise<void>,
|
||||
interruptibleRunnerSleep: () => Promise<void>,
|
||||
date: { now: () => number },
|
||||
) => (
|
||||
debug: Record<string, any>,
|
||||
@@ -495,11 +510,11 @@ test("realtime replay joint quiet rejects a permanent 31/32 split with bounded i
|
||||
const first = replayEnvelope("evt_first", 1);
|
||||
const late = replayEnvelope("evt_late", 2, true);
|
||||
const build = new Function(
|
||||
"sleep",
|
||||
"interruptibleRunnerSleep",
|
||||
"Date",
|
||||
`${source}\nreturn realtimeWaitReplayedTraceJointQuiet;`,
|
||||
) as (
|
||||
sleep: () => Promise<void>,
|
||||
interruptibleRunnerSleep: () => Promise<void>,
|
||||
date: { now: () => number },
|
||||
) => (
|
||||
debug: Record<string, any>,
|
||||
|
||||
@@ -23,6 +23,8 @@ async function validateRealtimeFanout(command) {
|
||||
if (!provider) throw new Error("validateRealtimeFanout requires provider");
|
||||
if (!firstPrompt || !secondPrompt) throw new Error("validateRealtimeFanout requires first and second prompt");
|
||||
const effective = realtimeFanoutEffectiveProfile(profile, command.durationMs);
|
||||
const aggregateCounters = { networkDroppedEntries: 0, valuesRedacted: true };
|
||||
realtimeAggregateLast = { commandId: command.id, profile: profileId, budget: effective.aggregateBudget, counters: aggregateCounters, status: "running", valuesRedacted: true };
|
||||
const reportDir = path.join(stateDir, "artifacts", "realtime-fanout", safeId(command.id));
|
||||
await mkdir(reportDir, { recursive: true, mode: 0o700 });
|
||||
const network = [];
|
||||
@@ -40,7 +42,10 @@ async function validateRealtimeFanout(command) {
|
||||
lastEventId: request.headers()["last-event-id"] || null,
|
||||
valuesRedacted: true,
|
||||
});
|
||||
if (network.length > 1000) network.splice(0, network.length - 1000);
|
||||
if (network.length > effective.aggregateBudget.perArrayEntries) {
|
||||
aggregateCounters.networkDroppedEntries += network.length - effective.aggregateBudget.perArrayEntries;
|
||||
network.splice(0, network.length - effective.aggregateBudget.perArrayEntries);
|
||||
}
|
||||
};
|
||||
page.on("request", requestListener);
|
||||
let subscriberA = null;
|
||||
@@ -130,7 +135,7 @@ async function validateRealtimeFanout(command) {
|
||||
subscriberB2 = await realtimeNewSubscriber(storageState, sessionId, "subscriber-b-reconnected", effective);
|
||||
const bReconnected = await realtimeWaitProductConnected(subscriberB2, effective.barrierTimeoutMs);
|
||||
realtimeAssertConnectedContract(bReconnected, sessionId, "subscriber-b-reconnected", effective, { requireRetainedEvents: effective.expectedProductSse.replayPriorTraceOnReconnect });
|
||||
await sleep(effective.reconnectQuietMs);
|
||||
await interruptibleRunnerSleep(effective.reconnectQuietMs);
|
||||
let reconnectBeforeSecond;
|
||||
if (effective.expectedProductSse.replayPriorTraceOnReconnect) {
|
||||
const firstReplayBeforeSecond = await realtimeWaitReplayedTraceJointQuiet(
|
||||
@@ -258,6 +263,13 @@ async function validateRealtimeFanout(command) {
|
||||
realtimeTurnSummary(first, firstDebug, productA, firstTerminal),
|
||||
realtimeTurnSummary(second, secondDebug, productB2, secondTerminalB),
|
||||
];
|
||||
aggregateCounters.productDroppedEntries = Number(productA.droppedEntries || 0) + Number(productB2.droppedEntries || 0);
|
||||
aggregateCounters.debugDroppedEntries = Number(firstDebug.droppedEntries || 0) + Number(secondDebug.droppedEntries || 0);
|
||||
aggregateCounters.productDroppedBodyEntries = Number(productA.droppedBodyEntries || 0) + Number(productB2.droppedBodyEntries || 0);
|
||||
aggregateCounters.productDroppedBodyBytes = Number(productA.droppedBodyBytes || 0) + Number(productB2.droppedBodyBytes || 0);
|
||||
aggregateCounters.debugDroppedBodyEntries = Number(firstDebug.droppedBodyEntries || 0) + Number(secondDebug.droppedBodyEntries || 0);
|
||||
aggregateCounters.debugDroppedBodyBytes = Number(firstDebug.droppedBodyBytes || 0) + Number(secondDebug.droppedBodyBytes || 0);
|
||||
realtimeAggregateLast = { ...realtimeAggregateLast, status: "completed", counters: aggregateCounters, valuesRedacted: true };
|
||||
Object.assign(report, {
|
||||
ok: true,
|
||||
status: "passed",
|
||||
@@ -290,6 +302,7 @@ async function validateRealtimeFanout(command) {
|
||||
valuesRedacted: true,
|
||||
},
|
||||
forbiddenRequests: { count: forbidden.length, rows: forbidden, valuesRedacted: true },
|
||||
aggregateBudget: { ...effective.aggregateBudget, counters: aggregateCounters, valuesRedacted: true },
|
||||
screenshots: [bBeforeShot, summaryScreenshot, subscriberAScreenshot, subscriberB2Screenshot],
|
||||
warmRunnerReused: first.runnerId && second.runnerId ? first.runnerId === second.runnerId : null,
|
||||
valuesRedacted: true,
|
||||
@@ -326,6 +339,7 @@ async function validateRealtimeFanout(command) {
|
||||
valuesRedacted: true,
|
||||
};
|
||||
} catch (error) {
|
||||
realtimeAggregateLast = { ...realtimeAggregateLast, status: "failed", counters: aggregateCounters, failure: errorSummary(error), valuesRedacted: true };
|
||||
Object.assign(report, {
|
||||
ok: false,
|
||||
status: "failed",
|
||||
@@ -367,6 +381,8 @@ async function validateExistingSessionRefresh(command) {
|
||||
const profile = realtimeFanoutProfiles[profileId];
|
||||
if (!profile || typeof profile !== "object") throw new Error("validateExistingSessionRefresh profile is not declared by the owning YAML: " + profileId);
|
||||
const effective = realtimeFanoutEffectiveProfile(profile, command.durationMs);
|
||||
const aggregateCounters = { networkDroppedEntries: 0, valuesRedacted: true };
|
||||
realtimeAggregateLast = { commandId: command.id, profile: profileId, budget: effective.aggregateBudget, counters: aggregateCounters, status: "running", valuesRedacted: true };
|
||||
if (effective.expectedProductSse.refreshHandoff !== true) throw new Error("validateExistingSessionRefresh requires a YAML profile with refresh handoff enabled");
|
||||
const reportDir = path.join(stateDir, "artifacts", "existing-session-refresh", safeId(command.id));
|
||||
await mkdir(reportDir, { recursive: true, mode: 0o700 });
|
||||
@@ -385,7 +401,10 @@ async function validateExistingSessionRefresh(command) {
|
||||
lastEventId: request.headers()["last-event-id"] || null,
|
||||
valuesRedacted: true,
|
||||
});
|
||||
if (network.length > 1000) network.splice(0, network.length - 1000);
|
||||
if (network.length > effective.aggregateBudget.perArrayEntries) {
|
||||
aggregateCounters.networkDroppedEntries += network.length - effective.aggregateBudget.perArrayEntries;
|
||||
network.splice(0, network.length - effective.aggregateBudget.perArrayEntries);
|
||||
}
|
||||
};
|
||||
const report = {
|
||||
ok: false,
|
||||
@@ -447,7 +466,7 @@ async function validateExistingSessionRefresh(command) {
|
||||
}
|
||||
phase = "live-handoff";
|
||||
await realtimeWaitForSameWorkbenchDom(before, effective.barrierTimeoutMs);
|
||||
await sleep(effective.reconnectQuietMs);
|
||||
await interruptibleRunnerSleep(effective.reconnectQuietMs);
|
||||
const after = await realtimeWorkbenchDomIdentity();
|
||||
realtimeAssertSameWorkbenchDom(before, after);
|
||||
const browserEvidence = await page.evaluate(() => JSON.parse(JSON.stringify(window.__unideskExistingSessionRefresh || {})));
|
||||
@@ -463,11 +482,16 @@ async function validateExistingSessionRefresh(command) {
|
||||
if (mainStreams[0].traceId !== null || mainStreams[0].afterSeq !== null || mainStreams[0].lastEventId !== null) throw new Error("main Workbench product SSE used a trace or cursor scope");
|
||||
if (forbidden.length > 0) throw new Error("forbidden recovery or polling requests observed: " + forbidden.map((item) => item.path).join(","));
|
||||
phase = "evidence-write";
|
||||
const screenshotFile = path.join(reportDir, "existing-session-refresh.png");
|
||||
await page.screenshot({ path: screenshotFile, fullPage: true, animations: "disabled", timeout: screenshotCaptureTimeoutMs });
|
||||
const screenshotMeta = await fileMeta(screenshotFile);
|
||||
screenshot = { kind: "existing-session-refresh-screenshot", path: screenshotFile, ...screenshotMeta, commandId: activeCommandId, valuesRedacted: true };
|
||||
await appendJsonl(files.artifacts, { ts: new Date().toISOString(), ...screenshot });
|
||||
const screenshotSkipped = reserveScreenshot("existing-session-refresh");
|
||||
if (screenshotSkipped !== null) {
|
||||
screenshot = screenshotSkipped;
|
||||
} else {
|
||||
const screenshotFile = path.join(reportDir, "existing-session-refresh.png");
|
||||
await page.screenshot({ path: screenshotFile, fullPage: false, animations: "disabled", timeout: screenshotCaptureTimeoutMs });
|
||||
const screenshotMeta = await fileMeta(screenshotFile);
|
||||
screenshot = { kind: "existing-session-refresh-screenshot", path: screenshotFile, ...screenshotMeta, commandId: activeCommandId, valuesRedacted: true };
|
||||
await appendJsonl(files.artifacts, { ts: new Date().toISOString(), ...screenshot });
|
||||
}
|
||||
const productSse = {
|
||||
requestCount: mainStreams.length,
|
||||
browserSourceCount: browserEvidence.sources.length,
|
||||
@@ -488,10 +512,12 @@ async function validateExistingSessionRefresh(command) {
|
||||
connected: connectedSummary,
|
||||
refreshReplay,
|
||||
productSse,
|
||||
aggregateBudget: { ...effective.aggregateBudget, counters: { ...aggregateCounters, browserDroppedEntries: Number(browserEvidence.droppedEntries || 0), browserDroppedBodyEntries: Number(browserEvidence.droppedBodyEntries || 0), browserDroppedBodyBytes: Number(browserEvidence.droppedBodyBytes || 0) }, valuesRedacted: true },
|
||||
forbiddenRequests: { count: 0, rows: [], valuesRedacted: true },
|
||||
screenshot,
|
||||
valuesRedacted: true,
|
||||
});
|
||||
realtimeAggregateLast = { ...realtimeAggregateLast, status: "completed", counters: { ...aggregateCounters, browserDroppedEntries: Number(browserEvidence.droppedEntries || 0), browserDroppedBodyEntries: Number(browserEvidence.droppedBodyEntries || 0), browserDroppedBodyBytes: Number(browserEvidence.droppedBodyBytes || 0) }, valuesRedacted: true };
|
||||
const artifacts = await realtimeWriteEvidenceArtifacts(reportDir, network, [], "existing-session-refresh");
|
||||
report.artifacts = artifacts;
|
||||
const reportArtifact = await realtimeWriteReport(reportDir, report, "existing-session-refresh-report");
|
||||
@@ -527,6 +553,7 @@ async function validateExistingSessionRefresh(command) {
|
||||
valuesRedacted: true,
|
||||
};
|
||||
} catch (error) {
|
||||
realtimeAggregateLast = { ...realtimeAggregateLast, status: "failed", counters: aggregateCounters, failure: errorSummary(error), valuesRedacted: true };
|
||||
const failure = realtimeExistingRefreshFailure(phase, error);
|
||||
if (!screenshot) {
|
||||
screenshot = await captureScreenshot("existing-session-refresh-failed-" + safeId(command.id))
|
||||
@@ -649,10 +676,17 @@ async function realtimeArmExistingSessionRefreshProbe(targetPage, profile) {
|
||||
let config;
|
||||
try { config = JSON.parse(raw); } catch { return; }
|
||||
const NativeEventSource = window.EventSource;
|
||||
const state = { sources: [], connected: [], businessEventCount: 0, failures: [], errors: [] };
|
||||
const state = { sources: [], connected: [], businessEventCount: 0, failures: [], errors: [], droppedEntries: 0, droppedBodyEntries: 0, droppedBodyBytes: 0 };
|
||||
const maxEntries = Math.max(1, Number(config.maxEntries || 1));
|
||||
const maxEventTextChars = Math.max(1, Number(config.maxEventTextChars || 1));
|
||||
const bodyBytes = (value) => new TextEncoder().encode(String(value || "")).byteLength;
|
||||
const pushBounded = (items, value) => {
|
||||
items.push(value);
|
||||
if (items.length > maxEntries) { state.droppedEntries += items.length - maxEntries; items.splice(0, items.length - maxEntries); }
|
||||
};
|
||||
window.__unideskExistingSessionRefresh = state;
|
||||
if (typeof NativeEventSource !== "function") {
|
||||
state.errors.push({ kind: "eventsource-unavailable" });
|
||||
pushBounded(state.errors, { kind: "eventsource-unavailable" });
|
||||
return;
|
||||
}
|
||||
class ObservedEventSource extends NativeEventSource {
|
||||
@@ -661,17 +695,23 @@ async function realtimeArmExistingSessionRefreshProbe(targetPage, profile) {
|
||||
let pathname = "";
|
||||
try { pathname = new URL(String(url), window.location.href).pathname; } catch {}
|
||||
if (pathname !== config.productEventsPath) return;
|
||||
state.sources.push({ url: String(url), opened: 0 });
|
||||
pushBounded(state.sources, { url: String(url), opened: 0 });
|
||||
const source = state.sources[state.sources.length - 1];
|
||||
this.addEventListener("open", () => { source.opened += 1; });
|
||||
this.addEventListener(config.productReadyEvent, (event) => {
|
||||
try { state.connected.push(JSON.parse(event.data)); } catch { state.errors.push({ kind: "connected-parse" }); }
|
||||
const raw = String(event.data || "");
|
||||
const bytes = bodyBytes(raw);
|
||||
if (bytes > maxEventTextChars) { state.droppedBodyEntries += 1; state.droppedBodyBytes += bytes; pushBounded(state.errors, { kind: "connected-too-large", dataBytes: bytes }); return; }
|
||||
try { pushBounded(state.connected, JSON.parse(raw)); } catch { pushBounded(state.errors, { kind: "connected-parse" }); }
|
||||
});
|
||||
this.addEventListener(config.businessEvent, () => { state.businessEventCount += 1; });
|
||||
this.addEventListener("workbench.error", (event) => {
|
||||
try { state.failures.push(JSON.parse(event.data)); } catch { state.errors.push({ kind: "workbench-error-parse" }); }
|
||||
const raw = String(event.data || "");
|
||||
const bytes = bodyBytes(raw);
|
||||
if (bytes > maxEventTextChars) { state.droppedBodyEntries += 1; state.droppedBodyBytes += bytes; pushBounded(state.errors, { kind: "workbench-error-too-large", dataBytes: bytes }); return; }
|
||||
try { pushBounded(state.failures, JSON.parse(raw)); } catch { pushBounded(state.errors, { kind: "workbench-error-parse" }); }
|
||||
});
|
||||
this.addEventListener("error", () => { state.errors.push({ kind: "eventsource-error" }); });
|
||||
this.addEventListener("error", () => { pushBounded(state.errors, { kind: "eventsource-error" }); });
|
||||
}
|
||||
}
|
||||
Object.defineProperty(ObservedEventSource, "CONNECTING", { value: NativeEventSource.CONNECTING });
|
||||
@@ -685,12 +725,16 @@ async function realtimeArmExistingSessionRefreshProbe(targetPage, profile) {
|
||||
productEventsPath: profile.productEventsPath,
|
||||
productReadyEvent: profile.productReadyEvent,
|
||||
businessEvent: profile.businessEvent,
|
||||
maxEntries: profile.aggregateBudget.perArrayEntries,
|
||||
maxEventTextChars: profile.aggregateBudget.perEventBodyBytes,
|
||||
});
|
||||
}
|
||||
|
||||
async function realtimeWorkbenchDomIdentity() {
|
||||
return page.evaluate(() => {
|
||||
const cards = Array.from(document.querySelectorAll("#conversation-list > .message-card"));
|
||||
return page.evaluate((maxRows) => {
|
||||
const cardNodes = document.querySelectorAll("#conversation-list > .message-card");
|
||||
if (cardNodes.length > maxRows) return { overflow: true, messageCount: cardNodes.length, maxRows, identities: [], missingIdentityRows: [], duplicateIdentities: [], traceIds: [], rows: [], valuesRedacted: true };
|
||||
const cards = Array.from(cardNodes);
|
||||
const rows = cards.map((element, index) => ({
|
||||
index,
|
||||
role: element.getAttribute("data-role") || null,
|
||||
@@ -709,10 +753,11 @@ async function realtimeWorkbenchDomIdentity() {
|
||||
rows,
|
||||
valuesRedacted: true,
|
||||
};
|
||||
});
|
||||
}, maxDomEvidenceRows);
|
||||
}
|
||||
|
||||
function realtimeAssertSameWorkbenchDom(before, after) {
|
||||
if (before.overflow === true || after.overflow === true) throw new Error("Workbench DOM identity evidence exceeds the YAML row cap");
|
||||
if (before.missingIdentityRows.length > 0 || after.missingIdentityRows.length > 0) throw new Error("Workbench DOM contains rows without a stable message identity");
|
||||
if (before.duplicateIdentities.length > 0 || after.duplicateIdentities.length > 0) throw new Error("Workbench DOM contains duplicate message identities");
|
||||
if (before.messageCount !== after.messageCount) throw new Error("Workbench message count changed across refresh: " + before.messageCount + " -> " + after.messageCount);
|
||||
@@ -771,6 +816,21 @@ function realtimeFanoutEffectiveProfile(profile, durationMs) {
|
||||
const terminalTimeoutMs = hasDurationOverride
|
||||
? Math.min(Number(profile.terminalTimeoutMs), Math.max(1000, Number(durationMs)))
|
||||
: Number(profile.terminalTimeoutMs);
|
||||
const entrySlots = 17;
|
||||
const eventStreamSlots = 8;
|
||||
const perArrayEntries = Math.max(1, Math.min(maxRealtimeEventEntries, Math.floor(maxRealtimeTotalEntries / entrySlots)));
|
||||
const bodyRetentionSlots = eventStreamSlots * (perArrayEntries + 1);
|
||||
const aggregateBudget = {
|
||||
maxTotalEntries: maxRealtimeTotalEntries,
|
||||
maxTotalBodyBytes: maxRealtimeTotalBodyBytes,
|
||||
entrySlots,
|
||||
eventStreamSlots,
|
||||
perArrayEntries,
|
||||
bodyRetentionSlots,
|
||||
perEventBodyBytes: Math.max(1, Math.min(maxRealtimeEventBodyBytes, Math.floor(maxRealtimeTotalBodyBytes / bodyRetentionSlots))),
|
||||
allocation: "partitioned-across-network-product-debug-active-arrays",
|
||||
valuesRedacted: true,
|
||||
};
|
||||
return {
|
||||
...profile,
|
||||
debugStreams,
|
||||
@@ -785,6 +845,7 @@ function realtimeFanoutEffectiveProfile(profile, durationMs) {
|
||||
conditionalEventTypePairs: profile.conditionalEventTypePairs && typeof profile.conditionalEventTypePairs === "object" ? sanitize(profile.conditionalEventTypePairs) : {},
|
||||
expectedProductSse: sanitize(expectedProductSse),
|
||||
expectedKafka: sanitize(expectedKafka),
|
||||
aggregateBudget,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -795,7 +856,14 @@ async function realtimeNewSubscriber(storageState, sessionId, label, profile) {
|
||||
const key = label + "-" + sessionId;
|
||||
await subscriberPage.evaluate(({ key: streamKey, sessionId: scopedSessionId, profile: inputProfile }) => {
|
||||
const root = window.__unideskRealtimeFanoutProducts ??= {};
|
||||
const state = { connected: null, connectedEventCount: 0, openCount: 0, records: [], errors: [], source: null };
|
||||
const state = { connected: null, connectedEventCount: 0, openCount: 0, records: [], errors: [], source: null, droppedEntries: 0, droppedBodyEntries: 0, droppedBodyBytes: 0 };
|
||||
const maxEntries = Math.max(1, Number(inputProfile.maxEntries || 1));
|
||||
const maxEventTextChars = Math.max(1, Number(inputProfile.maxEventTextChars || 1));
|
||||
const bodyBytes = (value) => new TextEncoder().encode(String(value || "")).byteLength;
|
||||
const pushBounded = (items, value) => {
|
||||
items.push(value);
|
||||
if (items.length > maxEntries) { state.droppedEntries += items.length - maxEntries; items.splice(0, items.length - maxEntries); }
|
||||
};
|
||||
root[streamKey] = state;
|
||||
const firstText = (...values) => {
|
||||
for (const value of values) if (typeof value === "string" && value.trim()) return value.trim();
|
||||
@@ -811,12 +879,15 @@ async function realtimeNewSubscriber(storageState, sessionId, label, profile) {
|
||||
state.source = source;
|
||||
source.onopen = () => { state.openCount += 1; };
|
||||
const record = (eventName, raw) => {
|
||||
const textRaw = String(raw || "");
|
||||
const bytes = bodyBytes(textRaw);
|
||||
if (bytes > maxEventTextChars) { state.droppedBodyEntries += 1; state.droppedBodyBytes += bytes; pushBounded(state.errors, { at: Date.now(), event: "event-too-large", dataBytes: bytes }); return; }
|
||||
let value;
|
||||
try { value = JSON.parse(raw); } catch { return; }
|
||||
try { value = JSON.parse(textRaw); } catch { return; }
|
||||
const nested = value?.event && typeof value.event === "object" ? value.event : {};
|
||||
const context = value?.context && typeof value.context === "object" ? value.context : {};
|
||||
const eventType = firstText(nested.eventType, nested.type, value.eventType, value.type);
|
||||
state.records.push({
|
||||
pushBounded(state.records, {
|
||||
eventName,
|
||||
at: Date.now(),
|
||||
schema: firstText(value.schema),
|
||||
@@ -831,19 +902,21 @@ async function realtimeNewSubscriber(storageState, sessionId, label, profile) {
|
||||
userMessageId: firstText(value.userMessageId, nested.userMessageId, nested.messageId),
|
||||
terminal: value.terminal === true || nested.terminal === true || eventType === "terminal" || eventType === "terminal_status",
|
||||
status: firstText(nested.status, value.status),
|
||||
envelopeFingerprint: fingerprint(raw),
|
||||
envelopeFingerprint: fingerprint(textRaw),
|
||||
valuesRedacted: true,
|
||||
});
|
||||
if (state.records.length > 800) state.records.splice(0, state.records.length - 800);
|
||||
};
|
||||
source.addEventListener(inputProfile.productReadyEvent, (event) => {
|
||||
state.connectedEventCount += 1;
|
||||
try { state.connected = JSON.parse(event.data); } catch { state.connected = { parseError: true }; }
|
||||
const raw = String(event.data || "");
|
||||
const bytes = bodyBytes(raw);
|
||||
if (bytes > maxEventTextChars) { state.droppedBodyEntries += 1; state.droppedBodyBytes += bytes; state.connected = { tooLarge: true, dataBytes: bytes }; return; }
|
||||
try { state.connected = JSON.parse(raw); } catch { state.connected = { parseError: true }; }
|
||||
});
|
||||
source.addEventListener(inputProfile.businessEvent, (event) => record(inputProfile.businessEvent, event.data));
|
||||
source.addEventListener("workbench.error", (event) => state.errors.push({ at: Date.now(), event: "workbench.error", dataBytes: String(event.data || "").length }));
|
||||
source.onerror = () => state.errors.push({ at: Date.now(), event: "transport.error" });
|
||||
}, { key, sessionId, profile: { productEventsPath: profile.productEventsPath, productReadyEvent: profile.productReadyEvent, businessEvent: profile.businessEvent } });
|
||||
source.addEventListener("workbench.error", (event) => pushBounded(state.errors, { at: Date.now(), event: "workbench.error", dataBytes: String(event.data || "").length }));
|
||||
source.onerror = () => pushBounded(state.errors, { at: Date.now(), event: "transport.error" });
|
||||
}, { key, sessionId, profile: { productEventsPath: profile.productEventsPath, productReadyEvent: profile.productReadyEvent, businessEvent: profile.businessEvent, maxEntries: profile.aggregateBudget.perArrayEntries, maxEventTextChars: profile.aggregateBudget.perEventBodyBytes } });
|
||||
return { label, key, context: subscriberContext, page: subscriberPage };
|
||||
}
|
||||
|
||||
@@ -872,7 +945,7 @@ async function realtimeGotoHealth(targetPage, timeoutMs, profile, label) {
|
||||
wrapped.details = { ...(wrapped.details || {}), label, attempts, valuesRedacted: true };
|
||||
throw wrapped;
|
||||
}
|
||||
await sleep(profile.navigationRetryDelayMs * attempt);
|
||||
await interruptibleRunnerSleep(profile.navigationRetryDelayMs * attempt);
|
||||
}
|
||||
}
|
||||
throw new Error(label + " health navigation retry exhausted");
|
||||
@@ -943,8 +1016,8 @@ async function realtimeRunTurn(input) {
|
||||
if (!admissionOutcome.response) throw new Error("realtime turn admission response failed: " + String(admissionOutcome.error?.message || admissionOutcome.error || "unknown"));
|
||||
const admission = admissionOutcome.response;
|
||||
if (admission.status() < 200 || admission.status() >= 300) throw new Error("realtime turn admission returned HTTP " + admission.status());
|
||||
let admissionBody = {};
|
||||
try { admissionBody = await admission.json(); } catch {}
|
||||
const admissionBodyResult = await boundedResponseJson(admission);
|
||||
const admissionBody = admissionBodyResult.value || {};
|
||||
const canonicalTraceId = realtimeFirstText(admissionBody?.traceId, admissionBody?.result?.traceId, captured.traceId);
|
||||
if (canonicalTraceId !== captured.traceId) throw new Error("canonical trace changed after ready barrier");
|
||||
const nonTerminal = [];
|
||||
@@ -970,7 +1043,14 @@ async function realtimeRunTurn(input) {
|
||||
async function realtimeOpenDebugStreams(debug, key, traceId, profile) {
|
||||
await debug.page.evaluate(({ key: streamKey, traceId: scopedTraceId, profile: inputProfile }) => {
|
||||
const root = window.__unideskRealtimeFanoutDebug ??= {};
|
||||
const state = { connected: {}, connectedEventCount: {}, openCount: {}, records: {}, errors: {}, sources: {} };
|
||||
const state = { connected: {}, connectedEventCount: {}, openCount: {}, records: {}, errors: {}, sources: {}, droppedEntries: 0, droppedBodyEntries: 0, droppedBodyBytes: 0 };
|
||||
const maxEntries = Math.max(1, Number(inputProfile.maxEntries || 1));
|
||||
const maxEventTextChars = Math.max(1, Number(inputProfile.maxEventTextChars || 1));
|
||||
const bodyBytes = (value) => new TextEncoder().encode(String(value || "")).byteLength;
|
||||
const pushBounded = (items, value) => {
|
||||
items.push(value);
|
||||
if (items.length > maxEntries) { state.droppedEntries += items.length - maxEntries; items.splice(0, items.length - maxEntries); }
|
||||
};
|
||||
root[streamKey] = state;
|
||||
const firstText = (...values) => {
|
||||
for (const value of values) if (typeof value === "string" && value.trim()) return value.trim();
|
||||
@@ -1001,11 +1081,17 @@ async function realtimeOpenDebugStreams(debug, key, traceId, profile) {
|
||||
source.onopen = () => { state.openCount[stream] += 1; };
|
||||
source.addEventListener(inputProfile.debugReadyEvent, (event) => {
|
||||
state.connectedEventCount[stream] += 1;
|
||||
try { state.connected[stream] = JSON.parse(event.data); } catch { state.connected[stream] = { parseError: true }; }
|
||||
const raw = String(event.data || "");
|
||||
const bytes = bodyBytes(raw);
|
||||
if (bytes > maxEventTextChars) { state.droppedBodyEntries += 1; state.droppedBodyBytes += bytes; state.connected[stream] = { tooLarge: true, dataBytes: bytes }; return; }
|
||||
try { state.connected[stream] = JSON.parse(raw); } catch { state.connected[stream] = { parseError: true }; }
|
||||
});
|
||||
source.addEventListener("hwlab.kafka.event", (event) => {
|
||||
const raw = String(event.data || "");
|
||||
const bytes = bodyBytes(raw);
|
||||
if (bytes > maxEventTextChars) { state.droppedBodyEntries += 1; state.droppedBodyBytes += bytes; pushBounded(state.errors[stream], { at: Date.now(), kind: "event-too-large", dataBytes: bytes }); return; }
|
||||
let outer;
|
||||
try { outer = JSON.parse(event.data); } catch { return; }
|
||||
try { outer = JSON.parse(raw); } catch { return; }
|
||||
const value = outer?.value && typeof outer.value === "object" ? outer.value : {};
|
||||
const nested = value?.event && typeof value.event === "object" ? value.event : {};
|
||||
const payload = nested?.payload && typeof nested.payload === "object" ? nested.payload : {};
|
||||
@@ -1017,7 +1103,7 @@ async function realtimeOpenDebugStreams(debug, key, traceId, profile) {
|
||||
: stream === "hwlab"
|
||||
? firstText(nested.eventType, nested.type, value.eventType)
|
||||
: firstText(value.eventType, value.stdio?.method);
|
||||
state.records[stream].push({
|
||||
pushBounded(state.records[stream], {
|
||||
stream,
|
||||
at: Date.now(),
|
||||
topic: firstText(outer.topic),
|
||||
@@ -1042,16 +1128,17 @@ async function realtimeOpenDebugStreams(debug, key, traceId, profile) {
|
||||
envelopeFingerprint: stream === "hwlab" ? fingerprint(JSON.stringify(value)) : null,
|
||||
valuesRedacted: true,
|
||||
});
|
||||
if (state.records[stream].length > 1000) state.records[stream].splice(0, state.records[stream].length - 1000);
|
||||
});
|
||||
source.addEventListener("hwlab.kafka.error", (event) => state.errors[stream].push({ at: Date.now(), dataBytes: String(event.data || "").length }));
|
||||
source.onerror = () => state.errors[stream].push({ at: Date.now(), transport: true });
|
||||
source.addEventListener("hwlab.kafka.error", (event) => pushBounded(state.errors[stream], { at: Date.now(), dataBytes: String(event.data || "").length }));
|
||||
source.onerror = () => pushBounded(state.errors[stream], { at: Date.now(), transport: true });
|
||||
}
|
||||
}, { key, traceId, profile: {
|
||||
debugStreams: profile.debugStreams,
|
||||
debugEventsPath: profile.debugEventsPath,
|
||||
debugReadyEvent: profile.debugReadyEvent,
|
||||
fromBeginning: profile.fromBeginning,
|
||||
maxEntries: profile.aggregateBudget.perArrayEntries,
|
||||
maxEventTextChars: profile.aggregateBudget.perEventBodyBytes,
|
||||
} });
|
||||
}
|
||||
|
||||
@@ -1143,7 +1230,7 @@ async function realtimeWaitTurnStable(debug, key, traceId, subscribers, profile)
|
||||
} else if (Date.now() - stableSince >= profile.terminalQuietMs) {
|
||||
return { debug: debugSnapshot, products: productSnapshots };
|
||||
}
|
||||
await sleep(250);
|
||||
await interruptibleRunnerSleep(250);
|
||||
}
|
||||
throw new Error("realtime debug/product streams did not become quiet after terminal");
|
||||
}
|
||||
@@ -1169,7 +1256,7 @@ async function realtimeProductSnapshot(subscriber) {
|
||||
return subscriber.page.evaluate((key) => {
|
||||
const state = window.__unideskRealtimeFanoutProducts?.[key];
|
||||
if (!state) return { connected: null, connectedEventCount: 0, openCount: 0, records: [], errors: [{ missing: true }] };
|
||||
return { connected: state.connected ? JSON.parse(JSON.stringify(state.connected)) : null, connectedEventCount: state.connectedEventCount, openCount: state.openCount, records: JSON.parse(JSON.stringify(state.records)), errors: JSON.parse(JSON.stringify(state.errors)) };
|
||||
return { connected: state.connected ? JSON.parse(JSON.stringify(state.connected)) : null, connectedEventCount: state.connectedEventCount, openCount: state.openCount, records: JSON.parse(JSON.stringify(state.records)), errors: JSON.parse(JSON.stringify(state.errors)), droppedEntries: Number(state.droppedEntries || 0), droppedBodyEntries: Number(state.droppedBodyEntries || 0), droppedBodyBytes: Number(state.droppedBodyBytes || 0) };
|
||||
}, subscriber.key);
|
||||
}
|
||||
|
||||
@@ -1177,7 +1264,7 @@ async function realtimeDebugSnapshot(debug, key) {
|
||||
return debug.page.evaluate((streamKey) => {
|
||||
const state = window.__unideskRealtimeFanoutDebug?.[streamKey];
|
||||
if (!state) return { connected: {}, connectedEventCount: {}, openCount: {}, records: { stdio: [], agentrun: [], hwlab: [] }, errors: { stdio: [], agentrun: [], hwlab: [] } };
|
||||
return { connected: JSON.parse(JSON.stringify(state.connected)), connectedEventCount: JSON.parse(JSON.stringify(state.connectedEventCount)), openCount: JSON.parse(JSON.stringify(state.openCount)), records: JSON.parse(JSON.stringify(state.records)), errors: JSON.parse(JSON.stringify(state.errors)) };
|
||||
return { connected: JSON.parse(JSON.stringify(state.connected)), connectedEventCount: JSON.parse(JSON.stringify(state.connectedEventCount)), openCount: JSON.parse(JSON.stringify(state.openCount)), records: JSON.parse(JSON.stringify(state.records)), errors: JSON.parse(JSON.stringify(state.errors)), droppedEntries: Number(state.droppedEntries || 0), droppedBodyEntries: Number(state.droppedBodyEntries || 0), droppedBodyBytes: Number(state.droppedBodyBytes || 0) };
|
||||
}, key);
|
||||
}
|
||||
|
||||
@@ -1191,6 +1278,9 @@ async function realtimeAllDebugSnapshots(debug) {
|
||||
openCount: JSON.parse(JSON.stringify(state.openCount || {})),
|
||||
records: JSON.parse(JSON.stringify(state.records || {})),
|
||||
errors: JSON.parse(JSON.stringify(state.errors || {})),
|
||||
droppedEntries: Number(state.droppedEntries || 0),
|
||||
droppedBodyEntries: Number(state.droppedBodyEntries || 0),
|
||||
droppedBodyBytes: Number(state.droppedBodyBytes || 0),
|
||||
}));
|
||||
});
|
||||
}
|
||||
@@ -1282,7 +1372,7 @@ async function realtimeWaitReplayedTraceJointQuiet(debug, key, subscriber, trace
|
||||
if (comparison.equal && terminalSeen && sourceTransportStable && quietForMs >= profile.terminalQuietMs) {
|
||||
return { debug: debugSnapshot, product: productSnapshot, comparison, quietForMs, valuesRedacted: true };
|
||||
}
|
||||
await sleep(250);
|
||||
await interruptibleRunnerSleep(250);
|
||||
}
|
||||
const error = new Error(label + " Kafka source and product replay envelope multisets did not converge before the joint quiet timeout");
|
||||
error.details = {
|
||||
@@ -1589,6 +1679,8 @@ function realtimeTurnSummary(turn, debug, product, terminal) {
|
||||
}
|
||||
|
||||
async function realtimeCaptureSubscriberScreenshot(targetPage, reportDir, name, summary) {
|
||||
const screenshotSkipped = reserveScreenshot("realtime-fanout-" + name);
|
||||
if (screenshotSkipped !== null) return screenshotSkipped;
|
||||
await targetPage.evaluate((value) => {
|
||||
document.documentElement.style.background = "#0b1020";
|
||||
document.body.innerHTML = "";
|
||||
@@ -1598,7 +1690,7 @@ async function realtimeCaptureSubscriberScreenshot(targetPage, reportDir, name,
|
||||
document.body.appendChild(pre);
|
||||
}, summary);
|
||||
const file = path.join(reportDir, name);
|
||||
await targetPage.screenshot({ path: file, fullPage: true, animations: "disabled", timeout: screenshotCaptureTimeoutMs });
|
||||
await targetPage.screenshot({ path: file, fullPage: false, animations: "disabled", timeout: screenshotCaptureTimeoutMs });
|
||||
const meta = await fileMeta(file);
|
||||
const artifact = { kind: "realtime-fanout-screenshot", path: file, name, byteCount: meta.byteCount, sha256: meta.sha256, commandId: activeCommandId, valuesRedacted: true };
|
||||
await appendJsonl(files.artifacts, { ts: new Date().toISOString(), ...artifact });
|
||||
@@ -1679,7 +1771,7 @@ async function realtimePoll(read, timeoutMs, label) {
|
||||
while (Date.now() < deadline) {
|
||||
const value = await read();
|
||||
if (value) return value;
|
||||
await sleep(250);
|
||||
await interruptibleRunnerSleep(250);
|
||||
}
|
||||
throw new Error(label + " timed out after " + timeoutMs + "ms");
|
||||
}
|
||||
|
||||
@@ -73,14 +73,34 @@ async function writeManifest(extra = {}) {
|
||||
maxPerformanceEntries,
|
||||
maxBrowserProcessHistoryEntries,
|
||||
maxBrowserFreezeSignalHistoryEntries,
|
||||
maxRealtimeEventEntries,
|
||||
maxRealtimeEventBodyBytes,
|
||||
maxDomEvidenceRows,
|
||||
maxProcessScanEntries,
|
||||
maxPerformanceCaptureMs,
|
||||
responseBodyMaxBytes,
|
||||
webPerformanceRequestBodyMaxBytes,
|
||||
maxJsonlQueueEntries,
|
||||
maxJsonlQueueBytes,
|
||||
maxJsonlLineBytes,
|
||||
jsonlFlushTimeoutMs,
|
||||
maxResponseBodyInFlight,
|
||||
maxPassiveListenerTasks,
|
||||
passiveTaskFlushTimeoutMs,
|
||||
closeStepTimeoutMs,
|
||||
maxProcessReadConcurrency,
|
||||
maxRealtimeTotalEntries,
|
||||
maxRealtimeTotalBodyBytes,
|
||||
valuesRedacted: true,
|
||||
},
|
||||
alertThresholds,
|
||||
browserFreezePolicy,
|
||||
projectManagement,
|
||||
jsonlRotation,
|
||||
jsonlWriter: jsonlWriterStats(),
|
||||
responseBodies: responseBodyStats(),
|
||||
passiveTasks: passiveTaskStats(),
|
||||
realtimeAggregate: realtimeAggregateLast,
|
||||
commandDirs: dirs,
|
||||
artifacts: files,
|
||||
safety: { pureClient: true, inboundApi: false, database: false, queueConsumer: false, k8sWorkload: false, valuesRedacted: true, secretValuesPrinted: false },
|
||||
@@ -115,8 +135,24 @@ async function writeHeartbeat(extra = {}) {
|
||||
maxPerformanceEntries,
|
||||
maxBrowserProcessHistoryEntries,
|
||||
maxBrowserFreezeSignalHistoryEntries,
|
||||
maxRealtimeEventEntries,
|
||||
maxRealtimeEventBodyBytes,
|
||||
maxDomEvidenceRows,
|
||||
maxProcessScanEntries,
|
||||
maxPerformanceCaptureMs,
|
||||
responseBodyMaxBytes,
|
||||
webPerformanceRequestBodyMaxBytes,
|
||||
maxJsonlQueueEntries,
|
||||
maxJsonlQueueBytes,
|
||||
maxJsonlLineBytes,
|
||||
jsonlFlushTimeoutMs,
|
||||
maxResponseBodyInFlight,
|
||||
maxPassiveListenerTasks,
|
||||
passiveTaskFlushTimeoutMs,
|
||||
closeStepTimeoutMs,
|
||||
maxProcessReadConcurrency,
|
||||
maxRealtimeTotalEntries,
|
||||
maxRealtimeTotalBodyBytes,
|
||||
valuesRedacted: true,
|
||||
},
|
||||
lastObserverRefreshAt: Number.isFinite(lastObserverRefreshAtMs) ? new Date(lastObserverRefreshAtMs).toISOString() : null,
|
||||
@@ -124,6 +160,10 @@ async function writeHeartbeat(extra = {}) {
|
||||
sampleSeq,
|
||||
commandSeq,
|
||||
activeCommandId,
|
||||
jsonlWriter: jsonlWriterStats(),
|
||||
responseBodies: responseBodyStats(),
|
||||
passiveTasks: passiveTaskStats(),
|
||||
realtimeAggregate: realtimeAggregateLast,
|
||||
updatedAt: new Date().toISOString(),
|
||||
uptimeMs: Date.now() - startedAtMs,
|
||||
...extra,
|
||||
@@ -131,6 +171,22 @@ async function writeHeartbeat(extra = {}) {
|
||||
await writeFile(files.heartbeat, JSON.stringify(heartbeat, null, 2) + "\n", { mode: 0o600 });
|
||||
}
|
||||
|
||||
async function writeTerminalResourceStats(extra = {}) {
|
||||
for (const file of [files.manifest, files.heartbeat]) {
|
||||
let existing = {};
|
||||
try { existing = JSON.parse(await readFile(file, "utf8")); } catch {}
|
||||
await writeFile(file, JSON.stringify({
|
||||
...existing,
|
||||
jsonlWriter: jsonlWriterStats(),
|
||||
responseBodies: responseBodyStats(),
|
||||
passiveTasks: passiveTaskStats(),
|
||||
terminalResources: sanitize(extra),
|
||||
updatedAt: new Date().toISOString(),
|
||||
valuesRedacted: true,
|
||||
}, null, 2) + "\n", { mode: 0o600 });
|
||||
}
|
||||
}
|
||||
|
||||
function startHeartbeatPulse() {
|
||||
const timer = setInterval(() => {
|
||||
if (stopping) return;
|
||||
@@ -410,62 +466,96 @@ async function triggerBrowserFreezeBlocker(trigger) {
|
||||
}
|
||||
|
||||
async function terminateBrowserForFreezeBlocker(blocker) {
|
||||
const childProcess = browser && typeof browser.process === "function" ? browser.process() : null;
|
||||
const pid = Number(childProcess?.pid);
|
||||
if (!Number.isFinite(pid) || pid <= 0) {
|
||||
return { ok: false, reason: "browser-process-unavailable", blockerKind: blocker.kind, valuesRedacted: true };
|
||||
const result = await terminateTrackedBrowserProcesses("freeze-blocker:" + String(blocker.kind || "unknown"));
|
||||
return { ...result, blockerKind: blocker.kind, valuesRedacted: true };
|
||||
}
|
||||
|
||||
function rememberTrackedBrowserProcesses(processes) {
|
||||
const merged = new Map(trackedBrowserProcessIdentities.map((item) => [item.pid + ":" + item.startTicks, item]));
|
||||
for (const item of Array.isArray(processes) ? processes : []) {
|
||||
if (!Number.isInteger(item.pid) || !Number.isInteger(item.startTicks) || !item.role) continue;
|
||||
merged.set(item.pid + ":" + item.startTicks, {
|
||||
pid: item.pid,
|
||||
ppid: item.ppid,
|
||||
pgid: item.pgid,
|
||||
sid: item.sid,
|
||||
startTicks: item.startTicks,
|
||||
role: item.role,
|
||||
commandHash: item.commandHash || null,
|
||||
valuesRedacted: true,
|
||||
});
|
||||
}
|
||||
trackedBrowserProcessIdentities = [...merged.values()].slice(-Math.min(maxProcessScanEntries, 1024));
|
||||
}
|
||||
|
||||
async function matchingTrackedBrowserProcesses() {
|
||||
const rows = await Promise.all(trackedBrowserProcessIdentities.map(async (identity) => {
|
||||
const current = await readOneProcProcessStat(identity.pid).catch(() => null);
|
||||
return current && current.startTicks === identity.startTicks ? { ...identity, currentPpid: current.ppid, currentPgid: current.pgid, currentSid: current.sid } : null;
|
||||
}));
|
||||
return rows.filter(Boolean);
|
||||
}
|
||||
|
||||
async function waitForTrackedBrowserExit(timeoutMs, pollIntervalMs) {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
let remaining = await matchingTrackedBrowserProcesses();
|
||||
while (remaining.length > 0 && Date.now() <= deadline) {
|
||||
await boundedCleanupSleep(pollIntervalMs);
|
||||
remaining = await matchingTrackedBrowserProcesses();
|
||||
}
|
||||
return remaining;
|
||||
}
|
||||
|
||||
async function terminateTrackedBrowserProcesses(reason, requiredDiscovery = null) {
|
||||
let refreshDiscovery;
|
||||
try {
|
||||
const summary = await collectChromiumProcessSummary(null);
|
||||
refreshDiscovery = { ok: true, processScan: summary.processScan, chromiumProcessCount: summary.chromiumProcessCount, valuesRedacted: true };
|
||||
} catch (error) {
|
||||
refreshDiscovery = { ok: false, processScan: lastProcessScan, error: errorSummary(error), valuesRedacted: true };
|
||||
}
|
||||
const discovery = requiredDiscovery || refreshDiscovery;
|
||||
const identityScanUnavailable = browserLaunchAttempted === true
|
||||
&& (discovery?.ok !== true || discovery?.processScan?.complete !== true);
|
||||
const initial = await matchingTrackedBrowserProcesses();
|
||||
const result = {
|
||||
ok: false,
|
||||
pid: Math.floor(pid),
|
||||
ok: initial.length === 0 && !identityScanUnavailable,
|
||||
reason,
|
||||
browserLaunchAttempted,
|
||||
identityScanUnavailable,
|
||||
requiredDiscovery: discovery,
|
||||
refreshDiscovery,
|
||||
trackedIdentityCount: trackedBrowserProcessIdentities.length,
|
||||
initialLiveCount: initial.length,
|
||||
gracefulSignal: browserFreezePolicy.kill.gracefulSignal,
|
||||
forceSignal: browserFreezePolicy.kill.forceSignal,
|
||||
graceMs: browserFreezePolicy.kill.graceMs,
|
||||
pollIntervalMs: browserFreezePolicy.kill.pollIntervalMs,
|
||||
gracefulSent: false,
|
||||
forceSent: false,
|
||||
exitedAfterGrace: false,
|
||||
exitedAfterForce: false,
|
||||
gracefulSentCount: 0,
|
||||
forceSentCount: 0,
|
||||
remainingIdentityCount: initial.length,
|
||||
valuesRedacted: true,
|
||||
};
|
||||
try {
|
||||
childProcess.kill(browserFreezePolicy.kill.gracefulSignal);
|
||||
result.gracefulSent = true;
|
||||
} catch (error) {
|
||||
result.gracefulError = errorSummary(error);
|
||||
}
|
||||
result.exitedAfterGrace = await waitForPidExit(result.pid, browserFreezePolicy.kill.graceMs, browserFreezePolicy.kill.pollIntervalMs);
|
||||
if (!result.exitedAfterGrace) {
|
||||
for (const item of [...initial].sort((a, b) => b.pid - a.pid)) {
|
||||
try {
|
||||
childProcess.kill(browserFreezePolicy.kill.forceSignal);
|
||||
result.forceSent = true;
|
||||
} catch (error) {
|
||||
result.forceError = errorSummary(error);
|
||||
}
|
||||
result.exitedAfterForce = await waitForPidExit(result.pid, browserFreezePolicy.kill.graceMs, browserFreezePolicy.kill.pollIntervalMs);
|
||||
process.kill(item.pid, browserFreezePolicy.kill.gracefulSignal);
|
||||
result.gracefulSentCount += 1;
|
||||
} catch {}
|
||||
}
|
||||
result.ok = result.exitedAfterGrace || result.exitedAfterForce;
|
||||
let remaining = await waitForTrackedBrowserExit(browserFreezePolicy.kill.graceMs, browserFreezePolicy.kill.pollIntervalMs);
|
||||
for (const item of [...remaining].sort((a, b) => b.pid - a.pid)) {
|
||||
try {
|
||||
process.kill(item.pid, browserFreezePolicy.kill.forceSignal);
|
||||
result.forceSentCount += 1;
|
||||
} catch {}
|
||||
}
|
||||
remaining = await waitForTrackedBrowserExit(browserFreezePolicy.kill.graceMs, browserFreezePolicy.kill.pollIntervalMs);
|
||||
result.remainingIdentityCount = remaining.length;
|
||||
result.remainingIdentities = remaining.slice(0, 50).map((item) => ({ pid: item.pid, startTicks: item.startTicks, role: item.role, originalPpid: item.ppid, currentPpid: item.currentPpid, originalSid: item.sid, currentSid: item.currentSid, valuesRedacted: true }));
|
||||
result.ok = remaining.length === 0 && !identityScanUnavailable;
|
||||
return result;
|
||||
}
|
||||
|
||||
async function waitForPidExit(pid, timeoutMs, pollIntervalMs) {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (Date.now() <= deadline) {
|
||||
if (!pidAlive(pid)) return true;
|
||||
await sleep(pollIntervalMs);
|
||||
}
|
||||
return !pidAlive(pid);
|
||||
}
|
||||
|
||||
function pidAlive(pid) {
|
||||
try {
|
||||
process.kill(pid, 0);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function browserProcessSampleRef(sample) {
|
||||
return {
|
||||
ts: sample?.ts ?? null,
|
||||
@@ -532,13 +622,16 @@ async function collectChromiumProcessSummary(browserPid) {
|
||||
queue.push(child);
|
||||
}
|
||||
}
|
||||
const processes = all
|
||||
.filter((item) => descendants.has(item.pid))
|
||||
const detailed = await readProcProcessDetails(all.filter((item) => descendants.has(item.pid)));
|
||||
const processes = detailed
|
||||
.map((item) => ({ ...item, role: classifyChromiumProcess(item) }))
|
||||
.filter((item) => item.role)
|
||||
.map((item) => ({
|
||||
pid: item.pid,
|
||||
ppid: item.ppid,
|
||||
pgid: item.pgid,
|
||||
sid: item.sid,
|
||||
startTicks: item.startTicks,
|
||||
name: item.name || null,
|
||||
role: item.role,
|
||||
rssBytes: item.rssBytes,
|
||||
@@ -548,6 +641,7 @@ async function collectChromiumProcessSummary(browserPid) {
|
||||
valuesRedacted: true,
|
||||
}))
|
||||
.sort((a, b) => Number(b.rssBytes || 0) - Number(a.rssBytes || 0));
|
||||
rememberTrackedBrowserProcesses(processes);
|
||||
const roles = {};
|
||||
for (const item of processes) {
|
||||
const role = item.role || "unknown";
|
||||
@@ -561,6 +655,7 @@ async function collectChromiumProcessSummary(browserPid) {
|
||||
const maxProcess = processes[0] || null;
|
||||
return {
|
||||
sampledRootPids: roots,
|
||||
processScan: lastProcessScan,
|
||||
chromiumProcessCount: processes.length,
|
||||
totalRssBytes,
|
||||
totalRssMb: bytesToMb(totalRssBytes),
|
||||
@@ -577,28 +672,68 @@ async function readProcProcessTable() {
|
||||
let entries = [];
|
||||
try {
|
||||
entries = await readdir("/proc");
|
||||
} catch {
|
||||
return [];
|
||||
} catch (error) {
|
||||
lastProcessScan = { ok: false, complete: false, totalPidEntries: 0, scannedPidEntries: 0, failedPidEntries: 0, truncated: false, readConcurrency: maxProcessReadConcurrency, error: errorSummary(error), valuesRedacted: true };
|
||||
throw new Error("proc-process-table-unavailable");
|
||||
}
|
||||
const numeric = entries.map((name) => Number(name)).filter((pid) => Number.isInteger(pid) && pid > 0);
|
||||
const rows = await Promise.all(numeric.map((pid) => readOneProcProcess(pid).catch(() => null)));
|
||||
return rows.filter(Boolean);
|
||||
const allNumeric = entries.map((name) => Number(name)).filter((pid) => Number.isInteger(pid) && pid > 0).sort((a, b) => b - a);
|
||||
const numeric = allNumeric.slice(0, maxProcessScanEntries);
|
||||
const rows = [];
|
||||
for (let offset = 0; offset < numeric.length; offset += maxProcessReadConcurrency) {
|
||||
const batch = await Promise.all(numeric.slice(offset, offset + maxProcessReadConcurrency).map((pid) => readOneProcProcessStat(pid).catch(() => null)));
|
||||
rows.push(...batch.filter(Boolean));
|
||||
}
|
||||
const failedPidEntries = numeric.length - rows.length;
|
||||
const truncated = numeric.length < allNumeric.length;
|
||||
const unusable = numeric.length > 0 && rows.length === 0;
|
||||
lastProcessScan = {
|
||||
ok: !unusable,
|
||||
complete: !unusable && !truncated,
|
||||
totalPidEntries: allNumeric.length,
|
||||
scannedPidEntries: numeric.length,
|
||||
readablePidEntries: rows.length,
|
||||
failedPidEntries,
|
||||
truncated,
|
||||
readConcurrency: maxProcessReadConcurrency,
|
||||
error: unusable ? "all-proc-stat-reads-failed" : null,
|
||||
valuesRedacted: true,
|
||||
};
|
||||
if (unusable) throw new Error("proc-process-table-unreadable");
|
||||
return rows;
|
||||
}
|
||||
|
||||
async function readOneProcProcess(pid) {
|
||||
const [statText, statusText, cmdlineText] = await Promise.all([
|
||||
readFile(path.join("/proc", String(pid), "stat"), "utf8").catch(() => ""),
|
||||
async function readOneProcProcessStat(pid) {
|
||||
const statText = await readFile(path.join("/proc", String(pid), "stat"), "utf8");
|
||||
if (!statText) return null;
|
||||
return { pid, ...procIdentityFromStat(statText) };
|
||||
}
|
||||
|
||||
async function readProcProcessDetails(rows) {
|
||||
const detailed = [];
|
||||
for (let offset = 0; offset < rows.length; offset += maxProcessReadConcurrency) {
|
||||
const batch = await Promise.all(rows.slice(offset, offset + maxProcessReadConcurrency).map((row) => readOneProcProcessDetails(row).catch(() => null)));
|
||||
detailed.push(...batch.filter(Boolean));
|
||||
}
|
||||
return detailed;
|
||||
}
|
||||
|
||||
async function readOneProcProcessDetails(row) {
|
||||
const pid = row.pid;
|
||||
const [statusText, cmdlineText] = await Promise.all([
|
||||
readFile(path.join("/proc", String(pid), "status"), "utf8").catch(() => ""),
|
||||
readFile(path.join("/proc", String(pid), "cmdline"), "utf8").catch(() => ""),
|
||||
]);
|
||||
if (!statText && !statusText) return null;
|
||||
const ppid = procPpidFromStat(statText);
|
||||
if (!statusText && !cmdlineText) return null;
|
||||
const ppid = row.ppid;
|
||||
const name = procStatusField(statusText, "Name") || null;
|
||||
const rssKb = procStatusKb(statusText, "VmRSS");
|
||||
const vmSizeKb = procStatusKb(statusText, "VmSize");
|
||||
return {
|
||||
pid,
|
||||
ppid,
|
||||
pgid: row.pgid,
|
||||
sid: row.sid,
|
||||
startTicks: row.startTicks,
|
||||
name,
|
||||
cmdline: String(cmdlineText || "").replace(/\0/gu, " ").replace(/\s+/gu, " ").trim(),
|
||||
rssBytes: Number.isFinite(rssKb) ? rssKb * 1024 : 0,
|
||||
@@ -606,13 +741,13 @@ async function readOneProcProcess(pid) {
|
||||
};
|
||||
}
|
||||
|
||||
function procPpidFromStat(value) {
|
||||
function procIdentityFromStat(value) {
|
||||
const text = String(value || "");
|
||||
const end = text.lastIndexOf(")");
|
||||
if (end < 0) return null;
|
||||
if (end < 0) return { ppid: null, pgid: null, sid: null, startTicks: null };
|
||||
const parts = text.slice(end + 1).trim().split(/\s+/u);
|
||||
const ppid = Number(parts[1]);
|
||||
return Number.isFinite(ppid) ? ppid : null;
|
||||
const integerAt = (index) => Number.isInteger(Number(parts[index])) ? Number(parts[index]) : null;
|
||||
return { ppid: integerAt(1), pgid: integerAt(2), sid: integerAt(3), startTicks: integerAt(19) };
|
||||
}
|
||||
|
||||
function procStatusField(value, key) {
|
||||
@@ -725,7 +860,13 @@ function applyBrowserPageRuntimeBaseline(result) {
|
||||
const key = [result.pageRole || "unknown", result.pageId || "unknown", Number.isFinite(Number(result.pageEpoch)) ? Number(result.pageEpoch) : 0].join(":");
|
||||
const current = browserPageRuntimeMemorySnapshot(result);
|
||||
const existing = browserPageRuntimeBaselines.get(key);
|
||||
if (!existing && current) browserPageRuntimeBaselines.set(key, { ...current, capturedAt: new Date().toISOString(), source: "first-page-runtime-sample", valuesRedacted: true });
|
||||
if (!existing && current) {
|
||||
const prefix = [result.pageRole || "unknown", result.pageId || "unknown"].join(":") + ":";
|
||||
for (const existingKey of browserPageRuntimeBaselines.keys()) {
|
||||
if (existingKey.startsWith(prefix)) browserPageRuntimeBaselines.delete(existingKey);
|
||||
}
|
||||
browserPageRuntimeBaselines.set(key, { ...current, capturedAt: new Date().toISOString(), source: "first-page-runtime-sample", valuesRedacted: true });
|
||||
}
|
||||
const baseline = browserPageRuntimeBaselines.get(key) || null;
|
||||
result.baseline = baseline ? {
|
||||
capturedAt: baseline.capturedAt,
|
||||
@@ -847,8 +988,10 @@ function bytesToMb(value) {
|
||||
}
|
||||
|
||||
function attachPassiveListeners(targetPage, pageRole = "control", targetPageId = pageId) {
|
||||
void installPagePerformanceProbe(targetPage, pageRole, targetPageId)
|
||||
.catch((error) => appendJsonl(files.errors, eventRecord("performance-probe-install-error", { pageRole, pageId: targetPageId, error: errorSummary(error), valuesRedacted: true })));
|
||||
trackPassiveTask(
|
||||
() => installPagePerformanceProbe(targetPage, pageRole, targetPageId),
|
||||
"performance-probe-install:" + pageRole,
|
||||
);
|
||||
targetPage.on("request", (request) => {
|
||||
const webPerformancePayload = summarizeWebPerformanceRequestPayload(request);
|
||||
void appendJsonl(files.network, eventRecord("request", {
|
||||
@@ -878,19 +1021,10 @@ function attachPassiveListeners(targetPage, pageRole = "control", targetPageId =
|
||||
statusText: response.statusText(),
|
||||
fromServiceWorker: response.fromServiceWorker(),
|
||||
};
|
||||
void (async () => {
|
||||
trackPassiveTask(async () => {
|
||||
const bodyFields = await summarizeWorkbenchResponseBody(response, request);
|
||||
await appendJsonl(files.network, eventRecord("response", { ...base, ...bodyFields }));
|
||||
})().catch((error) => appendJsonl(files.errors, eventRecord("response-body-summary-error", {
|
||||
pageRole,
|
||||
pageId: targetPageId,
|
||||
sampleSeq,
|
||||
commandId: activeCommandId,
|
||||
method: request.method(),
|
||||
url: safeUrl(response.url()),
|
||||
error: errorSummary(error),
|
||||
valuesRedacted: true
|
||||
})));
|
||||
}, "response-body-summary:" + pageRole);
|
||||
});
|
||||
targetPage.on("requestfailed", (request) => {
|
||||
void appendJsonl(files.network, eventRecord("requestfailed", {
|
||||
|
||||
@@ -795,8 +795,7 @@ function digestSessionRail(value) {
|
||||
};
|
||||
}
|
||||
|
||||
async function captureScreenshot(reason, imageType = "png") {
|
||||
if (!page || page.isClosed()) throw new Error("page is not available for screenshot");
|
||||
function reserveScreenshot(reason) {
|
||||
if (screenshotCount >= maxScreenshots) {
|
||||
lastScreenshotAtMs = Date.now();
|
||||
return {
|
||||
@@ -814,6 +813,14 @@ async function captureScreenshot(reason, imageType = "png") {
|
||||
valuesRedacted: true,
|
||||
};
|
||||
}
|
||||
screenshotCount += 1;
|
||||
return null;
|
||||
}
|
||||
|
||||
async function captureScreenshot(reason, imageType = "png") {
|
||||
if (!page || page.isClosed()) throw new Error("page is not available for screenshot");
|
||||
const skipped = reserveScreenshot(reason);
|
||||
if (skipped !== null) return skipped;
|
||||
if (screenshotCaptureState && screenshotCaptureState.settled !== true) {
|
||||
const ageMs = Date.now() - Number(screenshotCaptureState.startedAtMs || Date.now());
|
||||
const error = new Error("screenshot capture already in progress");
|
||||
@@ -831,7 +838,6 @@ async function captureScreenshot(reason, imageType = "png") {
|
||||
lastScreenshotAtMs = Date.now();
|
||||
throw error;
|
||||
}
|
||||
screenshotCount += 1;
|
||||
artifactSeq += 1;
|
||||
const safeReason = safeId(String(reason || "manual")).slice(0, 40) || "manual";
|
||||
const type = imageType === "jpeg" || imageType === "jpg" ? "jpeg" : "png";
|
||||
@@ -905,10 +911,10 @@ async function summarizeWorkbenchResponseBody(response, request) {
|
||||
const headers = response.headers();
|
||||
const contentType = String(headers["content-type"] || headers["Content-Type"] || "");
|
||||
if (!/json/iu.test(contentType)) return { bodyRead: false, bodyReadSkipped: "non-json", valuesRedacted: true };
|
||||
const contentLength = Number(headers["content-length"] || headers["Content-Length"]);
|
||||
const maxBytes = responseBodyMaxBytes;
|
||||
if (Number.isFinite(contentLength) && contentLength > maxBytes) return { bodyRead: false, bodyReadSkipped: "content-length-too-large", bodyByteCount: contentLength, valuesRedacted: true };
|
||||
const text = await response.text();
|
||||
const body = await boundedResponseText(response, maxBytes);
|
||||
if (body.ok !== true || typeof body.value !== "string") return { bodyRead: false, bodyReadSkipped: body.error?.reason || "bounded-response-read-failed", bodyContract: body.bodyContract, valuesRedacted: true };
|
||||
const text = body.value;
|
||||
const byteCount = Buffer.byteLength(text);
|
||||
if (byteCount > maxBytes) return { bodyRead: true, bodyReadSkipped: "body-too-large", bodyByteCount: byteCount, bodyHash: sha256Text(text), valuesRedacted: true };
|
||||
let parsed = null;
|
||||
|
||||
@@ -15,6 +15,7 @@ import { nodeWebObserveRunnerTraceReadabilitySource } from "./hwlab-node-web-obs
|
||||
export function nodeWebObserveRunnerSource(): string {
|
||||
return String.raw`#!/usr/bin/env node
|
||||
import { createHash, randomBytes } from "node:crypto";
|
||||
import { createReadStream } from "node:fs";
|
||||
import { appendFile, mkdir, readFile, readdir, rename, stat, unlink, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
@@ -31,14 +32,30 @@ const targetPath = process.env.UNIDESK_WEB_OBSERVE_TARGET_PATH || "/workbench";
|
||||
const sampleIntervalMs = positiveInteger(process.env.UNIDESK_WEB_OBSERVE_SAMPLE_INTERVAL_MS, 5000);
|
||||
const screenshotIntervalMs = positiveInteger(process.env.UNIDESK_WEB_OBSERVE_SCREENSHOT_INTERVAL_MS, 300000);
|
||||
const screenshotCaptureTimeoutMs = boundedInteger(process.env.UNIDESK_WEB_OBSERVE_SCREENSHOT_CAPTURE_TIMEOUT_MS, 15000, 1000, 120000);
|
||||
const maxSamples = boundedInteger(process.env.UNIDESK_WEB_OBSERVE_MAX_SAMPLES, 720, 1, 10000000);
|
||||
const maxSamples = requiredBoundedInteger(process.env.UNIDESK_WEB_OBSERVE_MAX_SAMPLES, "UNIDESK_WEB_OBSERVE_MAX_SAMPLES", 1, 10000000);
|
||||
const observerRefreshIntervalMs = positiveInteger(process.env.UNIDESK_WEB_OBSERVE_OBSERVER_REFRESH_INTERVAL_MS, 180000);
|
||||
const maxRunMs = boundedInteger(process.env.UNIDESK_WEB_OBSERVE_MAX_RUN_MS, 3600000, 1000, 86400000);
|
||||
const maxScreenshots = boundedInteger(process.env.UNIDESK_WEB_OBSERVE_MAX_SCREENSHOTS, 48, 1, 10000);
|
||||
const maxPerformanceEntries = boundedInteger(process.env.UNIDESK_WEB_OBSERVE_MAX_PERFORMANCE_ENTRIES, 2000, 1, 100000);
|
||||
const maxBrowserProcessHistoryEntries = boundedInteger(process.env.UNIDESK_WEB_OBSERVE_MAX_BROWSER_PROCESS_HISTORY_ENTRIES, 360, 1, 100000);
|
||||
const maxBrowserFreezeSignalHistoryEntries = boundedInteger(process.env.UNIDESK_WEB_OBSERVE_MAX_BROWSER_FREEZE_SIGNAL_HISTORY_ENTRIES, 120, 1, 100000);
|
||||
const responseBodyMaxBytes = boundedInteger(process.env.UNIDESK_WEB_OBSERVE_RESPONSE_BODY_MAX_BYTES, 524288, 1024, 16777216);
|
||||
const maxRunMs = requiredBoundedInteger(process.env.UNIDESK_WEB_OBSERVE_MAX_RUN_MS, "UNIDESK_WEB_OBSERVE_MAX_RUN_MS", 1000, 86400000);
|
||||
const maxScreenshots = requiredBoundedInteger(process.env.UNIDESK_WEB_OBSERVE_MAX_SCREENSHOTS, "UNIDESK_WEB_OBSERVE_MAX_SCREENSHOTS", 1, 10000);
|
||||
const maxPerformanceEntries = requiredBoundedInteger(process.env.UNIDESK_WEB_OBSERVE_MAX_PERFORMANCE_ENTRIES, "UNIDESK_WEB_OBSERVE_MAX_PERFORMANCE_ENTRIES", 1, 100000);
|
||||
const maxBrowserProcessHistoryEntries = requiredBoundedInteger(process.env.UNIDESK_WEB_OBSERVE_MAX_BROWSER_PROCESS_HISTORY_ENTRIES, "UNIDESK_WEB_OBSERVE_MAX_BROWSER_PROCESS_HISTORY_ENTRIES", 1, 100000);
|
||||
const maxBrowserFreezeSignalHistoryEntries = requiredBoundedInteger(process.env.UNIDESK_WEB_OBSERVE_MAX_BROWSER_FREEZE_SIGNAL_HISTORY_ENTRIES, "UNIDESK_WEB_OBSERVE_MAX_BROWSER_FREEZE_SIGNAL_HISTORY_ENTRIES", 1, 100000);
|
||||
const maxRealtimeEventEntries = requiredBoundedInteger(process.env.UNIDESK_WEB_OBSERVE_MAX_REALTIME_EVENT_ENTRIES, "UNIDESK_WEB_OBSERVE_MAX_REALTIME_EVENT_ENTRIES", 1, 100000);
|
||||
const maxRealtimeEventBodyBytes = requiredBoundedInteger(process.env.UNIDESK_WEB_OBSERVE_MAX_REALTIME_EVENT_BODY_BYTES, "UNIDESK_WEB_OBSERVE_MAX_REALTIME_EVENT_BODY_BYTES", 1, 16777216);
|
||||
const maxDomEvidenceRows = requiredBoundedInteger(process.env.UNIDESK_WEB_OBSERVE_MAX_DOM_EVIDENCE_ROWS, "UNIDESK_WEB_OBSERVE_MAX_DOM_EVIDENCE_ROWS", 1, 100000);
|
||||
const maxProcessScanEntries = requiredBoundedInteger(process.env.UNIDESK_WEB_OBSERVE_MAX_PROCESS_SCAN_ENTRIES, "UNIDESK_WEB_OBSERVE_MAX_PROCESS_SCAN_ENTRIES", 1, 1000000);
|
||||
const maxPerformanceCaptureMs = requiredBoundedInteger(process.env.UNIDESK_WEB_OBSERVE_MAX_PERFORMANCE_CAPTURE_SECONDS, "UNIDESK_WEB_OBSERVE_MAX_PERFORMANCE_CAPTURE_SECONDS", 1, 600) * 1000;
|
||||
const responseBodyMaxBytes = requiredBoundedInteger(process.env.UNIDESK_WEB_OBSERVE_RESPONSE_BODY_MAX_BYTES, "UNIDESK_WEB_OBSERVE_RESPONSE_BODY_MAX_BYTES", 1024, 16777216);
|
||||
const maxJsonlQueueEntries = requiredBoundedInteger(process.env.UNIDESK_WEB_OBSERVE_MAX_JSONL_QUEUE_ENTRIES, "UNIDESK_WEB_OBSERVE_MAX_JSONL_QUEUE_ENTRIES", 1, 100000);
|
||||
const maxJsonlQueueBytes = requiredBoundedInteger(process.env.UNIDESK_WEB_OBSERVE_MAX_JSONL_QUEUE_BYTES, "UNIDESK_WEB_OBSERVE_MAX_JSONL_QUEUE_BYTES", 1024, 67108864);
|
||||
const maxJsonlLineBytes = requiredBoundedInteger(process.env.UNIDESK_WEB_OBSERVE_MAX_JSONL_LINE_BYTES, "UNIDESK_WEB_OBSERVE_MAX_JSONL_LINE_BYTES", 1024, 16777216);
|
||||
const jsonlFlushTimeoutMs = requiredBoundedInteger(process.env.UNIDESK_WEB_OBSERVE_JSONL_FLUSH_TIMEOUT_MS, "UNIDESK_WEB_OBSERVE_JSONL_FLUSH_TIMEOUT_MS", 100, 60000);
|
||||
const maxResponseBodyInFlight = requiredBoundedInteger(process.env.UNIDESK_WEB_OBSERVE_MAX_RESPONSE_BODY_IN_FLIGHT, "UNIDESK_WEB_OBSERVE_MAX_RESPONSE_BODY_IN_FLIGHT", 1, 64);
|
||||
const maxPassiveListenerTasks = requiredBoundedInteger(process.env.UNIDESK_WEB_OBSERVE_MAX_PASSIVE_LISTENER_TASKS, "UNIDESK_WEB_OBSERVE_MAX_PASSIVE_LISTENER_TASKS", 1, 10000);
|
||||
const passiveTaskFlushTimeoutMs = requiredBoundedInteger(process.env.UNIDESK_WEB_OBSERVE_PASSIVE_TASK_FLUSH_TIMEOUT_MS, "UNIDESK_WEB_OBSERVE_PASSIVE_TASK_FLUSH_TIMEOUT_MS", 100, 60000);
|
||||
const closeStepTimeoutMs = requiredBoundedInteger(process.env.UNIDESK_WEB_OBSERVE_CLOSE_STEP_TIMEOUT_MS, "UNIDESK_WEB_OBSERVE_CLOSE_STEP_TIMEOUT_MS", 100, 60000);
|
||||
const maxProcessReadConcurrency = requiredBoundedInteger(process.env.UNIDESK_WEB_OBSERVE_MAX_PROCESS_READ_CONCURRENCY, "UNIDESK_WEB_OBSERVE_MAX_PROCESS_READ_CONCURRENCY", 1, 256);
|
||||
const maxRealtimeTotalEntries = requiredBoundedInteger(process.env.UNIDESK_WEB_OBSERVE_MAX_REALTIME_TOTAL_ENTRIES, "UNIDESK_WEB_OBSERVE_MAX_REALTIME_TOTAL_ENTRIES", 1, 100000);
|
||||
const maxRealtimeTotalBodyBytes = requiredBoundedInteger(process.env.UNIDESK_WEB_OBSERVE_MAX_REALTIME_TOTAL_BODY_BYTES, "UNIDESK_WEB_OBSERVE_MAX_REALTIME_TOTAL_BODY_BYTES", 1024, 67108864);
|
||||
const viewport = parseViewport(process.env.UNIDESK_WEB_OBSERVE_VIEWPORT || "1440x900");
|
||||
const browserProxyMode = parseBrowserProxyMode(process.env.UNIDESK_WEB_OBSERVE_BROWSER_PROXY_MODE || "auto");
|
||||
const origin = {
|
||||
@@ -49,7 +66,7 @@ const origin = {
|
||||
browserProxyModeSource: process.env.UNIDESK_WEB_OBSERVE_BROWSER_PROXY_MODE_SOURCE || "default",
|
||||
valuesRedacted: true,
|
||||
};
|
||||
const webPerformanceRequestBodyMaxBytes = boundedInteger(process.env.UNIDESK_WEB_OBSERVE_WEB_PERFORMANCE_BODY_MAX_BYTES, 65536, 1024, 1048576);
|
||||
const webPerformanceRequestBodyMaxBytes = requiredBoundedInteger(process.env.UNIDESK_WEB_OBSERVE_WEB_PERFORMANCE_BODY_MAX_BYTES, "UNIDESK_WEB_OBSERVE_WEB_PERFORMANCE_BODY_MAX_BYTES", 1024, 1048576);
|
||||
const authLoginMaxAttempts = boundedInteger(process.env.UNIDESK_WEB_OBSERVE_AUTH_LOGIN_MAX_ATTEMPTS, 6, 1, 20);
|
||||
const authLoginRequestTimeoutMs = boundedInteger(process.env.UNIDESK_WEB_OBSERVE_AUTH_LOGIN_REQUEST_TIMEOUT_MS, 30000, 1000, 120000);
|
||||
const authLoginInitialDelayMs = boundedInteger(process.env.UNIDESK_WEB_OBSERVE_AUTH_LOGIN_INITIAL_DELAY_MS, 500, 0, 60000);
|
||||
@@ -115,16 +132,25 @@ let browserProcessMonitorSeq = 0;
|
||||
let browserFreezeBlocker = null;
|
||||
let shutdownSignal = null;
|
||||
let browserResourcesClosePromise = null;
|
||||
let browserLaunchAttempted = false;
|
||||
const browserProcessHistory = [];
|
||||
const browserPageRuntimeBaselines = new Map();
|
||||
const browserFreezeSignalHistory = [];
|
||||
let trackedBrowserProcessIdentities = [];
|
||||
const jsonlRotation = { stamp: compactFileTimestamp(startedAt), files: [] };
|
||||
const jsonlWriterState = { queue: [], queuedBytes: 0, inFlight: 0, pump: null, acceptedEntries: 0, acceptedBytes: 0, writtenEntries: 0, writtenBytes: 0, droppedEntries: 0, droppedBytes: 0, failedEntries: 0, dropsByFile: {} };
|
||||
const responseBodyState = { inFlight: 0, accepted: 0, skippedSaturated: 0, completed: 0, failed: 0 };
|
||||
const passiveTaskState = { tasks: new Set(), accepted: 0, dropped: 0, completed: 0, failed: 0, drainTimedOut: false };
|
||||
const runnerSleepWakes = new Set();
|
||||
let lastProcessScan = { ok: false, complete: false, totalPidEntries: 0, scannedPidEntries: 0, failedPidEntries: 0, truncated: false, readConcurrency: maxProcessReadConcurrency, error: "not-scanned", valuesRedacted: true };
|
||||
let realtimeAggregateLast = null;
|
||||
|
||||
for (const signal of ["SIGINT", "SIGTERM", "SIGHUP"]) {
|
||||
process.once(signal, () => {
|
||||
shutdownSignal = signal;
|
||||
stopping = true;
|
||||
terminalStatus = "stopping";
|
||||
interruptRunnerSleep();
|
||||
void closeBrowserResources("signal:" + signal);
|
||||
});
|
||||
}
|
||||
@@ -139,6 +165,7 @@ try {
|
||||
if (jsonlRotation.files.length > 0) await appendJsonl(files.control, eventRecord("jsonl-rotated", { stamp: jsonlRotation.stamp, archiveDir: path.relative(stateDir, dirs.archive), files: jsonlRotation.files, valuesRedacted: true }));
|
||||
const launcher = await import(pathToFileURL(path.resolve("scripts/src/browser-launcher.mjs")).href);
|
||||
const { chromium } = await launcher.importPlaywright();
|
||||
browserLaunchAttempted = true;
|
||||
browser = await launcher.launchChromium(chromium, chromiumLaunchOptions);
|
||||
context = await browser.newContext({ viewport, ...(playwrightProxy === null ? {} : { proxy: playwrightProxy }) });
|
||||
page = await context.newPage();
|
||||
@@ -181,7 +208,7 @@ try {
|
||||
if (browserFreezeBlocker) break;
|
||||
await samplePage("interval");
|
||||
if (browserFreezeBlocker) break;
|
||||
await sleep(sampleIntervalMs);
|
||||
await interruptibleRunnerSleep(sampleIntervalMs);
|
||||
}
|
||||
if (browserFreezeBlocker) {
|
||||
terminalStatus = "failed";
|
||||
@@ -203,7 +230,11 @@ try {
|
||||
} finally {
|
||||
if (browserProcessMonitorStop) browserProcessMonitorStop();
|
||||
if (heartbeatPulseTimer) clearInterval(heartbeatPulseTimer);
|
||||
await closeBrowserResources(shutdownSignal === null ? "finally" : "signal:" + shutdownSignal);
|
||||
interruptRunnerSleep();
|
||||
const closeResult = await closeBrowserResources(shutdownSignal === null ? "finally" : "signal:" + shutdownSignal);
|
||||
const passiveTaskDrain = await drainPassiveTasks();
|
||||
const terminalFlush = await flushJsonlQueue();
|
||||
await writeTerminalResourceStats({ closeResult, passiveTaskDrain, terminalFlush }).catch(() => {});
|
||||
}
|
||||
|
||||
async function closeBrowserResources(reason) {
|
||||
@@ -214,15 +245,32 @@ async function closeBrowserResources(reason) {
|
||||
const closingPage = page;
|
||||
const closingContext = context;
|
||||
const closingBrowser = browser;
|
||||
let preCloseIdentityDiscovery;
|
||||
try {
|
||||
const summary = await collectChromiumProcessSummary(null);
|
||||
preCloseIdentityDiscovery = { ok: true, processScan: summary.processScan, chromiumProcessCount: summary.chromiumProcessCount, valuesRedacted: true };
|
||||
} catch (error) {
|
||||
preCloseIdentityDiscovery = { ok: false, processScan: lastProcessScan, error: errorSummary(error), valuesRedacted: true };
|
||||
}
|
||||
observerPage = null;
|
||||
page = null;
|
||||
context = null;
|
||||
browser = null;
|
||||
if (closingObserverPage && !closingObserverPage.isClosed()) await closingObserverPage.close().catch(() => {});
|
||||
if (closingPage && !closingPage.isClosed()) await closingPage.close().catch(() => {});
|
||||
if (closingContext) await closingContext.close().catch(() => {});
|
||||
if (closingBrowser) await closingBrowser.close().catch(() => {});
|
||||
return { ok: true, reason, valuesRedacted: true };
|
||||
const steps = [];
|
||||
const closeStep = async (label, operation) => {
|
||||
try {
|
||||
await withHardTimeout(Promise.resolve().then(operation), closeStepTimeoutMs, label + " close exceeded " + closeStepTimeoutMs + "ms");
|
||||
steps.push({ label, ok: true, valuesRedacted: true });
|
||||
} catch (error) {
|
||||
steps.push({ label, ok: false, error: errorSummary(error), valuesRedacted: true });
|
||||
}
|
||||
};
|
||||
if (closingObserverPage && !closingObserverPage.isClosed()) await closeStep("observer-page", () => closingObserverPage.close());
|
||||
if (closingPage && !closingPage.isClosed()) await closeStep("control-page", () => closingPage.close());
|
||||
if (closingContext) await closeStep("browser-context", () => closingContext.close());
|
||||
if (closingBrowser) await closeStep("browser", () => closingBrowser.close());
|
||||
const browserCleanup = await terminateTrackedBrowserProcesses("browser-close-residual", preCloseIdentityDiscovery);
|
||||
return { ok: steps.every((step) => step.ok === true) && browserCleanup.ok === true, reason, closeStepTimeoutMs, steps, browserCleanup, valuesRedacted: true };
|
||||
})();
|
||||
return browserResourcesClosePromise;
|
||||
}
|
||||
|
||||
@@ -300,7 +300,7 @@ async function workbenchTraceReadabilityWaitForCurrentProductCard(timeoutMs, pol
|
||||
if (stableCardCount === 1 && timelineCount === 1) return { card, traceId, scope: lastScope };
|
||||
}
|
||||
}
|
||||
await sleep(pollIntervalMs);
|
||||
await interruptibleRunnerSleep(pollIntervalMs);
|
||||
}
|
||||
const error = new Error("在 owning YAML 指定的 " + timeoutMs + "ms 内未找到当前新运行中的产品 Trace 卡片");
|
||||
error.details = { scope: lastScope, valuesRedacted: true };
|
||||
@@ -313,7 +313,7 @@ async function workbenchTraceReadabilityWaitForDisclosureOpen(card, traceId, tim
|
||||
while (Date.now() <= deadline) {
|
||||
latest = await workbenchTraceReadabilitySnapshot(card, traceId);
|
||||
if (latest.disclosure.open === true) return latest;
|
||||
await sleep(pollIntervalMs);
|
||||
await interruptibleRunnerSleep(pollIntervalMs);
|
||||
}
|
||||
return latest;
|
||||
}
|
||||
@@ -422,7 +422,7 @@ async function workbenchTraceReadabilityWaitForReadableRunning(card, traceId, ti
|
||||
if (workbenchTraceReadabilityTerminalStatus(latest.status)) {
|
||||
return { observed: false, reason: "terminal-before-readable-running-sample", elapsedMs: Date.now() - startedAtMs, snapshot: latest, valuesRedacted: true };
|
||||
}
|
||||
await sleep(pollIntervalMs);
|
||||
await interruptibleRunnerSleep(pollIntervalMs);
|
||||
}
|
||||
return { observed: false, reason: "readable-running-timeout", elapsedMs: Date.now() - startedAtMs, snapshot: latest, valuesRedacted: true };
|
||||
}
|
||||
@@ -436,7 +436,7 @@ async function workbenchTraceReadabilityWaitForTerminal(card, traceId, timeoutMs
|
||||
if (workbenchTraceReadabilityTerminalStatus(latest.status)) {
|
||||
return { observed: true, status: latest.status, elapsedMs: Date.now() - startedAtMs, snapshot: latest, valuesRedacted: true };
|
||||
}
|
||||
await sleep(pollIntervalMs);
|
||||
await interruptibleRunnerSleep(pollIntervalMs);
|
||||
}
|
||||
return { observed: false, status: latest.status, elapsedMs: Date.now() - startedAtMs, snapshot: latest, valuesRedacted: true };
|
||||
}
|
||||
@@ -466,7 +466,7 @@ async function workbenchTraceReadabilityWaitForStableTerminal(card, traceId, qui
|
||||
stableSinceMs = null;
|
||||
}
|
||||
previousSignature = signature;
|
||||
await sleep(pollIntervalMs);
|
||||
await interruptibleRunnerSleep(pollIntervalMs);
|
||||
}
|
||||
return { observed: workbenchTraceReadabilityTerminalStatus(latest.status), stable: false, sampleCount, elapsedMs: Date.now() - startedAtMs, snapshot: latest, valuesRedacted: true };
|
||||
}
|
||||
|
||||
@@ -2,13 +2,221 @@
|
||||
// Responsibility: Runner low-level IO, URL, YAML-derived policy parsing, redaction, and timeout utility source fragment.
|
||||
|
||||
export function nodeWebObserveRunnerUtilitySource(): string {
|
||||
return String.raw`async function appendJsonl(file, value) {
|
||||
await appendFile(file, JSON.stringify(sanitize(value)) + "\n", { mode: 0o600 });
|
||||
return String.raw`function appendJsonl(file, value) {
|
||||
let line;
|
||||
try {
|
||||
line = JSON.stringify(sanitize(value)) + "\n";
|
||||
} catch {
|
||||
recordJsonlDrop(file, 0);
|
||||
return Promise.resolve({ ok: false, dropped: true, reason: "json-encode-failed", valuesRedacted: true });
|
||||
}
|
||||
const bytes = Buffer.byteLength(line);
|
||||
if (bytes > maxJsonlLineBytes) {
|
||||
recordJsonlDrop(file, bytes);
|
||||
return Promise.resolve({ ok: false, dropped: true, reason: "entry-too-large", bytes, maxJsonlLineBytes, valuesRedacted: true });
|
||||
}
|
||||
if (jsonlWriterState.queue.length >= maxJsonlQueueEntries || jsonlWriterState.queuedBytes + bytes > maxJsonlQueueBytes) {
|
||||
recordJsonlDrop(file, bytes);
|
||||
return Promise.resolve({ ok: false, dropped: true, reason: "queue-saturated", bytes, valuesRedacted: true });
|
||||
}
|
||||
jsonlWriterState.acceptedEntries += 1;
|
||||
jsonlWriterState.acceptedBytes += bytes;
|
||||
jsonlWriterState.queuedBytes += bytes;
|
||||
const completion = new Promise((resolve) => jsonlWriterState.queue.push({ file, line, bytes, resolve }));
|
||||
startJsonlPump();
|
||||
return completion;
|
||||
}
|
||||
|
||||
function recordJsonlDrop(file, bytes) {
|
||||
jsonlWriterState.droppedEntries += 1;
|
||||
jsonlWriterState.droppedBytes += Math.max(0, Number(bytes) || 0);
|
||||
const key = path.basename(String(file || "unknown")).slice(0, 80);
|
||||
jsonlWriterState.dropsByFile[key] = (jsonlWriterState.dropsByFile[key] || 0) + 1;
|
||||
}
|
||||
|
||||
function startJsonlPump() {
|
||||
if (jsonlWriterState.pump) return jsonlWriterState.pump;
|
||||
jsonlWriterState.pump = (async () => {
|
||||
while (jsonlWriterState.queue.length > 0) {
|
||||
const entry = jsonlWriterState.queue.shift();
|
||||
jsonlWriterState.queuedBytes = Math.max(0, jsonlWriterState.queuedBytes - entry.bytes);
|
||||
jsonlWriterState.inFlight = 1;
|
||||
try {
|
||||
await appendFile(entry.file, entry.line, { mode: 0o600 });
|
||||
jsonlWriterState.writtenEntries += 1;
|
||||
jsonlWriterState.writtenBytes += entry.bytes;
|
||||
entry.resolve({ ok: true, dropped: false, valuesRedacted: true });
|
||||
} catch (error) {
|
||||
jsonlWriterState.failedEntries += 1;
|
||||
entry.resolve({ ok: false, dropped: false, error: errorSummary(error), valuesRedacted: true });
|
||||
} finally {
|
||||
jsonlWriterState.inFlight = 0;
|
||||
}
|
||||
}
|
||||
})().finally(() => {
|
||||
jsonlWriterState.pump = null;
|
||||
if (jsonlWriterState.queue.length > 0) startJsonlPump();
|
||||
});
|
||||
return jsonlWriterState.pump;
|
||||
}
|
||||
|
||||
function jsonlWriterStats() {
|
||||
return {
|
||||
queueEntries: jsonlWriterState.queue.length,
|
||||
queueBytes: jsonlWriterState.queuedBytes,
|
||||
inFlight: jsonlWriterState.inFlight,
|
||||
maxQueueEntries: maxJsonlQueueEntries,
|
||||
maxQueueBytes: maxJsonlQueueBytes,
|
||||
maxLineBytes: maxJsonlLineBytes,
|
||||
acceptedEntries: jsonlWriterState.acceptedEntries,
|
||||
acceptedBytes: jsonlWriterState.acceptedBytes,
|
||||
writtenEntries: jsonlWriterState.writtenEntries,
|
||||
writtenBytes: jsonlWriterState.writtenBytes,
|
||||
droppedEntries: jsonlWriterState.droppedEntries,
|
||||
droppedBytes: jsonlWriterState.droppedBytes,
|
||||
failedEntries: jsonlWriterState.failedEntries,
|
||||
dropsByFile: { ...jsonlWriterState.dropsByFile },
|
||||
valuesRedacted: true,
|
||||
};
|
||||
}
|
||||
|
||||
async function flushJsonlQueue() {
|
||||
try {
|
||||
await withHardTimeout((async () => {
|
||||
while (jsonlWriterState.queue.length > 0 || jsonlWriterState.inFlight > 0 || jsonlWriterState.pump) {
|
||||
if (jsonlWriterState.pump) await jsonlWriterState.pump;
|
||||
else if (jsonlWriterState.queue.length > 0) await startJsonlPump();
|
||||
else break;
|
||||
}
|
||||
})(), jsonlFlushTimeoutMs, "jsonl terminal flush exceeded " + jsonlFlushTimeoutMs + "ms");
|
||||
return { ok: true, timedOut: false, stats: jsonlWriterStats(), valuesRedacted: true };
|
||||
} catch (error) {
|
||||
return { ok: false, timedOut: true, error: errorSummary(error), stats: jsonlWriterStats(), valuesRedacted: true };
|
||||
}
|
||||
}
|
||||
|
||||
async function fileMeta(file) {
|
||||
const [buffer, stats] = await Promise.all([readFile(file), stat(file)]);
|
||||
return { byteCount: stats.size, sha256: "sha256:" + createHash("sha256").update(buffer).digest("hex") };
|
||||
const digestPromise = new Promise((resolve, reject) => {
|
||||
const digest = createHash("sha256");
|
||||
const stream = createReadStream(file);
|
||||
stream.on("data", (chunk) => digest.update(chunk));
|
||||
stream.once("error", reject);
|
||||
stream.once("end", () => resolve("sha256:" + digest.digest("hex")));
|
||||
});
|
||||
const [sha256, stats] = await Promise.all([digestPromise, stat(file)]);
|
||||
return { byteCount: stats.size, sha256 };
|
||||
}
|
||||
|
||||
function responseBodyReadContract(response, maxBytes = responseBodyMaxBytes) {
|
||||
let headers = {};
|
||||
try { headers = response?.headers?.() || {}; } catch {}
|
||||
const contentEncoding = String(headers["content-encoding"] || headers["Content-Encoding"] || "").trim().toLowerCase();
|
||||
if (contentEncoding && contentEncoding !== "identity") {
|
||||
return { ok: false, reason: "encoded-body-size-unbounded", contentEncoding: truncate(contentEncoding, 80), contentLength: null, maxBytes, valuesRedacted: true };
|
||||
}
|
||||
const rawContentLength = headers["content-length"] || headers["Content-Length"];
|
||||
const contentLength = Number(rawContentLength);
|
||||
if (!Number.isFinite(contentLength) || contentLength < 0) {
|
||||
return { ok: false, reason: "content-length-unavailable", contentLength: null, maxBytes, valuesRedacted: true };
|
||||
}
|
||||
if (contentLength > maxBytes) {
|
||||
return { ok: false, reason: "content-length-too-large", contentLength, maxBytes, valuesRedacted: true };
|
||||
}
|
||||
return { ok: true, reason: null, contentLength, maxBytes, valuesRedacted: true };
|
||||
}
|
||||
|
||||
async function boundedResponseJson(response, maxBytes = responseBodyMaxBytes) {
|
||||
const bodyContract = responseBodyReadContract(response, maxBytes);
|
||||
if (bodyContract.ok !== true) return { ok: false, value: null, error: bodyContract, bodyContract, valuesRedacted: true };
|
||||
const permit = tryAcquireResponseBodyPermit();
|
||||
if (!permit) return { ok: false, value: null, error: { reason: "response-body-inflight-saturated", valuesRedacted: true }, bodyContract, valuesRedacted: true };
|
||||
try {
|
||||
const value = await response.json();
|
||||
responseBodyState.completed += 1;
|
||||
return { ok: true, value, error: null, bodyContract, valuesRedacted: true };
|
||||
} catch (error) {
|
||||
responseBodyState.failed += 1;
|
||||
return { ok: false, value: null, error: errorSummary(error), bodyContract, valuesRedacted: true };
|
||||
} finally {
|
||||
permit.release();
|
||||
}
|
||||
}
|
||||
|
||||
async function boundedResponseText(response, maxBytes = responseBodyMaxBytes) {
|
||||
const bodyContract = responseBodyReadContract(response, maxBytes);
|
||||
if (bodyContract.ok !== true) return { ok: false, value: null, error: bodyContract, bodyContract, valuesRedacted: true };
|
||||
const permit = tryAcquireResponseBodyPermit();
|
||||
if (!permit) return { ok: false, value: null, error: { reason: "response-body-inflight-saturated", valuesRedacted: true }, bodyContract, valuesRedacted: true };
|
||||
try {
|
||||
const value = await response.text();
|
||||
responseBodyState.completed += 1;
|
||||
return { ok: true, value, error: null, bodyContract, valuesRedacted: true };
|
||||
} catch (error) {
|
||||
responseBodyState.failed += 1;
|
||||
return { ok: false, value: null, error: errorSummary(error), bodyContract, valuesRedacted: true };
|
||||
} finally {
|
||||
permit.release();
|
||||
}
|
||||
}
|
||||
|
||||
function tryAcquireResponseBodyPermit() {
|
||||
if (responseBodyState.inFlight >= maxResponseBodyInFlight) {
|
||||
responseBodyState.skippedSaturated += 1;
|
||||
return null;
|
||||
}
|
||||
responseBodyState.inFlight += 1;
|
||||
responseBodyState.accepted += 1;
|
||||
let released = false;
|
||||
return { release() { if (released) return; released = true; responseBodyState.inFlight = Math.max(0, responseBodyState.inFlight - 1); } };
|
||||
}
|
||||
|
||||
function responseBodyStats() {
|
||||
return { ...responseBodyState, maxInFlight: maxResponseBodyInFlight, valuesRedacted: true };
|
||||
}
|
||||
|
||||
function trackPassiveTask(factory, label) {
|
||||
if (stopping || passiveTaskState.tasks.size >= maxPassiveListenerTasks) {
|
||||
passiveTaskState.dropped += 1;
|
||||
return false;
|
||||
}
|
||||
passiveTaskState.accepted += 1;
|
||||
let task;
|
||||
task = Promise.resolve()
|
||||
.then(factory)
|
||||
.then(() => { passiveTaskState.completed += 1; })
|
||||
.catch(async (error) => {
|
||||
passiveTaskState.failed += 1;
|
||||
await appendJsonl(files.errors, eventRecord("passive-task-error", { label, error: errorSummary(error), valuesRedacted: true })).catch(() => {});
|
||||
})
|
||||
.finally(() => passiveTaskState.tasks.delete(task));
|
||||
passiveTaskState.tasks.add(task);
|
||||
task.catch(() => {});
|
||||
return true;
|
||||
}
|
||||
|
||||
function passiveTaskStats() {
|
||||
return {
|
||||
inFlight: passiveTaskState.tasks.size,
|
||||
maxInFlight: maxPassiveListenerTasks,
|
||||
accepted: passiveTaskState.accepted,
|
||||
dropped: passiveTaskState.dropped,
|
||||
completed: passiveTaskState.completed,
|
||||
failed: passiveTaskState.failed,
|
||||
drainTimedOut: passiveTaskState.drainTimedOut,
|
||||
valuesRedacted: true,
|
||||
};
|
||||
}
|
||||
|
||||
async function drainPassiveTasks() {
|
||||
try {
|
||||
await withHardTimeout((async () => {
|
||||
while (passiveTaskState.tasks.size > 0) await Promise.allSettled([...passiveTaskState.tasks]);
|
||||
})(), passiveTaskFlushTimeoutMs, "passive task drain exceeded " + passiveTaskFlushTimeoutMs + "ms");
|
||||
return { ok: true, timedOut: false, stats: passiveTaskStats(), valuesRedacted: true };
|
||||
} catch (error) {
|
||||
passiveTaskState.drainTimedOut = true;
|
||||
return { ok: false, timedOut: true, error: errorSummary(error), stats: passiveTaskStats(), valuesRedacted: true };
|
||||
}
|
||||
}
|
||||
|
||||
function currentPageUrl() {
|
||||
@@ -77,6 +285,14 @@ function boundedInteger(value, fallback, min, max) {
|
||||
return integer;
|
||||
}
|
||||
|
||||
function requiredBoundedInteger(value, name, min, max) {
|
||||
const parsed = Number(value);
|
||||
if (!Number.isInteger(parsed) || parsed < min || parsed > max) {
|
||||
throw new Error(name + " must come from the owning YAML and be an integer between " + min + " and " + max);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function positiveNumber(value, fallback) {
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
|
||||
@@ -306,9 +522,33 @@ function errorSummary(error) {
|
||||
}
|
||||
|
||||
function sleep(ms) {
|
||||
return interruptibleRunnerSleep(ms);
|
||||
}
|
||||
|
||||
function boundedCleanupSleep(ms) {
|
||||
return new Promise((resolve) => setTimeout(resolve, Math.max(0, ms)));
|
||||
}
|
||||
|
||||
function interruptibleRunnerSleep(ms) {
|
||||
if (stopping) return Promise.resolve();
|
||||
return new Promise((resolve) => {
|
||||
const timer = setTimeout(() => {
|
||||
runnerSleepWakes.delete(wake);
|
||||
resolve();
|
||||
}, Math.max(0, ms));
|
||||
const wake = () => {
|
||||
clearTimeout(timer);
|
||||
runnerSleepWakes.delete(wake);
|
||||
resolve();
|
||||
};
|
||||
runnerSleepWakes.add(wake);
|
||||
});
|
||||
}
|
||||
|
||||
function interruptRunnerSleep() {
|
||||
for (const wake of [...runnerSleepWakes]) wake();
|
||||
}
|
||||
|
||||
function withHardTimeout(promise, timeoutMs, message) {
|
||||
const guarded = Promise.resolve(promise);
|
||||
let timer = null;
|
||||
|
||||
@@ -1285,12 +1285,10 @@ async function launchWorkbenchFromTask(command) {
|
||||
}, { timeout: 30000 }).then(async (response) => {
|
||||
let chatPayload = null;
|
||||
let chatPayloadError = null;
|
||||
try {
|
||||
chatPayload = await response.json();
|
||||
} catch (error) {
|
||||
chatPayloadError = errorSummary(error);
|
||||
}
|
||||
const headers = response.headers();
|
||||
const chatBody = await boundedResponseJson(response);
|
||||
chatPayload = chatBody.value;
|
||||
chatPayloadError = chatBody.error;
|
||||
return {
|
||||
observed: true,
|
||||
status: response.status(),
|
||||
@@ -1316,11 +1314,9 @@ async function launchWorkbenchFromTask(command) {
|
||||
const launchTraceHeader = typeof headers["x-hwlab-otel-trace-id"] === "string" ? headers["x-hwlab-otel-trace-id"] : null;
|
||||
let payload = null;
|
||||
let responseParseError = null;
|
||||
try {
|
||||
payload = await launchResponse.json();
|
||||
} catch (error) {
|
||||
responseParseError = errorSummary(error);
|
||||
}
|
||||
const launchBody = await boundedResponseJson(launchResponse);
|
||||
payload = launchBody.value;
|
||||
responseParseError = launchBody.error;
|
||||
const sessionId = sessionIdFromAgentSessionPayload(payload);
|
||||
const workbenchUrl = safeUrlPath(payload?.workbenchUrl || payload?.url || "");
|
||||
const contractVersion = typeof payload?.contractVersion === "string" ? payload.contractVersion : null;
|
||||
|
||||
@@ -48,10 +48,13 @@ test("manual start is blocked at exactly 4 GiB and requires controlled GC then M
|
||||
assert.equal(memory.comparator, "less-than-or-equal");
|
||||
assert.equal(memory.thresholdMatched, true);
|
||||
assert.equal(gcNext.requiredBeforeStart, true);
|
||||
assert.match(String(gcNext.planCommand), /gc remote NC01 plan$/u);
|
||||
assert.match(String(gcNext.runCommand), /gc remote NC01 run --confirm$/u);
|
||||
assert.match(String(gcNext.planCommand), /gc remote NC01 plan --memory-pressure-only$/u);
|
||||
assert.match(String(gcNext.runCommand), /gc remote NC01 run --confirm --memory-pressure-only$/u);
|
||||
assert.match(String(gcNext.statusCommandTemplate), /gc remote NC01 status --job-id <job-id>$/u);
|
||||
assert.match(String(gcNext.recheckCommand), /gc remote NC01 memory$/u);
|
||||
assert.equal(gcNext.recheckMetric, "MemAvailable");
|
||||
assert.deepEqual(gcNext.requiredOrder, ["plan", "run", "status-until-terminal", "memory-recheck"]);
|
||||
assert.deepEqual(gcNext.startRemainsBlockedUntil, ["gc-job-terminal", "MemAvailable-threshold-cleared"]);
|
||||
});
|
||||
|
||||
test("sentinel allows exactly 4 GiB but skips one byte below without considering swap", () => {
|
||||
@@ -127,7 +130,7 @@ test("manual and sentinel gates return before any observer launch path", () => {
|
||||
const sentinelSource = readFileSync(rootPath("scripts/src/hwlab-node-web-sentinel-p5-observe.ts"), "utf8");
|
||||
const quickVerify = sentinelSource.indexOf("export function runSentinelQuickVerify");
|
||||
const sentinelGuard = sentinelSource.indexOf("runWebProbeMemoryGuard(state.spec, \"sentinel-cadence\")", quickVerify);
|
||||
const sentinelSkip = sentinelSource.indexOf('status: "skipped-wait-next-round"', sentinelGuard);
|
||||
const sentinelSkip = sentinelSource.indexOf("return recordSkippedQuickVerify", sentinelGuard);
|
||||
const providerPreflight = sentinelSource.indexOf("runSentinelExactProviderProfilePreflight", quickVerify);
|
||||
const observeStart = sentinelSource.indexOf('"web-probe", "observe", "start"', quickVerify);
|
||||
assert.ok(quickVerify >= 0 && sentinelGuard > quickVerify && sentinelSkip > sentinelGuard);
|
||||
|
||||
@@ -39,13 +39,19 @@ function humanBytes(value: number | null): string | null {
|
||||
function controlledGcNext(node: string): Record<string, unknown> {
|
||||
return {
|
||||
automaticGc: false,
|
||||
requiredOrder: ["plan", "run", "recheck"],
|
||||
planCommand: `bun scripts/cli.ts gc remote ${node} plan`,
|
||||
runCommand: `bun scripts/cli.ts gc remote ${node} run --confirm`,
|
||||
requiredOrder: ["plan", "run", "status-until-terminal", "memory-recheck"],
|
||||
scope: "memory-pressure-only",
|
||||
planCommand: `bun scripts/cli.ts gc remote ${node} plan --memory-pressure-only`,
|
||||
runCommand: `bun scripts/cli.ts gc remote ${node} run --confirm --memory-pressure-only`,
|
||||
jobIdSource: "gc remote run result.data.jobId",
|
||||
statusCommandTemplate: `bun scripts/cli.ts gc remote ${node} status --job-id <job-id>`,
|
||||
terminalStatuses: ["succeeded", "failed", "completed", "blocked"],
|
||||
statusRequiredWhenJobIdReturned: true,
|
||||
recheckCommand: `bun scripts/cli.ts gc remote ${node} memory`,
|
||||
recheckMetric: "MemAvailable",
|
||||
recheckSource: "/proc/meminfo",
|
||||
swapExcluded: true,
|
||||
startRemainsBlockedUntil: ["gc-job-terminal", "MemAvailable-threshold-cleared"],
|
||||
valuesRedacted: true,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -439,6 +439,7 @@ function knownWebProbeFindingIds(): string[] {
|
||||
"scripts/src/hwlab-node-web-observe-analyzer-window-page-source.ts",
|
||||
"scripts/src/hwlab-node-web-observe-analyzer-workbench-triad-source.ts",
|
||||
"scripts/src/hwlab-node-web-sentinel-p5-observe.ts",
|
||||
"scripts/src/hwlab-node-web-sentinel-p5-observe-analysis.ts",
|
||||
]) {
|
||||
let source = "";
|
||||
try {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -13,6 +13,7 @@ import {
|
||||
quickVerifyCleanupFindings,
|
||||
quickVerifyUnhandledExceptionSummary,
|
||||
readAnalysisSummaryFromWorkspace,
|
||||
sentinelChildStartMemorySkip,
|
||||
sentinelServiceProxyInvocation,
|
||||
stopQuickVerifyObserverAfterPartialStart,
|
||||
} from "./hwlab-node-web-sentinel-p5-observe";
|
||||
@@ -69,6 +70,7 @@ test("recorded sentinel run is available through the latest report index", () =>
|
||||
views: { summary: { renderedText: "indexed summary" } },
|
||||
findingCount: 0,
|
||||
artifactCount: 0,
|
||||
updatedAt: "2026-07-13T00:00:00.000Z",
|
||||
valuesRedacted: true,
|
||||
});
|
||||
const latest = service.report("summary", null);
|
||||
@@ -100,6 +102,7 @@ test("latest report selects the newest stored run without a report SHA", () => {
|
||||
views: { summary: { renderedText: "old indexed summary" } },
|
||||
findingCount: 0,
|
||||
artifactCount: 0,
|
||||
updatedAt: "2026-07-13T00:00:00.000Z",
|
||||
valuesRedacted: true,
|
||||
});
|
||||
service.recordRun({
|
||||
@@ -111,6 +114,7 @@ test("latest report selects the newest stored run without a report SHA", () => {
|
||||
findings: [{ id: "quick-verify-observer-start-failed", severity: "error", summary: "observer start failed" }],
|
||||
findingCount: 1,
|
||||
artifactCount: 0,
|
||||
updatedAt: "2026-07-13T00:00:01.000Z",
|
||||
valuesRedacted: true,
|
||||
});
|
||||
|
||||
@@ -255,7 +259,7 @@ test("quick verify observer lifecycle stops once on success, failure, and except
|
||||
}
|
||||
});
|
||||
|
||||
test("quick verify observer lifecycle records a thrown stop without retrying it", () => {
|
||||
test("quick verify observer lifecycle retries a failed stop again from the bounded finally fallback", () => {
|
||||
let calls = 0;
|
||||
const lifecycle = createQuickVerifyObserverLifecycle("webobs-stop-throws", () => {
|
||||
calls += 1;
|
||||
@@ -265,12 +269,28 @@ test("quick verify observer lifecycle records a thrown stop without retrying it"
|
||||
const first = lifecycle.stop("observe-stop-after-failure");
|
||||
const fallback = lifecycle.stop("observe-stop-after-finally");
|
||||
|
||||
assert.equal(calls, 1);
|
||||
assert.strictEqual(first, fallback);
|
||||
assert.equal(calls, 4);
|
||||
assert.notStrictEqual(first, fallback);
|
||||
assert.equal(first.failure, "observer-stop-unhandled-exception");
|
||||
assert.equal(Array.isArray(fallback.stopAttempts), true);
|
||||
assert.equal((fallback.stopAttempts as unknown[]).length, 4);
|
||||
assert.equal(JSON.stringify(first).includes("sensitive-stop-error"), false);
|
||||
});
|
||||
|
||||
test("quick verify observer lifecycle retries a failed force-stop and caches the successful fallback", () => {
|
||||
let calls = 0;
|
||||
const lifecycle = createQuickVerifyObserverLifecycle("webobs-stop-retry", (_observerId, phase) => {
|
||||
calls += 1;
|
||||
return { phase, ok: calls === 2, observerId: "webobs-stop-retry" };
|
||||
});
|
||||
const first = lifecycle.stop("observe-stop-after-failure");
|
||||
const cached = lifecycle.stop("observe-stop-after-finally");
|
||||
assert.equal(calls, 2);
|
||||
assert.equal(first.ok, true);
|
||||
assert.strictEqual(first, cached);
|
||||
assert.equal(Array.isArray(first.stopAttempts), true);
|
||||
});
|
||||
|
||||
test("quick verify cleans a partial start only when start output contains an observer id", () => {
|
||||
const calls: { observerId: string; phase: string }[] = [];
|
||||
const stopObserver = (observerId: string, phase: string) => {
|
||||
@@ -296,19 +316,66 @@ test("quick verify unexpected exception evidence is bounded and redacted", () =>
|
||||
assert.equal(serialized.includes(secret), false);
|
||||
});
|
||||
|
||||
test("sentinel preserves a child launch-time memory race as skipped instead of observe-start-failed", () => {
|
||||
const skip = sentinelChildStartMemorySkip({
|
||||
ok: true,
|
||||
command: "web-probe observe start",
|
||||
data: {
|
||||
ok: true,
|
||||
status: "skipped-wait-next-round",
|
||||
observerCreated: false,
|
||||
memoryGuard: {
|
||||
mode: "sentinel-cadence",
|
||||
status: "skipped",
|
||||
memory: {
|
||||
metric: "MemAvailable",
|
||||
source: "/proc/meminfo",
|
||||
swapExcluded: true,
|
||||
availableBytes: 4 * 1024 ** 3 - 1,
|
||||
thresholdBytes: 4 * 1024 ** 3,
|
||||
comparator: "less-than",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(skip?.status, "skipped-wait-next-round");
|
||||
assert.equal(skip?.decision, "skip-observer");
|
||||
assert.equal(skip?.observerCreated, false);
|
||||
assert.equal((skip?.memoryGuard as Record<string, unknown>).mode, "sentinel-cadence");
|
||||
assert.equal(sentinelChildStartMemorySkip({ data: { status: "blocked", observerCreated: false } }), null);
|
||||
});
|
||||
|
||||
test("web observe runner records finite lifecycle policy and closes the browser", () => {
|
||||
const source = nodeWebObserveRunnerSource();
|
||||
|
||||
assert.match(source, /sampling:\s*\{[^}]*maxSamples,[^}]*maxRunMs,[^}]*maxRunSeconds:/u);
|
||||
assert.match(source, /observerLifecycle:\s*\{[^}]*maxSamples,[^}]*maxRunMs,[^}]*maxRunSeconds:/u);
|
||||
assert.match(source, /if \(maxRunMs > 0 && Date\.now\(\) - startedAtMs >= maxRunMs\)/u);
|
||||
assert.match(source, /const maxScreenshots = boundedInteger\(process\.env\.UNIDESK_WEB_OBSERVE_MAX_SCREENSHOTS/u);
|
||||
assert.match(source, /const maxPerformanceEntries = boundedInteger\(process\.env\.UNIDESK_WEB_OBSERVE_MAX_PERFORMANCE_ENTRIES/u);
|
||||
assert.match(source, /const maxScreenshots = requiredBoundedInteger\(process\.env\.UNIDESK_WEB_OBSERVE_MAX_SCREENSHOTS/u);
|
||||
assert.match(source, /const maxPerformanceEntries = requiredBoundedInteger\(process\.env\.UNIDESK_WEB_OBSERVE_MAX_PERFORMANCE_ENTRIES/u);
|
||||
assert.match(source, /const maxRealtimeEventBodyBytes = requiredBoundedInteger\(process\.env\.UNIDESK_WEB_OBSERVE_MAX_REALTIME_EVENT_BODY_BYTES/u);
|
||||
assert.match(source, /while \(browserProcessHistory\.length > maxBrowserProcessHistoryEntries\)/u);
|
||||
assert.match(source, /while \(browserFreezeSignalHistory\.length > maxBrowserFreezeSignalHistoryEntries\)/u);
|
||||
assert.match(source, /if \(screenshotCount >= maxScreenshots\)/u);
|
||||
assert.match(source, /const maxBytes = responseBodyMaxBytes/u);
|
||||
assert.match(source, /function responseBodyReadContract\(response, maxBytes = responseBodyMaxBytes\)/u);
|
||||
assert.match(source, /contentEncoding[\s\S]*?reason: "encoded-body-size-unbounded"/u);
|
||||
assert.match(source, /!Number\.isFinite\(contentLength\)[\s\S]*?reason: "content-length-unavailable"/u);
|
||||
assert.match(source, /bodyReadSkipped: body\.error\?\.reason \|\| "bounded-response-read-failed"/u);
|
||||
assert.match(source, /\.slice\(0, maxProcessScanEntries\)/u);
|
||||
assert.match(source, /if \(items\.length > maxEntries\)[\s\S]*?items\.splice/u);
|
||||
assert.match(source, /process\.once\(signal,[\s\S]*?closeBrowserResources\("signal:" \+ signal\)/u);
|
||||
assert.match(source, /finally \{[\s\S]*?await closeBrowserResources/u);
|
||||
assert.match(source, /closingObserverPage\.close\(\)[\s\S]*?closingPage\.close\(\)[\s\S]*?closingContext\.close\(\)[\s\S]*?closingBrowser\.close\(\)/u);
|
||||
|
||||
const directory = mkdtempSync(join(tmpdir(), "web-observe-runner-check-"));
|
||||
try {
|
||||
const runner = join(directory, "observer-runner.mjs");
|
||||
writeFileSync(runner, source);
|
||||
const syntax = spawnSync("node", ["--check", runner], { encoding: "utf8" });
|
||||
assert.equal(syntax.status, 0, syntax.stderr);
|
||||
} finally {
|
||||
rmSync(directory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -2728,7 +2728,7 @@ function reportRunView(config: WebProbeSentinelServiceConfig, db: Database, view
|
||||
return { ok: false, error: "unsupported-report-view", view, valuesRedacted: true };
|
||||
}
|
||||
const row = runId === null
|
||||
? db.query("SELECT * FROM runs WHERE sentinel_id = ? AND node = ? AND lane = ? ORDER BY updated_at DESC LIMIT 1").get(config.sentinelId, config.node, config.lane) as Record<string, unknown> | null
|
||||
? db.query("SELECT * FROM runs WHERE sentinel_id = ? AND node = ? AND lane = ? ORDER BY updated_at DESC, rowid DESC LIMIT 1").get(config.sentinelId, config.node, config.lane) as Record<string, unknown> | null
|
||||
: readRunRow(config, db, runId);
|
||||
if (row === null) return { ok: false, error: "report-run-missing", runId, view, valuesRedacted: true };
|
||||
const selectedRunId = stringOrNull(row.id);
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { resolveCliChildJsonCommandResult, type CliChildJsonResolution } from "../cli-child-json-recovery";
|
||||
import type { HwlabRuntimeLaneSpec } from "../hwlab-node-lanes";
|
||||
import { runWebProbeMemoryGuard } from "../hwlab-node-web-probe-memory-guard";
|
||||
import { evaluateWebProbeMemoryGuard, runWebProbeMemoryGuard } from "../hwlab-node-web-probe-memory-guard";
|
||||
import { nodeWebObserveRunnerSource } from "../hwlab-node-web-observe-runner-source";
|
||||
import { withWebObserveCommandRendered, withWebObserveStatusRendered } from "../hwlab-node-web-observe-render";
|
||||
import { buildWebObserveWrapperForObserveOptions, webObserveWrapperStateDirFromStatus } from "../hwlab-node-web-observe-wrapper";
|
||||
@@ -455,6 +455,134 @@ console.log(JSON.stringify({
|
||||
`;
|
||||
}
|
||||
|
||||
export function nodeWebObserveFinalLaunchGuardSource(): string {
|
||||
return String.raw`
|
||||
const fs = require("fs");
|
||||
const thresholdBytes = Number(process.argv[2]);
|
||||
const comparator = process.argv[3];
|
||||
const mode = process.argv[4];
|
||||
const jobId = process.argv[5];
|
||||
const stateDir = process.argv[6];
|
||||
const source = "/proc/meminfo";
|
||||
|
||||
function finish(reason, availableBytes) {
|
||||
const valid = Number.isSafeInteger(availableBytes) && availableBytes >= 0;
|
||||
const thresholdMatched = valid
|
||||
? comparator === "less-than" ? availableBytes < thresholdBytes : availableBytes <= thresholdBytes
|
||||
: null;
|
||||
const blocked = reason !== null || thresholdMatched === true;
|
||||
const cadenceSkip = blocked && mode === "sentinel-cadence";
|
||||
const payload = {
|
||||
ok: !blocked || cadenceSkip,
|
||||
command: "web-probe-observe start",
|
||||
status: blocked ? cadenceSkip ? "skipped-wait-next-round" : "blocked" : "allowed",
|
||||
reason: reason || (thresholdMatched ? "memavailable-threshold-matched" : null),
|
||||
failureKind: blocked ? "web-probe-final-memory-start-guard" : null,
|
||||
jobId,
|
||||
stateDir,
|
||||
observerCreated: false,
|
||||
browserCreated: false,
|
||||
startBlocked: blocked,
|
||||
decision: blocked ? cadenceSkip ? "skip-observer" : "gc-required" : "start-observer",
|
||||
mutation: blocked,
|
||||
stateArtifactCreated: blocked,
|
||||
memoryGuard: {
|
||||
phase: "remote-launch-critical-section",
|
||||
metric: "MemAvailable",
|
||||
source,
|
||||
swapExcluded: true,
|
||||
availableBytes: valid ? availableBytes : null,
|
||||
thresholdBytes,
|
||||
comparator,
|
||||
thresholdMatched,
|
||||
},
|
||||
observedAt: new Date().toISOString(),
|
||||
valuesRedacted: true,
|
||||
};
|
||||
console.log(JSON.stringify(payload));
|
||||
process.exit(blocked ? 42 : 0);
|
||||
}
|
||||
|
||||
if (!Number.isSafeInteger(thresholdBytes) || thresholdBytes <= 0) finish("memory-threshold-invalid", null);
|
||||
if (comparator !== "less-than" && comparator !== "less-than-or-equal") finish("memory-comparator-invalid", null);
|
||||
let text;
|
||||
try { text = fs.readFileSync(source, "utf8"); } catch { finish("proc-meminfo-read-failed", null); }
|
||||
const match = text.match(/^MemAvailable:\s+(\d+)\s+kB$/m);
|
||||
if (!match) finish("memavailable-missing", null);
|
||||
const availableBytes = Number(match[1]) * 1024;
|
||||
finish(null, availableBytes);
|
||||
`;
|
||||
}
|
||||
|
||||
export function nodeWebObserveLaunchReadinessSource(): string {
|
||||
return String.raw`
|
||||
const fs = require("fs");
|
||||
const runnerPid = Number(process.argv[2]);
|
||||
const stateDir = process.argv[3];
|
||||
const expectedJobId = process.argv[4];
|
||||
const timeoutMs = Number(process.argv[5]);
|
||||
const pollMs = Number(process.argv[6]);
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
const pause = (ms) => Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
||||
const readJson = (name) => { try { return JSON.parse(fs.readFileSync(stateDir + "/" + name, "utf8")); } catch { return null; } };
|
||||
|
||||
function procRows() {
|
||||
const rows = new Map();
|
||||
let names = [];
|
||||
try { names = fs.readdirSync("/proc").filter((name) => /^\d+$/.test(name)); } catch { return rows; }
|
||||
for (const name of names) {
|
||||
try {
|
||||
const stat = fs.readFileSync("/proc/" + name + "/stat", "utf8");
|
||||
const close = stat.lastIndexOf(")");
|
||||
const fields = stat.slice(close + 2).trim().split(/\s+/);
|
||||
const command = fs.readFileSync("/proc/" + name + "/cmdline").toString("utf8").replace(/\0/g, " ");
|
||||
rows.set(Number(name), { pid: Number(name), ppid: Number(fields[1]), command });
|
||||
} catch {}
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
function descendants(rows) {
|
||||
const selected = new Set([runnerPid]);
|
||||
let changed = true;
|
||||
while (changed) {
|
||||
changed = false;
|
||||
for (const row of rows.values()) if (!selected.has(row.pid) && selected.has(row.ppid)) { selected.add(row.pid); changed = true; }
|
||||
}
|
||||
return [...selected].map((pid) => rows.get(pid)).filter(Boolean);
|
||||
}
|
||||
|
||||
function matchingStateRecord(name, value) {
|
||||
if (!value || typeof value !== "object") return null;
|
||||
const status = String(value.status || "");
|
||||
if (!/^(starting|running|ready)$/i.test(status)) return null;
|
||||
if (String(value.jobId || "") !== expectedJobId || Number(value.pid) !== runnerPid) return null;
|
||||
return { name, status, jobId: String(value.jobId), pid: Number(value.pid) };
|
||||
}
|
||||
|
||||
while (Date.now() <= deadline) {
|
||||
const rows = procRows();
|
||||
const runnerAlive = rows.has(runnerPid);
|
||||
const children = runnerAlive ? descendants(rows) : [];
|
||||
const browserChild = children.find((row) => row.pid !== runnerPid && /(?:^|[ /])(chrome|chromium)(?:[ /]|$)/i.test(row.command));
|
||||
const heartbeat = readJson("heartbeat.json");
|
||||
const manifest = readJson("manifest.json");
|
||||
const stateRecord = matchingStateRecord("heartbeat.json", heartbeat) || matchingStateRecord("manifest.json", manifest);
|
||||
if (runnerAlive && browserChild && stateRecord) {
|
||||
console.log(JSON.stringify({ ok: true, expectedJobId, runnerPid, runnerAlive: true, browserPid: browserChild.pid, stateRecord, readiness: "current-state-and-browser-descendant", observedAt: new Date().toISOString(), valuesRedacted: true }));
|
||||
process.exit(0);
|
||||
}
|
||||
if (!runnerAlive) {
|
||||
console.log(JSON.stringify({ ok: false, reason: "runner-exited-before-ready", expectedJobId, runnerPid, runnerAlive: false, stateRecord, observedAt: new Date().toISOString(), valuesRedacted: true }));
|
||||
process.exit(43);
|
||||
}
|
||||
pause(pollMs);
|
||||
}
|
||||
console.log(JSON.stringify({ ok: false, reason: "launch-readiness-timeout", expectedJobId, runnerPid, runnerAlive: fs.existsSync("/proc/" + runnerPid), observedAt: new Date().toISOString(), valuesRedacted: true }));
|
||||
process.exit(44);
|
||||
`;
|
||||
}
|
||||
|
||||
export function runNodeWebProbeObserveStart(
|
||||
options: NodeWebProbeObserveOptions,
|
||||
spec: HwlabRuntimeLaneSpec,
|
||||
@@ -482,7 +610,9 @@ export function runNodeWebProbeObserveStart(
|
||||
};
|
||||
}
|
||||
const observerLifecycle = spec.webProbe?.resourcePolicy?.observerLifecycle;
|
||||
const finalMemoryPolicy = spec.webProbe?.resourcePolicy?.memoryStartGuard;
|
||||
if (observerLifecycle === undefined) throw new Error("web-probe observer lifecycle policy is missing from the owning YAML");
|
||||
if (finalMemoryPolicy === undefined) throw new Error("web-probe memory start policy is missing from the owning YAML");
|
||||
const jobId = `webobs-${Date.now().toString(36)}-${randomBytes(3).toString("hex")}`;
|
||||
const timestamp = new Date().toISOString().replace(/[-:]/gu, "").replace(/[.]\d{3}Z$/u, "Z");
|
||||
const day = timestamp.slice(0, 8);
|
||||
@@ -520,8 +650,24 @@ export function runNodeWebProbeObserveStart(
|
||||
`UNIDESK_WEB_OBSERVE_MAX_PERFORMANCE_ENTRIES=${shellQuote(String(observerLifecycle.maxPerformanceEntries))}`,
|
||||
`UNIDESK_WEB_OBSERVE_MAX_BROWSER_PROCESS_HISTORY_ENTRIES=${shellQuote(String(observerLifecycle.maxBrowserProcessHistoryEntries))}`,
|
||||
`UNIDESK_WEB_OBSERVE_MAX_BROWSER_FREEZE_SIGNAL_HISTORY_ENTRIES=${shellQuote(String(observerLifecycle.maxBrowserFreezeSignalHistoryEntries))}`,
|
||||
`UNIDESK_WEB_OBSERVE_MAX_REALTIME_EVENT_ENTRIES=${shellQuote(String(observerLifecycle.maxRealtimeEventEntries))}`,
|
||||
`UNIDESK_WEB_OBSERVE_MAX_REALTIME_EVENT_BODY_BYTES=${shellQuote(String(observerLifecycle.maxRealtimeEventBodyBytes))}`,
|
||||
`UNIDESK_WEB_OBSERVE_MAX_DOM_EVIDENCE_ROWS=${shellQuote(String(observerLifecycle.maxDomEvidenceRows))}`,
|
||||
`UNIDESK_WEB_OBSERVE_MAX_PROCESS_SCAN_ENTRIES=${shellQuote(String(observerLifecycle.maxProcessScanEntries))}`,
|
||||
`UNIDESK_WEB_OBSERVE_MAX_PERFORMANCE_CAPTURE_SECONDS=${shellQuote(String(observerLifecycle.maxPerformanceCaptureSeconds))}`,
|
||||
`UNIDESK_WEB_OBSERVE_RESPONSE_BODY_MAX_BYTES=${shellQuote(String(observerLifecycle.maxResponseBodyBytes))}`,
|
||||
`UNIDESK_WEB_OBSERVE_WEB_PERFORMANCE_BODY_MAX_BYTES=${shellQuote(String(observerLifecycle.maxWebPerformanceBodyBytes))}`,
|
||||
`UNIDESK_WEB_OBSERVE_MAX_JSONL_QUEUE_ENTRIES=${shellQuote(String(observerLifecycle.maxJsonlQueueEntries))}`,
|
||||
`UNIDESK_WEB_OBSERVE_MAX_JSONL_QUEUE_BYTES=${shellQuote(String(observerLifecycle.maxJsonlQueueBytes))}`,
|
||||
`UNIDESK_WEB_OBSERVE_MAX_JSONL_LINE_BYTES=${shellQuote(String(observerLifecycle.maxJsonlLineBytes))}`,
|
||||
`UNIDESK_WEB_OBSERVE_JSONL_FLUSH_TIMEOUT_MS=${shellQuote(String(observerLifecycle.jsonlFlushTimeoutMs))}`,
|
||||
`UNIDESK_WEB_OBSERVE_MAX_RESPONSE_BODY_IN_FLIGHT=${shellQuote(String(observerLifecycle.maxResponseBodyInFlight))}`,
|
||||
`UNIDESK_WEB_OBSERVE_MAX_PASSIVE_LISTENER_TASKS=${shellQuote(String(observerLifecycle.maxPassiveListenerTasks))}`,
|
||||
`UNIDESK_WEB_OBSERVE_PASSIVE_TASK_FLUSH_TIMEOUT_MS=${shellQuote(String(observerLifecycle.passiveTaskFlushTimeoutMs))}`,
|
||||
`UNIDESK_WEB_OBSERVE_CLOSE_STEP_TIMEOUT_MS=${shellQuote(String(observerLifecycle.closeStepTimeoutMs))}`,
|
||||
`UNIDESK_WEB_OBSERVE_MAX_PROCESS_READ_CONCURRENCY=${shellQuote(String(observerLifecycle.maxProcessReadConcurrency))}`,
|
||||
`UNIDESK_WEB_OBSERVE_MAX_REALTIME_TOTAL_ENTRIES=${shellQuote(String(observerLifecycle.maxRealtimeTotalEntries))}`,
|
||||
`UNIDESK_WEB_OBSERVE_MAX_REALTIME_TOTAL_BODY_BYTES=${shellQuote(String(observerLifecycle.maxRealtimeTotalBodyBytes))}`,
|
||||
`UNIDESK_WEB_OBSERVE_VIEWPORT=${shellQuote(options.viewport)}`,
|
||||
`UNIDESK_WEB_OBSERVE_BROWSER_PROXY_MODE=${shellQuote(options.browserProxyMode)}`,
|
||||
`UNIDESK_WEB_OBSERVE_ALERT_THRESHOLDS_JSON=${shellQuote(JSON.stringify(alertThresholds))}`,
|
||||
@@ -539,6 +685,26 @@ export function runNodeWebProbeObserveStart(
|
||||
`UNIDESK_WEB_OBSERVE_AUTH_LOGIN_MAX_DELAY_MS=${shellQuote(String(authLogin.maxDelayMs))}`,
|
||||
]),
|
||||
].join(" ");
|
||||
const finalComparator = options.memoryGuardMode === "sentinel-cadence"
|
||||
? finalMemoryPolicy.sentinelComparator
|
||||
: finalMemoryPolicy.manualComparator;
|
||||
const finalGuardFailure = (reason: string) => JSON.stringify({
|
||||
ok: false,
|
||||
command: "web-probe-observe start",
|
||||
status: "blocked",
|
||||
reason,
|
||||
failureKind: "web-probe-final-memory-start-guard",
|
||||
jobId,
|
||||
stateDir,
|
||||
observerCreated: false,
|
||||
startBlocked: true,
|
||||
mutation: true,
|
||||
stateArtifactCreated: true,
|
||||
browserCreated: false,
|
||||
valuesRedacted: true,
|
||||
});
|
||||
const launchFailureOutputSource = "const fs=require('fs'); const dir=process.argv[1]; const read=(n)=>{try{return JSON.parse(fs.readFileSync(dir+'/'+n,'utf8'))}catch{return null}}; const guard=read('launch-guard.json'); const readiness=read('launch-ready.json'); const pid=Number(process.argv[3]); const bool=(v)=>v==='true'; console.log(JSON.stringify({ok:false,command:'web-probe-observe start',status:'blocked',reason:'launch-readiness-unconfirmed',failureKind:'web-probe-launch-readiness',jobId:process.argv[2],stateDir:dir,observerCreated:false,browserCreated:false,browserCreationAttempted:true,startBlocked:true,mutation:true,stateArtifactCreated:true,memoryGuard:guard&&guard.memoryGuard||null,finalLaunchGuard:guard,launchReadiness:readiness,cleanup:{attempted:true,processGroupId:pid,termSent:bool(process.argv[4]),killSent:bool(process.argv[5]),aliveAfter:bool(process.argv[6]),completed:!bool(process.argv[6]),observedAt:new Date().toISOString(),valuesRedacted:true},valuesRedacted:true},null,2))";
|
||||
const cleanupPollCount = Math.ceil(finalMemoryPolicy.launchReadyTimeoutSeconds * 1000 / finalMemoryPolicy.launchReadyPollMilliseconds);
|
||||
const script = [
|
||||
"set -eu",
|
||||
`state_dir=${shellQuote(stateDir)}`,
|
||||
@@ -552,16 +718,71 @@ export function runNodeWebProbeObserveStart(
|
||||
"node -e \"const fs=require('fs'); fs.writeFileSync(process.argv[1], Buffer.from(fs.readFileSync(process.argv[2], 'utf8').replace(/\\s+/g, ''), 'base64'))\" \"$runner\" \"$runner_b64\"",
|
||||
"rm -f \"$runner_b64\"",
|
||||
"chmod 700 \"$runner\"",
|
||||
`if command -v setsid >/dev/null 2>&1; then setsid env ${runnerEnvAssignments} node "$runner" >"$state_dir/stdout.log" 2>"$state_dir/stderr.log" </dev/null & else nohup env ${runnerEnvAssignments} node "$runner" >"$state_dir/stdout.log" 2>"$state_dir/stderr.log" </dev/null & fi`,
|
||||
`launch_lock=${shellQuote(finalMemoryPolicy.launchLockPath)}`,
|
||||
`launch_guard_result="$state_dir/launch-guard.json"`,
|
||||
`launch_ready_result="$state_dir/launch-ready.json"`,
|
||||
`if ! command -v flock >/dev/null 2>&1; then printf '%s\\n' ${shellQuote(finalGuardFailure("host-flock-unavailable"))} >"$launch_guard_result"; cat "$launch_guard_result"; exit 0; fi`,
|
||||
`if ! command -v setsid >/dev/null 2>&1; then printf '%s\\n' ${shellQuote(finalGuardFailure("host-setsid-unavailable"))} >"$launch_guard_result"; cat "$launch_guard_result"; exit 0; fi`,
|
||||
`exec 9>"$launch_lock"`,
|
||||
`if ! flock -w ${shellQuote(String(finalMemoryPolicy.launchLockTimeoutSeconds))} 9; then printf '%s\\n' ${shellQuote(finalGuardFailure("launch-lock-timeout"))} >"$launch_guard_result"; cat "$launch_guard_result"; exit 0; fi`,
|
||||
"trap 'flock -u 9 2>/dev/null || true' EXIT",
|
||||
"set +e",
|
||||
`node - ${shellQuote(String(finalMemoryPolicy.thresholdBytes))} ${shellQuote(finalComparator)} ${shellQuote(options.memoryGuardMode)} ${shellQuote(jobId)} ${shellQuote(stateDir)} >"$launch_guard_result" <<'UNIDESK_WEB_OBSERVE_FINAL_GUARD'`,
|
||||
nodeWebObserveFinalLaunchGuardSource(),
|
||||
"UNIDESK_WEB_OBSERVE_FINAL_GUARD",
|
||||
"launch_guard_rc=$?",
|
||||
"set -e",
|
||||
"if [ \"$launch_guard_rc\" -ne 0 ]; then cat \"$launch_guard_result\"; flock -u 9; exit 0; fi",
|
||||
`setsid env ${runnerEnvAssignments} node "$runner" >"$state_dir/stdout.log" 2>"$state_dir/stderr.log" </dev/null 9>&- &`,
|
||||
"pid=$!",
|
||||
"printf '%s\\n' \"$pid\" >\"$state_dir/pid\"",
|
||||
"sleep 1",
|
||||
`node -e ${shellQuote("const fs=require('fs'); const dir=process.argv[1]; const read=(n)=>{try{return JSON.parse(fs.readFileSync(dir+'/'+n,'utf8'))}catch{return null}}; const pid=fs.existsSync(dir+'/pid')?fs.readFileSync(dir+'/pid','utf8').trim():null; console.log(JSON.stringify({ok:true,command:'web-probe-observe start',jobId:process.argv[2],stateDir:dir,pid:Number(pid)||null,manifestPath:dir+'/manifest.json',heartbeat:read('heartbeat.json'),manifest:read('manifest.json'),statusCommand:'bun scripts/cli.ts web-probe observe status --node '+process.argv[3]+' --lane '+process.argv[4]+' --state-dir '+dir,stopCommand:'bun scripts/cli.ts web-probe observe stop --node '+process.argv[3]+' --lane '+process.argv[4]+' --state-dir '+dir,valuesRedacted:true},null,2))")} "$state_dir" ${shellQuote(jobId)} ${shellQuote(options.node)} ${shellQuote(options.lane)}`,
|
||||
"set +e",
|
||||
`node - "$pid" "$state_dir" ${shellQuote(jobId)} ${shellQuote(String(finalMemoryPolicy.launchReadyTimeoutSeconds * 1000))} ${shellQuote(String(finalMemoryPolicy.launchReadyPollMilliseconds))} >"$launch_ready_result" <<'UNIDESK_WEB_OBSERVE_LAUNCH_READY'`,
|
||||
nodeWebObserveLaunchReadinessSource(),
|
||||
"UNIDESK_WEB_OBSERVE_LAUNCH_READY",
|
||||
"launch_ready_rc=$?",
|
||||
"set -e",
|
||||
`if [ "$launch_ready_rc" -ne 0 ]; then term_sent=false; if kill -TERM -- -"$pid" 2>/dev/null; then term_sent=true; fi; i=0; while [ "$i" -lt ${shellQuote(String(cleanupPollCount))} ] && kill -0 -- -"$pid" 2>/dev/null; do sleep ${shellQuote(String(finalMemoryPolicy.launchReadyPollMilliseconds / 1000))}; i=$((i+1)); done; kill_sent=false; if kill -0 -- -"$pid" 2>/dev/null; then if kill -KILL -- -"$pid" 2>/dev/null; then kill_sent=true; fi; fi; i=0; while [ "$i" -lt ${shellQuote(String(cleanupPollCount))} ] && kill -0 -- -"$pid" 2>/dev/null; do sleep ${shellQuote(String(finalMemoryPolicy.launchReadyPollMilliseconds / 1000))}; i=$((i+1)); done; alive_after=false; if kill -0 -- -"$pid" 2>/dev/null; then alive_after=true; fi; node -e ${shellQuote(launchFailureOutputSource)} "$state_dir" ${shellQuote(jobId)} "$pid" "$term_sent" "$kill_sent" "$alive_after"; flock -u 9; trap - EXIT; exit 0; fi`,
|
||||
"flock -u 9",
|
||||
"trap - EXIT",
|
||||
`node -e ${shellQuote("const fs=require('fs'); const dir=process.argv[1]; const read=(n)=>{try{return JSON.parse(fs.readFileSync(dir+'/'+n,'utf8'))}catch{return null}}; const pid=fs.existsSync(dir+'/pid')?fs.readFileSync(dir+'/pid','utf8').trim():null; console.log(JSON.stringify({ok:true,command:'web-probe-observe start',jobId:process.argv[2],stateDir:dir,pid:Number(pid)||null,manifestPath:dir+'/manifest.json',heartbeat:read('heartbeat.json'),manifest:read('manifest.json'),finalLaunchGuard:read('launch-guard.json'),launchReadiness:read('launch-ready.json'),observerCreated:true,browserCreated:true,observerMutation:true,stateArtifactMutation:true,statusCommand:'bun scripts/cli.ts web-probe observe status --node '+process.argv[3]+' --lane '+process.argv[4]+' --state-dir '+dir,stopCommand:'bun scripts/cli.ts web-probe observe stop --node '+process.argv[3]+' --lane '+process.argv[4]+' --state-dir '+dir,valuesRedacted:true},null,2))")} "$state_dir" ${shellQuote(jobId)} ${shellQuote(options.node)} ${shellQuote(options.lane)}`,
|
||||
].join("\n");
|
||||
const result = runTransWorkspaceStdinScript(options.node, spec.workspace, script, options.commandTimeoutSeconds);
|
||||
const startResolution = resolveWebObserveActionJson(result, "start");
|
||||
const started = startResolution.parsed;
|
||||
if (started?.observerCreated === false && started.startBlocked === true) {
|
||||
const finalSnapshot = record(started.memoryGuard);
|
||||
const finalMemoryGuard = evaluateWebProbeMemoryGuard(spec, options.memoryGuardMode, {
|
||||
ok: finalSnapshot.metric === "MemAvailable" && finalSnapshot.source === "/proc/meminfo" && finalSnapshot.swapExcluded === true,
|
||||
action: "gc remote memory",
|
||||
metric: finalSnapshot.metric,
|
||||
source: finalSnapshot.source,
|
||||
swapExcluded: finalSnapshot.swapExcluded,
|
||||
memory: { availableBytes: finalSnapshot.availableBytes },
|
||||
});
|
||||
return {
|
||||
...started,
|
||||
node: options.node,
|
||||
lane: options.lane,
|
||||
workspace: spec.workspace,
|
||||
initialMemoryGuard: memoryGuard,
|
||||
memoryGuard: finalMemoryGuard,
|
||||
finalLaunchGuard: started.finalLaunchGuard ?? started,
|
||||
valuesRedacted: true,
|
||||
};
|
||||
}
|
||||
const startOk = result.exitCode === 0 && started?.ok === true;
|
||||
const allowedFinalSnapshot = record(record(started?.finalLaunchGuard).memoryGuard);
|
||||
const effectiveMemoryGuard = allowedFinalSnapshot.metric === "MemAvailable"
|
||||
? evaluateWebProbeMemoryGuard(spec, options.memoryGuardMode, {
|
||||
ok: true,
|
||||
action: "gc remote memory",
|
||||
metric: allowedFinalSnapshot.metric,
|
||||
source: allowedFinalSnapshot.source,
|
||||
swapExcluded: allowedFinalSnapshot.swapExcluded,
|
||||
memory: { availableBytes: allowedFinalSnapshot.availableBytes },
|
||||
})
|
||||
: memoryGuard;
|
||||
const recovery = startOk
|
||||
? null
|
||||
: readNodeWebProbeObserveRemoteStatus({ ...options, id: jobId, stateDir }, spec, 1, Math.min(options.commandTimeoutSeconds, 30));
|
||||
@@ -620,7 +841,10 @@ export function runNodeWebProbeObserveStart(
|
||||
alertThresholds,
|
||||
browserFreezePolicy,
|
||||
projectManagement,
|
||||
memoryGuard,
|
||||
initialMemoryGuard: memoryGuard,
|
||||
memoryGuard: effectiveMemoryGuard,
|
||||
finalLaunchGuard: started?.finalLaunchGuard ?? null,
|
||||
launchReadiness: started?.launchReadiness ?? null,
|
||||
targetPath: options.targetPath,
|
||||
id: observerId,
|
||||
degradedReason,
|
||||
|
||||
@@ -189,12 +189,28 @@ test("observe start inherits finite observer lifecycle limits from the owning YA
|
||||
assert.deepEqual(spec.webProbe?.resourcePolicy?.observerLifecycle, {
|
||||
maxRunSeconds: 3600,
|
||||
maxSamples: 720,
|
||||
maxScreenshots: 48,
|
||||
maxPerformanceEntries: 2000,
|
||||
maxScreenshots: 24,
|
||||
maxPerformanceEntries: 1000,
|
||||
maxBrowserProcessHistoryEntries: 360,
|
||||
maxBrowserFreezeSignalHistoryEntries: 120,
|
||||
maxRealtimeEventEntries: 256,
|
||||
maxRealtimeEventBodyBytes: 65536,
|
||||
maxDomEvidenceRows: 1000,
|
||||
maxProcessScanEntries: 8192,
|
||||
maxPerformanceCaptureSeconds: 30,
|
||||
maxResponseBodyBytes: 524288,
|
||||
maxWebPerformanceBodyBytes: 65536,
|
||||
maxJsonlQueueEntries: 512,
|
||||
maxJsonlQueueBytes: 4194304,
|
||||
maxJsonlLineBytes: 262144,
|
||||
jsonlFlushTimeoutMs: 5000,
|
||||
maxResponseBodyInFlight: 2,
|
||||
maxPassiveListenerTasks: 64,
|
||||
passiveTaskFlushTimeoutMs: 5000,
|
||||
closeStepTimeoutMs: 5000,
|
||||
maxProcessReadConcurrency: 32,
|
||||
maxRealtimeTotalEntries: 512,
|
||||
maxRealtimeTotalBodyBytes: 4194304,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user