Document runtime exclusions and verify offline transfer savings

This commit is contained in:
Jonathan Sykes
2026-10-09 14:56:31 +08:00
parent dd09f66578
commit 8a4144eb00
11 changed files with 22411 additions and 3 deletions

View File

@@ -335,3 +335,19 @@ No product behavior changes are involved in these measurement corrections.
Keep browser/build/test processes idle during full timing measurements. WebKit's Keep browser/build/test processes idle during full timing measurements. WebKit's
zero long-task field means unavailable, not zero work. Autoplay-blocked/media-ready zero long-task field means unavailable, not zero work. Autoplay-blocked/media-ready
null results do not establish playback performance or iPhone audio continuity. null results do not establish playback performance or iPhone audio continuity.
## Runtime manifest hygiene
`server/asset-manifest.js` excludes unit tests (`*.test.js`, `*.test.mjs`),
Markdown, text licences/data (`*.txt`), C build sources, `*.entry.js` build
entries, `/admin.html`, and files below any `perf/` directory from the runtime
manifest and both build-tag modes. They remain directly servable with the usual
hash and cache headers. If a matching file is genuinely needed at runtime, add
its exact root-relative path to the optional `include` array in
`frontend/assets.json`; group declarations referencing excluded files fail at boot
unless explicitly included.
The offline completeness harness compares cache contents with the runtime
manifest dynamically; it must not hard-code the old 151-file inventory. See
`plans/manifest-hygiene-report.md` and `perf/results/hygiene-*.json` for the
before/after measurements and compatibility checks.

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,260 @@
{
"before": {
"count": 151,
"rawBytes": 2044337,
"manifestBytes": 15601
},
"after": {
"count": 102,
"rawBytes": 1807059,
"manifestBytes": 12551
},
"excluded": [
{
"path": "/admin.html",
"rawBytes": 102005,
"brBytes": 24050
},
{
"path": "/app-seams.test.js",
"rawBytes": 9786,
"brBytes": 2457
},
{
"path": "/asset-sync-core.test.js",
"rawBytes": 9430,
"brBytes": 2525
},
{
"path": "/asset-worker.test.js",
"rawBytes": 8219,
"brBytes": 1984
},
{
"path": "/async-guard.test.js",
"rawBytes": 2543,
"brBytes": 765
},
{
"path": "/audio-continuity.test.js",
"rawBytes": 2899,
"brBytes": 813
},
{
"path": "/bento-hub.test.js",
"rawBytes": 868,
"brBytes": 283
},
{
"path": "/car-mode.test.js",
"rawBytes": 2556,
"brBytes": 973
},
{
"path": "/direct-protocol.test.js",
"rawBytes": 1279,
"brBytes": 521
},
{
"path": "/direct-stream.test.js",
"rawBytes": 891,
"brBytes": 390
},
{
"path": "/download-errors.test.js",
"rawBytes": 1783,
"brBytes": 566
},
{
"path": "/downloads-page.test.js",
"rawBytes": 2453,
"brBytes": 699
},
{
"path": "/downloads.test.js",
"rawBytes": 1608,
"brBytes": 639
},
{
"path": "/eq-core.test.js",
"rawBytes": 1055,
"brBytes": 428
},
{
"path": "/export.test.js",
"rawBytes": 521,
"brBytes": 259
},
{
"path": "/f7-layout.test.js",
"rawBytes": 1400,
"brBytes": 380
},
{
"path": "/fonts/PlusJakartaSans-OFL.txt",
"rawBytes": 4402,
"brBytes": 1428
},
{
"path": "/fullscreen-orientation.test.js",
"rawBytes": 1278,
"brBytes": 404
},
{
"path": "/glass-controls.test.js",
"rawBytes": 1177,
"brBytes": 412
},
{
"path": "/glass-panel-layout.test.js",
"rawBytes": 782,
"brBytes": 259
},
{
"path": "/lazy.test.js",
"rawBytes": 7117,
"brBytes": 2030
},
{
"path": "/loudness.test.js",
"rawBytes": 1626,
"brBytes": 706
},
{
"path": "/lower-third.test.js",
"rawBytes": 845,
"brBytes": 338
},
{
"path": "/lyrics-core.test.js",
"rawBytes": 4996,
"brBytes": 1430
},
{
"path": "/lyrics-display.test.js",
"rawBytes": 494,
"brBytes": 244
},
{
"path": "/lyrics-window.test.js",
"rawBytes": 617,
"brBytes": 264
},
{
"path": "/midi.test.js",
"rawBytes": 987,
"brBytes": 348
},
{
"path": "/p2p-core.test.js",
"rawBytes": 2116,
"brBytes": 675
},
{
"path": "/party-dj.test.js",
"rawBytes": 550,
"brBytes": 234
},
{
"path": "/piano-core.test.js",
"rawBytes": 2102,
"brBytes": 731
},
{
"path": "/prewarm-next.test.js",
"rawBytes": 1268,
"brBytes": 434
},
{
"path": "/related-videos.test.js",
"rawBytes": 1514,
"brBytes": 556
},
{
"path": "/resume-core.test.js",
"rawBytes": 1531,
"brBytes": 484
},
{
"path": "/saved-page.test.js",
"rawBytes": 553,
"brBytes": 224
},
{
"path": "/server-backfill.test.js",
"rawBytes": 2118,
"brBytes": 683
},
{
"path": "/setlist-import.test.js",
"rawBytes": 1134,
"brBytes": 508
},
{
"path": "/settings-sections.test.js",
"rawBytes": 1209,
"brBytes": 405
},
{
"path": "/sha256.test.js",
"rawBytes": 3251,
"brBytes": 1153
},
{
"path": "/shell-consistency.test.js",
"rawBytes": 5265,
"brBytes": 1507
},
{
"path": "/stats-core.test.js",
"rawBytes": 3165,
"brBytes": 929
},
{
"path": "/sw-update.test.js",
"rawBytes": 17825,
"brBytes": 4099
},
{
"path": "/sw.test.js",
"rawBytes": 6219,
"brBytes": 1849
},
{
"path": "/vendor/framework7-swipe.entry.js",
"rawBytes": 1040,
"brBytes": 290
},
{
"path": "/video-edit.test.js",
"rawBytes": 3396,
"brBytes": 855
},
{
"path": "/visible-timer.test.js",
"rawBytes": 2827,
"brBytes": 687
},
{
"path": "/vocal-reducer.test.js",
"rawBytes": 648,
"brBytes": 235
},
{
"path": "/wasm/loudness.c",
"rawBytes": 2132,
"brBytes": 861
},
{
"path": "/wasm/sha256.c",
"rawBytes": 2428,
"brBytes": 1064
},
{
"path": "/worker-imports.test.js",
"rawBytes": 1370,
"brBytes": 636
}
],
"excludedBrBytes": 64694
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,41 @@
{
"commit": "b77938a",
"results": [
{
"browser": "chromium",
"oldTag": "1175f1a1d2c1",
"newTag": "604f8f8d8a68",
"banners": 1,
"updateReloads": 1,
"cachedFiles": 124,
"durationMs": 26801,
"wireBytes": 638444,
"offline": true,
"legacyURLs": 21,
"profile": "lossy",
"userInitiatedOnly": true,
"playbackGuard": true,
"evictionRepair": true,
"incrementalRefresh": true,
"offlineTransportErrors": 0
},
{
"browser": "webkit",
"oldTag": "1175f1a1d2c1",
"newTag": "604f8f8d8a68",
"banners": 1,
"updateReloads": 1,
"cachedFiles": 124,
"durationMs": 24322,
"wireBytes": 743954,
"offline": true,
"legacyURLs": 21,
"profile": "lossy",
"userInitiatedOnly": true,
"playbackGuard": true,
"evictionRepair": true,
"incrementalRefresh": true,
"offlineTransportErrors": 4
}
]
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,150 @@
# Runtime manifest hygiene
Based on main/offline-fix tip `0b8e398`; offline-fix was already merged when this
worktree was created. Implementation: `dd09f66`. No frontend runtime, loading,
service-worker, playback or layout code changed.
## Policy and delivery
`server/asset-manifest.js` now selects runtime inputs separately from delivery
metadata. It excludes `*.test.js`, `*.test.mjs`, `*.md`, `*.txt`, `*.c`,
`*.entry.js`, `/admin.html`, and all files beneath a `perf/` directory. The
optional `assets.json` `include` array accepts exact root-relative existing file
paths to override this policy. An explicitly declared group cannot silently lose
an excluded dependency: boot rejects it unless the path is included.
The 49 excluded files here are 44 unit tests, admin HTML, the Plus Jakarta Sans
font licence, two WASM C build sources, and the Framework7 source build entry.
WASM binaries, fonts, icons, workers and the Framework7 runtime bundle remain.
Excluded files remain directly servable, with the existing content hash, ETag,
compression and cache rules: plain requests revalidate (font-folder requests
keep their 30-day cache rule), matching `?v` is immutable, and stale `?v` is
no-store in per-file mode. Delivery hashes are computed separately; they never
enter `manifest.files` or build identity. `ASSET_HASHING=0` retains single-tag
stamping and its legacy header matrix, but its tag also uses only runtime
inputs. `ASSET_SYNC=0` still selects the legacy worker.
Fresh installs cache only runtime assets. On an incremental upgrade, excluded
entries from the previous manifest may survive for one build under existing
N-1 retention, then are pruned as N-2. No new excluded entries are downloaded.
This preserves retention for already-open pages instead of changing worker
pruning or contracts/pinning.
## Inventory
Measured from the untouched before snapshot and the fixed frontend using the
respective real server manifest modules. Counts include stamped index and SW.
| Metric | Before | After | Reduction |
| --- | ---: | ---: | ---: |
| Manifest files | 151 | 102 | 49 (32.5%) |
| Summed manifest file sizes | 2,044,337 B | 1,807,059 B | 237,278 B |
| JSON manifest body | 15,601 B | 12,551 B | 3,050 B |
Excluded source bodies total 237,278 B, or 64,694 B using per-file Brotli
compression. Actual install/update measurements below include protocol and
manifest/index effects; compression estimates are not substituted for those runs.
Full inventory: `perf/results/hygiene-assets.json`.
## Verification
Commands:
```sh
node --test frontend/*.test.js
cd server && bun install && bun run test
node perf/offline-complete.mjs --browser all --conditions --out perf/results/hygiene-offline.json
node perf/migration.mjs --browser all --profile lossy --out perf/results/hygiene-migration.json
node perf/offline-complete.mjs --browser all --legacy --out perf/results/hygiene-rollback-offline.json
```
The server test command used a worktree-local Python venv on PATH with yt-dlp
installed so both environment-sensitive worker tests ran successfully.
Frontend: 209 passed, zero failures. Server: 186 passed, zero failures.
Manifest/static/shell tests cover excluded-file identity invariance in both hash
modes, app.js identity changes, explicit inclusion, group validation, direct
bodies/hash/header behavior, SPA delivery and rollback flags.
Offline completeness: Chromium and WebKit each report manifest count 102,
missing `[]`, failures `[]`, across all four layouts and lazy features. Page
closure/interrupted completion, worker restart/re-registration, Save-Data, two
tabs and online-event resumption pass. Linux WebKit uses same-context worker
re-registration because persistent CacheStorage restart is broken there; its
offline proxy blocks transport because native setOffline breaks cached navigation.
Native OPFS-dependent video-edit behavior cannot be exercised in Linux WebKit.
Lossy legacy migration passes on both engines: one banner, one explicit-user
reload, offline launch, playback guard, legacy URLs, eviction repair and
incremental refresh. WebKit records four tolerated offline API transport errors,
with the offline assertions passing. Migration caches include retained N-1
entries, so their raw key count is deliberately greater than manifest count.
## Rollback limitation found during extra verification
An additional `--legacy` offline-completeness run disables both hashing and sync.
It fails on both the untouched base and fixed build: the legacy SHELL omits
`piano-engine.mjs`, so its direct offline request returns 503 across layouts.
The legacy shell also intentionally does not cache its own `sw.js`; the harness
therefore marks that inventory entry missing too, without a corresponding offline
UI failure. Before: 151 manifest entries / 51 missing; after: 102 / 2 missing.
The other 49 before-only misses are precisely the excluded non-runtime files.
Normal sync-mode completeness is 102/102 with no failures. Header/injection
rollback tests pass; this change neither fixes nor worsens the existing legacy
piano coverage gap. Results: `hygiene-before-rollback-offline.json` and
`hygiene-rollback-offline.json`.
## Reduced performance measurements
Three runs per scenario/engine, LTE. Before used an isolated untouched snapshot
of `0b8e398` inside this worktree; after used the fixed server. No external
worktree was modified. Cold wire totals include page requests and install/completion
fetches; these are not claimed to be worker-only bytes. Chromium uses CDP LTE
throttling; WebKit uses the LTE proxy.
```sh
node perf/.tmp/before/perf/baseline.mjs --runs 3 --browser all --profile lte --scenario all --out perf/results/hygiene-before.json
node perf/baseline.mjs --runs 3 --browser all --profile lte --scenario all --out perf/results/hygiene-after.json
```
All values below are actual medians; bytes are wire bytes.
| Engine | Scenario | Before B | After B | Before ms | After ms |
| --- | --- | ---: | ---: | ---: | ---: |
| chromium | Cold visit + install | 728,266 | 660,390 | 7101 | 7090 |
| chromium | Warm reload | 8,132 | 8,159 | 647 | 445 |
| chromium | Offline reload | 0 | 0 | 285 | 209 |
| chromium | Core app.js edit | 126,358 | 125,729 | 1008 | 753 |
| chromium | theme-glass.css edit | 44,027 | 43,434 | 1600 | 1372 |
| chromium | Presenter edit + idle fill | 42,065 | 41,436 | 1392 | 1376 |
| webkit | Cold visit + install | 1,189,813 | 1,111,241 | 2321 | 2310 |
| webkit | Warm reload | 0 | 0 | 92 | 89 |
| webkit | Offline reload | 0 | 0 | 100 | 88 |
| webkit | Core app.js edit | 148,709 | 147,955 | 2013 | 2013 |
| webkit | theme-glass.css edit | 57,732 | 56,976 | 1479 | 1466 |
| webkit | Presenter edit + idle fill | 55,382 | 54,625 | 1494 | 1557 |
Cold requests fall 165 → 116 (Chromium) and 209 → 160 (WebKit), exactly 49
fewer each. Cold wire savings are 67,876 B (9.3%) and 78,572 B (6.6%).
Ordinary runtime updates already shared unchanged files before this fix, so
their savings are mainly smaller manifest metadata: approximately 0.6–0.8 KB.
Test-only edits now leave the tag unchanged and trigger no build update.
Milliseconds for cold/warm/offline are boot-done; update milliseconds are completion
duration. These three-run timing differences are descriptive, not a performance
claim. Warm Chromium API traffic varies independently of the asset set.
Real output excerpts (after):
```text
run 3/3... done (660390 bytes, 116 reqs, boot=7090ms)
run 3/3... done (1111241 bytes, 160 reqs, boot=2310ms)
run 3/3... done (147955 bytes, 5 reqs, dur=2014ms)
run 3/3... done (56976 bytes, 9 reqs, dur=1483ms)
```
## Real iPhone checks
Confirm installed-PWA offline launch and feature/layout first use after update;
completion after suspension, storage pressure/OPFS sharing, and old excluded
cache entries disappearing after the subsequent build. Linux automation cannot
prove iOS worker lifetime, quota eviction or native OPFS behavior.

View File

@@ -55,7 +55,6 @@ export function createAssetManifest(publicDir = './public', { hashing = true, bu
else { else {
const content = readFileSync(path); const content = readFileSync(path);
bytes.set(url, content); bytes.set(url, content);
} }
} }
} }
@@ -91,7 +90,7 @@ export function createAssetManifest(publicDir = './public', { hashing = true, bu
const swSource = bytes.get('/sw.js')?.toString() ?? null; const swSource = bytes.get('/sw.js')?.toString() ?? null;
// Derived build metadata cannot be an input to its own hash. Canonicalize // Derived build metadata cannot be an input to its own hash. Canonicalize
// the index meta and SW injected tag, then publish hashes of the final bytes. // the index meta and SW injected tag, then publish hashes of the final bytes.
// All original bytes (including index/SW source) remain inputs via source hashes. // All runtime source bytes (including index/SW source) remain inputs via source hashes.
let canonicalIndex = source === null ? null : stampIndex(embedAssets(source, files, groups, '__BUILD_TAG__', hashing), files, { hashing }); let canonicalIndex = source === null ? null : stampIndex(embedAssets(source, files, groups, '__BUILD_TAG__', hashing), files, { hashing });
const canonical = { assetSync, files: { ...files }, groups, contracts }; const canonical = { assetSync, files: { ...files }, groups, contracts };
if (canonicalIndex !== null && hashing) canonical.files['/index.html'] = { ...files['/index.html'], h: assetHash(canonicalIndex), s: Buffer.byteLength(canonicalIndex), source: files['/index.html'].h }; if (canonicalIndex !== null && hashing) canonical.files['/index.html'] = { ...files['/index.html'], h: assetHash(canonicalIndex), s: Buffer.byteLength(canonicalIndex), source: files['/index.html'].h };

View File

@@ -94,7 +94,7 @@ test('non-runtime files stay deliverable but never affect manifest or build iden
for (const hashing of [true, false]) { for (const hashing of [true, false]) {
const first = createAssetManifest(dir, { hashing }); const first = createAssetManifest(dir, { hashing });
mkdirSync(join(dir, 'perf'), { recursive: true }); mkdirSync(join(dir, 'perf'), { recursive: true });
for (const name of ['app.test.js', 'app.test.mjs', 'README.md', 'license.txt', 'admin.html', 'perf/fixture.js']) { for (const name of ['app.test.js', 'app.test.mjs', 'README.md', 'license.txt', 'admin.html', 'perf/fixture.js', 'sha256.c', 'framework.entry.js']) {
writeFileSync(join(dir, name), 'non-runtime'); writeFileSync(join(dir, name), 'non-runtime');
const next = createAssetManifest(dir, { hashing }); const next = createAssetManifest(dir, { hashing });
expect(next.manifest.buildTag).toBe(first.manifest.buildTag); expect(next.manifest.buildTag).toBe(first.manifest.buildTag);