fix(inspect): abortable, stepped local reads with a busy state

The wait for the node table now ends with the selection's signal. A
local read resolves and projects in bounded steps with a turn of the
event loop between them, and the inspector carries aria-busy while it
loads. A budget test covers maximal 2 MiB graphs and checks the stepped
read is byte-identical to one synchronous build. The D117 wrap rule now
targets only the summary button, and TESTING.md scopes the restart line
to PRs L1 and L2.
This commit is contained in:
Vito Sansevero
2026-10-22 19:17:53 -07:00
parent 29e2bfd9ef
commit b4aa25a3e7
9 changed files with 214 additions and 15 deletions
+4 -2
View File
@@ -205,7 +205,7 @@ export function mountFileInspector(container, {api, sender, parentScope, variant
scope.listen(container, 'keydown', onInteraction);
scope.listen(container, 'click', onInteraction);
scope.listen(container, 'wheel', onInteraction, {passive:true});
scope.cleanup(() => {contentScope?.dispose(); container.replaceChildren(); container.classList.remove('file-inspector');});
scope.cleanup(() => {contentScope?.dispose(); container.replaceChildren(); container.classList.remove('file-inspector'); container.removeAttribute('aria-busy');});
const full = (variant ?? (inline ? 'panel' : 'full')) === 'full';
function statusView(state, parent) {
@@ -224,6 +224,8 @@ export function mountFileInspector(container, {api, sender, parentScope, variant
if (scope.signal.aborted) return;
contentScope?.dispose(); contentScope = childScope(scope);
container.replaceChildren();
// A local read resolves on this thread in bounded steps; the busy state stays until its answer lands.
if (state.loading) container.setAttribute('aria-busy', 'true'); else container.removeAttribute('aria-busy');
render(state);
}
/**
@@ -290,7 +292,7 @@ export function mountFileInspector(container, {api, sender, parentScope, variant
* copy does: the shared toast, or the select-to-copy dialog where the clipboard is unavailable or refused.
*/
function summaryButton(record) {
const button = createButton('Copy settings summary'), text = settingsSummary(record);
const button = createButton('Copy settings summary', 'settings-summary'), text = settingsSummary(record);
contentScope.listen(button, 'click', async () => {
try {
if (!navigator.clipboard) throw new Error('Clipboard requires a secure context');
+38 -6
View File
@@ -18,6 +18,24 @@ const MAX_FILENAME_CHARS = 255;
const MEDIA_TYPES = new Set(['image', 'video']);
const object = value => value !== null && typeof value === 'object' && !Array.isArray(value);
const abortError = () => new DOMException('The local selection ended', 'AbortError');
/** `promise`, or an AbortError as soon as `signal` aborts (the work behind `promise` is shared and keeps going). */
function untilAborted(promise, signal) {
if (!signal) return promise;
if (signal.aborted) return Promise.reject(abortError());
return new Promise((resolve, reject) => {
const onAbort = () => reject(abortError());
signal.addEventListener('abort', onAbort, { once: true });
promise.then(value => { signal.removeEventListener('abort', onAbort); resolve(value); },
error => { signal.removeEventListener('abort', onAbort); reject(error); });
});
}
/** One turn of the event loop between bounded steps, so the busy state paints and input is handled. */
function nextTurn(signal) {
return untilAborted(new Promise(resolve => setTimeout(resolve, 0)), signal);
}
const pngReader = () => globalThis.PngMetadata || globalThis.window?.PngMetadata;
/** A display name with no path separators or control characters; never empty. */
@@ -87,16 +105,21 @@ export function toInspectionRead(classification, result) {
const dimension = value => Number.isSafeInteger(value) && value > 0 ? value : null;
/** The roles for one local read (`{prompts, resolution}`): the bounded parse and resolution step of buildLocalPayload. */
export function localEvidence(read, classification, semantics = null) {
const inspection = toInspectionRead(classificationOf(classification), read);
return inspection.read === null ? unreadableEvidence() : resolveLocalEvidence(inspection.read.records, semantics);
}
/**
* The `inspection_version: 1` wire for one local read, before the response budget. Its roles are the server
* inspector's for the same records (local-resolver.mjs with `semantics`, the compiled node table; null answers
* "unavailable" as the server does), so the legacy fill in projectInspectionPayload applies exactly where it does
* for Images.
*/
export function buildLocalPayload({ read, file, classification, semantics = null }) {
export function buildLocalPayload({ read, file, classification, semantics = null, evidence = localEvidence(read, classification, semantics) }) {
const classified = classificationOf(classification);
const inspection = toInspectionRead(classified, read);
const evidence = inspection.read === null ? unreadableEvidence() : resolveLocalEvidence(inspection.read.records, semantics);
const filename = sanitizeLocalFilename(file?.name);
const size = Number.isSafeInteger(file?.size) && file.size >= 0 ? file.size : null;
const modified = typeof file?.lastModified === 'number' && Number.isFinite(file.lastModified) && file.lastModified >= 0 ? file.lastModified / 1000 : null;
@@ -171,7 +194,12 @@ export const localFileAdapter = Object.freeze({
scope(handle) { owned(handle); return LOCAL_SCOPE; },
// An inert sentinel: the controller's same-key check works and no URL is ever built.
inspectionPath(handle) { return `local-file:${owned(handle).key}`; },
async read(handle, { signal } = {}) {
/**
* Reads, resolves and projects one selection on the page's thread in bounded steps (each bound by the 2 MiB role
* and 4 MiB workflow budgets and the resolver's work limits) with a turn of the event loop between them, so the
* inspector's busy state paints. Every wait ends with the selection's `signal`; `yieldControl` is for tests.
*/
async read(handle, { signal, yieldControl = nextTurn } = {}) {
const secret = owned(handle);
let result = null;
try {
@@ -181,10 +209,14 @@ export const localFileAdapter = Object.freeze({
// MetadataReadError, an unreadable record. Abort and programming errors still reject.
if (!(error instanceof DOMException) || error.name === 'AbortError' || signal?.aborted) throw error;
}
const semantics = await loadInspectionSemantics();
signal?.throwIfAborted();
const semantics = await untilAborted(loadInspectionSemantics(), signal);
await yieldControl(signal);
const evidence = localEvidence(result, secret.classification, semantics);
await yieldControl(signal);
const file = { name: secret.filename, size: secret.file.size, lastModified: secret.file.lastModified };
return fitLocalPayload(buildLocalPayload({ read: result, file, classification: secret.classification, semantics }), MAX_RESPONSE_BYTES, { signal });
const payload = buildLocalPayload({ read: result, file, classification: secret.classification, semantics, evidence });
await yieldControl(signal);
return fitLocalPayload(payload, MAX_RESPONSE_BYTES, { signal });
},
toRecord(result, handle, extractFacts) {
const secret = owned(handle);
+1 -1
View File
@@ -213,7 +213,7 @@ main:focus-visible { outline: 2px solid var(--ca-focus); outline-offset: 4px; }
where the toolkit's `nowrap` would push them past its edge. */
.ca-tools #viewer-details > button { white-space: normal; /* D20 */ }
/* LI-4: "Copy settings summary" after the facts wraps the same way in the narrow rail. */
.ca-tools #viewer-details > div > button { white-space: normal; /* D117 */ }
.ca-tools #viewer-details .settings-summary { white-space: normal; /* D117 */ }
/* The Images page narrows to one column inside a narrow container (the Library-embedded gallery). */
.images-main { container: images-page / inline-size; }
@container images-page (inline-size < 600px) {