diff --git a/.github/workflows/nix-build.yml b/.github/workflows/nix-build.yml index 4cd79af1..b8994692 100644 --- a/.github/workflows/nix-build.yml +++ b/.github/workflows/nix-build.yml @@ -43,7 +43,20 @@ concurrency: # Keying on the event as well keeps a merge train collapsing to its latest push # without letting it cancel the schedule. group: nix-build-${{ github.event_name }}-${{ github.ref }} - cancel-in-progress: true + # false, where a PR-branch check would want true. This job takes half an hour + # and main takes merges minutes apart, so cancel-in-progress meant each merge + # evicted the previous run before it finished: the first four runs on main were + # cancelled at 21m38s, 25s, 7m05s and then one that only survived because the + # merges happened to stop. A cancelled run is not a failure either, so the + # branch showed nothing while the workflow reported nothing. + # + # What this buys is that a started run finishes, not that every merge is + # verified: GitHub holds a single pending entry per group and a newer push + # replaces it, so merges landing while a run is in flight still go unbuilt. + # That is the affordable half. Verifying each merge would need a queue this + # workflow does not have, and is not worth it for a half-hour job whose purpose + # is catching drift rather than gating a commit. + cancel-in-progress: false jobs: build: @@ -294,6 +307,13 @@ jobs: # capture failures are a separate problem, already reported above. echo "--- record then export ---" EXPORTED="" + # Tracked apart from EXPORTED so the verdict can name the stage that + # actually failed. Every run so far has died in record without export + # ever executing, while the annotation said "the export path does not + # work" -- an accusation aimed at the one component the run never + # reached, and the compositor addon is precisely what this step exists + # to vouch for. + RECORDED=0 for i in 1 2 3; do echo "=== export attempt $i/3 ===" rm -f /tmp/demo.openscreen /tmp/demo.mp4 @@ -302,8 +322,12 @@ jobs: if [ "$RC" -ne 0 ] || [ ! -f /tmp/demo.openscreen ]; then echo "record failed (rc=$RC); last lines:" tail -5 "/tmp/rec.$i.out" || true + # The bound inside get-sources names its own failure; surface it + # rather than leaving the reason five lines up in a scratch file. + grep -a "get-sources\]" "/tmp/rec.$i.out" | tail -3 || true continue fi + RECORDED=1 echo "recorded. project:" head -c 200 /tmp/demo.openscreen; echo @@ -319,8 +343,10 @@ jobs: done EXPORT_OK=0 - if [ -z "$EXPORTED" ]; then - echo "::error::No attempt produced an MP4. The compositor addon is packaged but the export path does not work." + if [ -z "$EXPORTED" ] && [ "$RECORDED" -eq 0 ]; then + echo "::error::No attempt got past record, so export never ran and the compositor addon is unproven. This is a capture failure on this host, not an export failure." + elif [ -z "$EXPORTED" ]; then + echo "::error::record produced a project but no attempt produced an MP4. The compositor addon is packaged and the export path does not work." else SIZE=$(wc -c < "$EXPORTED") # An MP4 opens with a 4-byte length then 'ftyp'. A zero-length or @@ -350,7 +376,7 @@ jobs: # per-attempt warnings above keep that flakiness visible without letting # it decide the build; tighten this to $ATTEMPTS once the capture failure # is understood and fixed. - echo "=== verdict: enumeration $OK/$ATTEMPTS ok, export $EXPORT_OK ===" + echo "=== verdict: enumeration $OK/$ATTEMPTS ok, record $RECORDED, export $EXPORT_OK ===" if [ "$EXPORT_OK" -ne 1 ] || [ "$OK" -eq 0 ]; then exit 1 fi diff --git a/electron/ipc/handlers.ts b/electron/ipc/handlers.ts index b6916e35..8596dfaf 100644 --- a/electron/ipc/handlers.ts +++ b/electron/ipc/handlers.ts @@ -52,7 +52,7 @@ import { import type { CursorTelemetryReader } from "../ai-edition/deep-agent/service"; import { DocumentService } from "../ai-edition/document-service"; import { LlmConfigStore } from "../ai-edition/llm-config-store"; -import { mainLogBuffer } from "../diagnostics/main-log-buffer"; +import { isDiagnosticModeEnabled, mainLogBuffer } from "../diagnostics/main-log-buffer"; import { mainT } from "../i18n"; import { getInstallChannel } from "../install-channel"; import { RECORDINGS_DIR } from "../main"; @@ -1716,12 +1716,40 @@ export function registerIpcHandlers( // await it, and a renderer-side race would only stop *waiting* while this // keeps running and its reply goes to nobody. Rejecting is what turns an // indefinite spinner into the pickers' existing error branch. - const sources = await withDeadline( - desktopCapturer.getSources(opts), - GET_SOURCES_TIMEOUT_MS, - `Desktop source enumeration did not return within ${GET_SOURCES_TIMEOUT_MS}ms. ` + - "This usually means the display or GPU stack cannot be reached — check that a display server is available.", - ); + // How long it actually took, under the existing diagnostic flag. The bound + // above turned an indefinite hang into a named failure, which is where the + // open question starts rather than ends: on a headless runner `openscreen + // sources` gets an answer within 20s four times in five while `record` -- + // the same call with the same options -- exceeds 30s every time. A duration + // on both paths is what tells those apart; a threshold alone cannot. + const startedAt = Date.now(); + const diagnostic = isDiagnosticModeEnabled(); + let sources: Awaited>; + try { + sources = await withDeadline( + desktopCapturer.getSources(opts), + GET_SOURCES_TIMEOUT_MS, + `Desktop source enumeration did not return within ${GET_SOURCES_TIMEOUT_MS}ms. ` + + "This usually means the display or GPU stack cannot be reached — check that a display server is available.", + ); + } catch (error) { + if (diagnostic) { + // The reason, not an assumption about it: this catch also sees a + // getSources that rejected on its own, well inside the deadline, and + // calling that a timeout would point the next reader at the wrong thing. + // The deadline error carries its own wording. + const reason = error instanceof Error ? error.message : String(error); + console.info( + `[get-sources] failed after ${Date.now() - startedAt}ms (types=${(opts?.types ?? []).join(",")}): ${reason}`, + ); + } + throw error; + } + if (diagnostic) { + console.info( + `[get-sources] returned ${sources.length} source(s) in ${Date.now() - startedAt}ms (types=${(opts?.types ?? []).join(",")})`, + ); + } lastEnumeratedSources = new Map(sources.map((source) => [source.id, source])); return sources.map((source) => ({ id: source.id,