diff --git a/scripts/src/hwlab-node-web-observe-runner-source.ts b/scripts/src/hwlab-node-web-observe-runner-source.ts index 6be7ef87..43d9eeb4 100644 --- a/scripts/src/hwlab-node-web-observe-runner-source.ts +++ b/scripts/src/hwlab-node-web-observe-runner-source.ts @@ -21,6 +21,7 @@ const jobId = safeId(process.env.UNIDESK_WEB_OBSERVE_JOB_ID || "webobs-" + Date. 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 = positiveInteger(process.env.UNIDESK_WEB_OBSERVE_MAX_SAMPLES, 0); const observerRefreshIntervalMs = positiveInteger(process.env.UNIDESK_WEB_OBSERVE_OBSERVER_REFRESH_INTERVAL_MS, 180000); const viewport = parseViewport(process.env.UNIDESK_WEB_OBSERVE_VIEWPORT || "1440x900"); @@ -68,6 +69,7 @@ let activeCommandId = null; let stopping = false; let terminalStatus = "starting"; let lastScreenshotAtMs = 0; +let screenshotCaptureState = null; let auth = null; let pageLoadSeq = 0; let controlPageEpoch = 0; @@ -3539,7 +3541,7 @@ async function samplePage(reason, options = {}) { await sampleOnePage(observerPage, { reason, groupSeq, pageRole: "observer", targetPageId: observerPageId, pageEpoch: observerPageEpoch }).catch((error) => appendJsonl(files.errors, eventRecord("observer-sample-error", { pageRole: "observer", pageId: observerPageId, pageEpoch: observerPageEpoch, error: errorSummary(error) }))); } if (options?.screenshot !== false && screenshotIntervalMs > 0 && Date.now() - lastScreenshotAtMs >= screenshotIntervalMs) { - await withHardTimeout(captureScreenshot("checkpoint", "jpeg"), 15000, "captureScreenshot checkpoint exceeded 15s") + await captureScreenshot("checkpoint", "jpeg") .catch((error) => appendJsonl(files.errors, eventRecord("screenshot-error", { pageRole: "control", pageId, error: errorSummary(error) }))); } await writeHeartbeat({ status: terminalStatus }); @@ -4267,18 +4269,68 @@ function digestSessionRail(value) { async function captureScreenshot(reason, imageType = "png") { if (!page || page.isClosed()) throw new Error("page is not available for screenshot"); + if (screenshotCaptureState && screenshotCaptureState.settled !== true) { + const ageMs = Date.now() - Number(screenshotCaptureState.startedAtMs || Date.now()); + const error = new Error("screenshot capture already in progress"); + error.details = { + reason, + currentUrl: currentPageUrl(), + pageId, + activeReason: screenshotCaptureState.reason, + activeStartedAt: screenshotCaptureState.startedAt, + activeAgeMs: ageMs, + activeTimedOut: screenshotCaptureState.timedOut === true, + timeoutMs: screenshotCaptureTimeoutMs, + valuesRedacted: true, + }; + lastScreenshotAtMs = Date.now(); + throw error; + } artifactSeq += 1; const safeReason = safeId(String(reason || "manual")).slice(0, 40) || "manual"; const type = imageType === "jpeg" || imageType === "jpg" ? "jpeg" : "png"; const ext = type === "jpeg" ? "jpg" : "png"; const file = path.join(dirs.screenshots, String(sampleSeq).padStart(6, "0") + "_" + String(artifactSeq).padStart(4, "0") + "_" + safeReason + "." + ext); - const options = type === "jpeg" ? { path: file, type: "jpeg", quality: 70, fullPage: false } : { path: file, type: "png", fullPage: false }; - await page.screenshot(options); - const meta = await fileMeta(file); - const artifact = { seq: artifactSeq, sampleSeq, ts: new Date().toISOString(), kind: "screenshot", reason, path: file, type, byteCount: meta.byteCount, sha256: meta.sha256, pageId, currentUrl: currentPageUrl() }; - await appendJsonl(files.artifacts, artifact); - lastScreenshotAtMs = Date.now(); - return artifact; + const timeoutMs = screenshotCaptureTimeoutMs; + const options = type === "jpeg" + ? { path: file, type: "jpeg", quality: 70, fullPage: false, animations: "disabled", timeout: timeoutMs } + : { path: file, type: "png", fullPage: false, animations: "disabled", timeout: timeoutMs }; + const state = { reason, startedAtMs: Date.now(), startedAt: new Date().toISOString(), timeoutMs, settled: false, timedOut: false }; + screenshotCaptureState = state; + const screenshotPromise = page.screenshot(options) + .then((value) => { + state.settled = true; + return value; + }) + .catch((error) => { + state.settled = true; + throw error; + }) + .finally(() => { + if (screenshotCaptureState === state) screenshotCaptureState = null; + }); + try { + await withHardTimeout(screenshotPromise, timeoutMs + 1000, "captureScreenshot " + safeReason + " exceeded " + timeoutMs + "ms"); + const meta = await fileMeta(file); + const artifact = { seq: artifactSeq, sampleSeq, ts: new Date().toISOString(), kind: "screenshot", reason, path: file, type, byteCount: meta.byteCount, sha256: meta.sha256, pageId, currentUrl: currentPageUrl(), timeoutMs }; + await appendJsonl(files.artifacts, artifact); + lastScreenshotAtMs = Date.now(); + return artifact; + } catch (error) { + if (String(error?.message || "").includes("exceeded " + timeoutMs + "ms")) state.timedOut = true; + lastScreenshotAtMs = Date.now(); + const wrapped = error instanceof Error ? error : new Error(String(error)); + wrapped.details = { + ...(wrapped.details || {}), + reason, + currentUrl: currentPageUrl(), + pageId, + timeoutMs, + file, + valuesRedacted: true, + }; + throw wrapped; + } } async function captureCommandScreenshot(command) {