Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,25 @@ notes. Entries describe what users experience, not internal refactors.

### Fixed

- First-visit editor preparation waits for the service worker to control the
page, so warmed font resources remain available in its offline cache.
- A failed recovery snapshot no longer removes an older document when storage
remains full after a retry.
- Recovery files that exceed the storage budget are rejected before their
contents are copied into another memory buffer.
- Unresponsive OpenAI-compatible, Gemini and Ollama requests now time out,
including stalled response reads, so another request can be sent.
- Gemini authentication, rate-limit and service failures now show actionable
guidance without exposing the service's response body.
- Plain assistant requests omit unused tool-calling settings for better
compatibility with configured AI services.
- Oversized AI responses stop before unbounded data accumulates in memory.
- OpenAI-compatible, Gemini, Ollama and local-service chat answers appear
progressively; interrupted streams are not treated as complete replies.
- AI offline preparation now downloads only the runtime capabilities actually
used. Cloud-only setup avoids local inference downloads, and prepared
capabilities remain available after app updates.

- A failed download started while switching documents no longer leaves the
editor empty. Closing the editor still cancels an unfinished replacement.
- A document still loading can recover from a temporary conversion failure
Expand Down
71 changes: 46 additions & 25 deletions bin/offline-assets.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -4,27 +4,40 @@ import { join } from 'node:path';
const ASSISTANT_ENTRY = 'lib/agent-plugin/agent-plugin.ts';
const GENERATED_ASSET = /^assets\/[A-Za-z0-9_.-]+\.(?:m?js|css|wasm|woff2)$/;

