mirror of
https://github.com/nexu-io/open-design.git
synced 2026-10-27 09:51:12 +01:00
* fix(desktop,web): stop 10-minute export hangs and make export failures attributable
Three findings from PostHog on `artifact_export_result` (14d, image
format): 13.5% of image exports fail, 22.8% on 0.19.2, and 20 users
failed 3-11 times each with zero successes. All failures are packaged
desktop (od://).
1. waitForPrintableContent had no timeout. It awaits document.fonts.ready,
every <img> load/error, and a new Image() per CSS url() background —
under od:// any of those can settle neither way, leaving the promise
pending until the daemon's 600s desktop-IPC ceiling. 122 of 142
DESKTOP_RENDERER_UNAVAILABLE failures sat at ~10 minutes before the
user saw an error. Its sibling waitForPrintReadyHandshake already
guards itself with a 30s race for this exact reason; this one was
missed. Bound it in-page per resource (10s) with an outer backstop
(15s), resolving rather than rejecting: capturing without a late
resource beats failing ten minutes later, and the script already
treats a failed resource as fine.
2. error_code reported the literal "Error" for 148 failures / 82 users —
48% of them, and undiagnosable. The daemon classifies its own errors,
but the web export path kept only .error.message and dropped .code, so
exportErrorCode fell through message-regex matching to err.name. Carry
code + status through, prefer a meaningful err.name (TypeError etc),
fall back to the daemon envelope code, and report UNCLASSIFIED instead
of "Error".
3. CAPTURE_FAILED (90 events / 37 users) collapsed five distinct give-up
points into one code. Tag each stage so the bucket says which step of
the fallback chain gave up.
* fix(desktop): bound the artifact document load and release the renderer
End-to-end verification against a real Electron main process found the
first fix incomplete: the export never even reached
waitForPrintableContent. Probing the actual hang showed
[OD-PROBE] loadURL start <- and nothing after it
renderDeckSlides awaited window.loadURL() directly, and Electron
resolves that on did-finish-load, which Chromium fires only once EVERY
subresource has settled. One image or font URL that answers neither way
leaves it pending forever, so the export hung one step earlier than the
function I had bounded.
Wait on dom-ready instead (the capture pipeline only needs a parsed,
scriptable document; subresources are waitForPrintableContent's job and
it bounds itself), with a 15s backstop and the loadURL rejection kept
handled.
Bounding the waits still was not enough. The requests we stop waiting
for stay in flight and every later executeJavaScript queues behind a
renderer still busy with them, so the steps after the wait still cost
~38s and the total stayed unstable. Cancelling the outstanding loads on
timeout hands the renderer back.
Measured on an artifact whose <img> and CSS url() both point at a socket
that accepts and never answers, against a live daemon+desktop runtime:
before hung past the daemon's 600s IPC ceiling
bound loadURL 169s -> 53s, but unstable (a later run hung again)
+ stop() 25.7s / 25.8s / 25.7s, identical output every run
stage breakdown without stop() with stop()
loadMs 89 70-160
assetsMs 15001 15001
prepareMs 15340 6
renderMs 22459 10566
totalMs 52889 25700
Both Electron mocks that stub webContents grew the once() the real
EventEmitter always had; without it the new dom-ready wait threw and
base-href-precedence went red.
* fix(desktop): address review — propagate load failures, cancel on either bound
Two blocking findings from review, both real and both verified by
reverting each fix and watching the matching [P0] go red.
1. A did-fail-load was being swallowed. `.catch(() => undefined)` turned
a genuine main-document failure into an immediately-resolved race
winner, so the pipeline went on to capture Chromium's error page and
could report a successful-but-wrong export. That is worse than the
hang this function exists to prevent: the previous bare
`await window.loadURL(...)` rejected and renderDeckSlides reported
RENDER_FAILED. Race tagged outcomes instead — a failure that beats
dom-ready rethrows, while one arriving after dom-ready has already
won stays handled (onRejected is attached up front) rather than
escaping as an unhandled rejection.
2. Cancellation keyed off the outer timer only. The in-page per-resource
deadline is 10s and the outer bound is 15s, so whenever the renderer
is healthy the script returns on time, `timedOut` stays false, and
the abandoned requests were left in flight — exactly the state that
makes every later executeJavaScript crawl. My own measurement hid
this: assetsMs 15001 means the outer timer won there. The injected
script now counts the deadlines it fired and reports
`{ stalled }`, and either layer giving up triggers stop().
Re-verified end-to-end against a live daemon + desktop runtime on the
same artifact (an <img> and a CSS url() both pointing at a socket that
accepts and never answers): 25.9s / 25.7s / 25.7s, http=200,
byte-identical PNG, stage split loadMs 69 / assetsMs 15002 /
prepareMs 8 / renderMs 10549.
148 lines
5.8 KiB
TypeScript
148 lines
5.8 KiB
TypeScript
// Regression boundary for the packaged export hang, at the step that actually
|
|
// hung.
|
|
//
|
|
// `renderDeckSlides` used to `await window.loadURL(...)` directly. Electron
|
|
// resolves that promise on `did-finish-load`, which Chromium only fires once
|
|
// EVERY subresource has settled — so one image or font URL that answers
|
|
// neither `load` nor `error` (the `od://` failure mode) left it pending
|
|
// forever. The export never even reached `waitForPrintableContent`, whose own
|
|
// (separate) missing timeout was the more obvious suspect.
|
|
//
|
|
// Verified against a real Electron main process before and after the fix: an
|
|
// artifact whose <img> and CSS url() both point at a socket that accepts and
|
|
// never answers went from hanging past the daemon's 600s IPC ceiling to
|
|
// returning a real PNG, with the document-load step dropping to 89ms. That
|
|
// matches production, where 122 of 142 `DESKTOP_RENDERER_UNAVAILABLE` export
|
|
// failures sat at ~10 minutes.
|
|
//
|
|
// `dom-ready` is the correct signal here: the capture pipeline only needs a
|
|
// parsed, scriptable document — waiting on subresources belongs to
|
|
// `waitForPrintableContent`, which bounds itself.
|
|
|
|
import { describe, expect, test, vi } from 'vitest';
|
|
|
|
import {
|
|
ARTIFACT_DOCUMENT_LOAD_TIMEOUT_MS,
|
|
loadArtifactDocument,
|
|
} from '../../src/main/deck-capture.js';
|
|
|
|
type DomReadyListener = () => void;
|
|
|
|
function createWindowStub(options: {
|
|
/** Resolve `loadURL` (i.e. did-finish-load fired). Default: never resolves. */
|
|
finishLoad?: boolean;
|
|
/** Reject `loadURL` immediately (i.e. did-fail-load beats dom-ready). */
|
|
failLoad?: boolean;
|
|
/** Hold the rejection until the test calls `rejectLoad()`. */
|
|
failLoadAfterDomReady?: boolean;
|
|
}) {
|
|
const listeners: DomReadyListener[] = [];
|
|
let rejectLoad: (() => void) | undefined;
|
|
const window = {
|
|
webContents: {
|
|
once(event: string, listener: DomReadyListener) {
|
|
if (event === 'dom-ready') listeners.push(listener);
|
|
},
|
|
},
|
|
loadURL(_url: string) {
|
|
if (options.failLoad) return Promise.reject(new Error('did-fail-load'));
|
|
if (options.failLoadAfterDomReady) {
|
|
return new Promise<void>((_resolve, reject) => {
|
|
rejectLoad = () => reject(new Error('did-fail-load'));
|
|
});
|
|
}
|
|
if (options.finishLoad) return Promise.resolve();
|
|
return new Promise<void>(() => {});
|
|
},
|
|
};
|
|
return {
|
|
window: window as unknown as Parameters<typeof loadArtifactDocument>[0],
|
|
fireDomReady: () => listeners.forEach((listener) => listener()),
|
|
domReadyListenerCount: () => listeners.length,
|
|
rejectLoad: () => rejectLoad?.(),
|
|
};
|
|
}
|
|
|
|
describe('loadArtifactDocument', () => {
|
|
test('[P0] proceeds on dom-ready instead of waiting for every subresource', async () => {
|
|
const stub = createWindowStub({}); // loadURL never resolves — a stalled subresource
|
|
|
|
// The listener must already be attached: a `data:` URL can reach
|
|
// dom-ready before we get a chance to subscribe otherwise.
|
|
const pending = loadArtifactDocument(stub.window, 'data:text/html,<p>hi');
|
|
expect(stub.domReadyListenerCount()).toBe(1);
|
|
|
|
stub.fireDomReady();
|
|
|
|
await expect(pending).resolves.toBeUndefined();
|
|
});
|
|
|
|
test('[P0] gives up after the timeout when dom-ready never fires either', async () => {
|
|
vi.useFakeTimers();
|
|
try {
|
|
const stub = createWindowStub({}); // neither loadURL nor dom-ready ever settles
|
|
let settled = false;
|
|
const pending = loadArtifactDocument(stub.window, 'data:text/html,<p>hi').then(() => {
|
|
settled = true;
|
|
});
|
|
|
|
await vi.advanceTimersByTimeAsync(ARTIFACT_DOCUMENT_LOAD_TIMEOUT_MS + 1_000);
|
|
await pending;
|
|
|
|
expect(
|
|
settled,
|
|
'an unbounded document load is what turned a stalled resource into a 10-minute export hang',
|
|
).toBe(true);
|
|
} finally {
|
|
vi.useRealTimers();
|
|
}
|
|
});
|
|
|
|
test('resolves normally when the document finishes loading', async () => {
|
|
const stub = createWindowStub({ finishLoad: true });
|
|
await expect(
|
|
loadArtifactDocument(stub.window, 'data:text/html,<p>hi'),
|
|
).resolves.toBeUndefined();
|
|
});
|
|
|
|
// Review catch (PR #7182): the first version swallowed this with
|
|
// `.catch(() => undefined)`, which turned a genuine main-document failure
|
|
// into an immediately-resolved race winner. The pipeline would then capture
|
|
// Chromium's error page and report a successful-but-wrong export — worse
|
|
// than the hang this function exists to prevent. A failure that beats
|
|
// dom-ready must still propagate, exactly as the bare `await loadURL` did.
|
|
test('[P0] propagates a did-fail-load that arrives before dom-ready', async () => {
|
|
const stub = createWindowStub({ failLoad: true });
|
|
await expect(
|
|
loadArtifactDocument(stub.window, 'data:text/html,<p>hi'),
|
|
).rejects.toThrow('did-fail-load');
|
|
});
|
|
|
|
// ...but once dom-ready has won, the document is usable and a late
|
|
// rejection must not take the export down — nor escape unhandled.
|
|
test('[P0] ignores a late did-fail-load once dom-ready already won', async () => {
|
|
const stub = createWindowStub({ failLoadAfterDomReady: true });
|
|
|
|
const orphaned: unknown[] = [];
|
|
const onUnhandled = (reason: unknown) => orphaned.push(reason);
|
|
process.on('unhandledRejection', onUnhandled);
|
|
try {
|
|
const pending = loadArtifactDocument(stub.window, 'data:text/html,<p>hi');
|
|
stub.fireDomReady();
|
|
await expect(pending).resolves.toBeUndefined();
|
|
|
|
// The load fails only now, after the race is already over.
|
|
stub.rejectLoad();
|
|
// Let the microtask queue drain; an unattached handler would surface here.
|
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
|
|
expect(
|
|
orphaned,
|
|
'the late rejection must stay handled — attaching onRejected up front is the point',
|
|
).toEqual([]);
|
|
} finally {
|
|
process.off('unhandledRejection', onUnhandled);
|
|
}
|
|
});
|
|
});
|