/** Follow app edges, optionally deferring lazy imports and attached runtime assets. */
function collectAssets(manifest, roots, excluded = new Set(), includeRuntime = true) {
const CAPABILITIES = ['webgpu', 'wllama', 'wllama-compat', 'cpu', 'cpu-compat', 'pdf'];

// Match source identities, never emitted hashes or arbitrary page-supplied URLs.
function capabilityFor(source) {
if (/\/wllama-count\/native\/compat\//.test(source)) return 'cpu-compat';
if (/\/wllama-count\//.test(source) || /\/wllama-cpu-runtime\.(?:js|ts)$/.test(source)) return 'cpu';
if (/\/node_modules\/@wllama\/wllama-compat\//.test('/' + source)) return 'wllama-compat';
if (/\/node_modules\/@wllama\/wllama\//.test('/' + source)) return 'wllama';
if (/\/node_modules\/@mlc-ai\/web-llm\//.test('/' + source)) return 'webgpu';
if (/\/node_modules\/pdfjs-dist\//.test('/' + source) || source.endsWith('/pdf-reference-reader.ts')) return 'pdf';
}

/** Follow edges within one capability; shared UI cycles never pull other engines in. */
function collectAssets(manifest, roots, assetOwners, allowed, owner) {
const visited = new Set();
const assets = new Set();
const add = (asset) => {
if (!GENERATED_ASSET.test(asset)) throw new Error(`Unsupported offline asset: ${asset}`);
const capability = assetOwners.get(asset) ?? (/(?:^|\/)webllm\.worker-/.test(asset) ? 'webgpu' : undefined);
if (!capability || capability === owner) assets.add(`/${asset}`);
};
const visit = (key) => {
if (visited.has(key) || excluded.has(key)) return;
if (visited.has(key)) return;
visited.add(key);
const chunk = manifest[key];
if (!chunk) throw new Error(`Missing offline manifest dependency: ${key}`);
// Worker outputs can appear in a static UI chunk's assets without being a
// static import. Keep its styles and fonts, but defer executable payloads.
const attachedAssets = (chunk.assets ?? []).filter((asset) => includeRuntime || !/\.(?:m?js|wasm)$/.test(asset));
for (const asset of [chunk.file, ...(chunk.css ?? []), ...attachedAssets]) {
if (!GENERATED_ASSET.test(asset)) throw new Error(`Unsupported offline asset: ${asset}`);
assets.add(`/${asset}`);
}
for (const imported of [...(chunk.imports ?? []), ...(includeRuntime ? (chunk.dynamicImports ?? []) : [])])
visit(imported);
const capability = capabilityFor(chunk.src ?? key);
if (capability && capability !== owner) return;
if (allowed && !allowed.has(key)) return;
for (const asset of [chunk.file, ...(chunk.css ?? []), ...(chunk.assets ?? [])]) add(asset);
for (const imported of [...(chunk.imports ?? []), ...(chunk.dynamicImports ?? [])]) visit(imported);
};
for (const root of roots) visit(root);
return assets;
return { assets, visited };
}

/** Core app dependencies install first; optional inference assets need explicit opt-in. */
Expand All @@ -37,15 +50,24 @@ export async function stampOfflineAssets(directory) {
(key) => key === ASSISTANT_ENTRY || manifest[key].src === ASSISTANT_ENTRY,
);
if (!entries.length || assistant.length !== 1) throw new Error('Missing offline app or assistant manifest entry');
const core = collectAssets(manifest, entries, new Set(assistant));
// Retain the small UI graph for old live pages whose lazy import URLs must
// survive a deploy. Inference engines, Workers and WASM remain optional.
for (const asset of collectAssets(manifest, assistant, new Set(), false)) core.add(asset);
const optional = collectAssets(manifest, assistant);
// Worker outputs may be emitted outside the main manifest's dependency graph.
// They belong to the optional inference runtime, never to the first visit.
for (const name of await readdir(join(directory, 'assets'))) {
if (/\.(?:m?js|css|wasm)$/.test(name)) optional.add(`/assets/${name}`);
const assetOwners = new Map();
for (const [key, chunk] of Object.entries(manifest)) {
const capability = capabilityFor(chunk.src ?? key);
if (capability) for (const asset of [chunk.file, ...(chunk.assets ?? [])]) assetOwners.set(asset, capability);
}
const { assets: core } = collectAssets(manifest, [...entries, ...assistant], assetOwners);
const capabilityAssets = {};
// Traversing a runtime root can point back into the UI chunk. Cut those
// shared edges at the already-collected core graph before following lazy imports.
const runtimeKeys = new Set(Object.keys(manifest).filter((key) => !core.has(`/${manifest[key].file}`)));
for (const capability of CAPABILITIES) {
const roots = Object.keys(manifest).filter((key) => capabilityFor(manifest[key].src ?? key) === capability);
const { assets } = collectAssets(manifest, roots, assetOwners, runtimeKeys, capability);
if (capability === 'webgpu') {
for (const chunk of Object.values(manifest))
for (const asset of chunk.assets ?? []) if (/(?:^|\/)webllm\.worker-/.test(asset)) assets.add(`/${asset}`);
}
capabilityAssets[capability] = [...assets].filter((asset) => !core.has(asset)).sort();
}
const fonts = (await readdir(join(directory, 'ran-fonts'))).filter((name) => name.endsWith('.woff2'));
for (const name of fonts) core.add(`/ran-fonts/${name}`);
Expand All @@ -55,11 +77,10 @@ export async function stampOfflineAssets(directory) {
if (/^(?:ran-tokens(?:\.[A-Za-z0-9_-]+)?|app-tokens)\.css$/.test(name)) core.add(`/${name}`);
if ((await readdir(join(directory, 'ran-fonts'))).includes('fonts.css')) core.add('/ran-fonts/fonts.css');
const assets = [...core].sort();
const assistantAssets = [...optional].filter((asset) => !core.has(asset)).sort();
const path = join(directory, 'sw.js');
const source = await readFile(path, 'utf8');
const marker = 'const APP_ASSETS_TO_CACHE = []; // BUILD_APP_ASSETS';
const assistantMarker = 'const ASSISTANT_ASSETS_TO_CACHE = []; // BUILD_ASSISTANT_ASSETS';
const assistantMarker = 'const ASSISTANT_CAPABILITY_ASSETS = {}; // BUILD_ASSISTANT_CAPABILITIES';
if (!source.includes(marker) || !source.includes(assistantMarker))
throw new Error('Missing offline asset build marker');
await writeFile(
Expand All @@ -68,7 +89,7 @@ export async function stampOfflineAssets(directory) {
.replace(marker, `const APP_ASSETS_TO_CACHE = ${JSON.stringify(assets)}; // BUILD_APP_ASSETS`)
.replace(
assistantMarker,
`const ASSISTANT_ASSETS_TO_CACHE = ${JSON.stringify(assistantAssets)}; // BUILD_ASSISTANT_ASSETS`,
`const ASSISTANT_CAPABILITY_ASSETS = ${JSON.stringify(capabilityAssets)}; // BUILD_ASSISTANT_CAPABILITIES`,
),
);
return assets;
Expand Down
48 changes: 48 additions & 0 deletions docs/explorations/2026-10-17-pr279-ci-followup.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
# PR 279: first-visit editor cache race

The Docker E2E run at commit `5ae9dfdf` failed its landing-page cache assertion
in shard 2. The other 109 cases in that shard passed. The retained trace from
GitHub Actions run `39027953673` showed that the sole missing entry was
`/sdkjs/common/libfont/engine/fonts.js`.

The font request succeeded, but occurred after `serviceWorker.ready` resolved
and before `clients.claim()` controlled the first-visit page. It populated only
the browser HTTP cache. The prefetcher's request deduplication then prevented a
controlled retry that would populate the worker's offline cache.

`public/landing-prefetch.js` now waits for actual page control before issuing
warm-up requests. It installs the control listener before rechecking the
controller, removes listeners when settled, and cancels the wait on departure
or worker upgrade. Resuming keeps the URL eligible for a new request. Existing
controlled pages continue immediately, and the upgrade gate still applies.

Two deterministic regressions first failed against the previous implementation:
one verifies that a resolved readiness promise cannot start an uncontrolled
fetch; the other checks cancellation, late control changes and retry after
returning. Both pass with the fix, along with all 15 landing-prefetch unit tests.
Independent read-only review also checked upgrade and BFCache interactions and
reported no actionable findings.

The browser assertion now reports the actual list of missing URLs rather than
only their count. Its existing 120-second deadline is unchanged.

## Verification

- Full unit coverage: 6,290 tests in 238 files pass; 89.56% lines and 83.08%
branches. All repository coverage gates pass.
- A fresh production Docker image passes all four landing-prefetch Chromium
cases, including all editor engines cached, background core caching, actual
font-catalog requests and an editor open served from the warmed worker cache.
- Archive integrity, repository-wide formatting, oxlint, refreshed workspace
declarations and application TypeScript checks pass.

```sh
pnpm run test:coverage --maxWorkers=2
pnpm run format:check
pnpm run lint:ts
docker build -t document:pr279-ci .
docker run -d --rm --name document-pr279-ci -p 127.0.0.1:4199:80 document:pr279-ci
E2E_BASE_URL=http://127.0.0.1:4199 E2E_PORT=4199 pnpm exec playwright test \
test/e2e/landing-prefetch.spec.ts --workers=1 --reporter=line
docker stop document-pr279-ci
```
Loading
Loading