# PERF3: restore compilation reuse on PodNotes reloads The fix restores V8's eval compilation cache. On the final base including #439, ten paired, fresh-profile runs of the normal isolated runner measured PodNotes' Startup time report entry at a median of **13.10 ms before and 3.95 ms after**. An earlier ten-pair comparison on the original base measured 12.65 ms and 4.20 ms. This report is taken after the runner enables community plugins and reloads the renderer. It measures a repeated plugin load in the same renderer process. **There is no demonstrated true first-process startup speedup.** Five interleaved pre-trusted, fresh-process runs per build measured medians of 14.1 ms for edb9447, 14.2 ms for master, and 14.8 ms for the fix. After #439, a noisier five-pair first-process repeat measured 17.8 ms before (13.2-24.9) and 20.4 ms after (15.4-20.8), also with no demonstrated gain. The earlier PERF2 and V-439 numbers described as cold loads used the setup/reload path. This investigation corrects that premise. ## Change and cause Original investigation base: `921be0d10ac8bb6285d924fc041a9f3abc94652b`. Historical baseline: `edb9447`. Before opening the PR, `origin/master` advanced to `e4844987f9ca5816b365def0d98378100d0f844f` with #439. That base was merged, and the production bundle, measurements, and checks were rerun. The source fix did not change. PR #428 introduced the bundle's only tagged template, `String.raw`, inside the private-feed address scrubber. V8 disables eval compilation caching for an entire script containing a tagged template, even when that function does not execute. Obsidian loads the wrapped plugin through `window.eval`. The fix replaces the tagged template with an ordinary escaped string. Reading and evaluating the expressions from the actual old and new source gives identical strings of 76 code units and identical `RegExp.source`. No privacy, storage, lifecycle, or classifier behavior changes. The fallback pattern remains lazy, and no scrub or secret cleanup moves past `onload`. Evidence: - [V8 ParseTemplateLiteral](https://github.com/v8/v8/blob/3134120c073f635e677d697ac9159876d5b26921/src/parsing/parser-base.h#L5525) calls `set_allow_eval_cache(false)` for tagged templates. A source copy is in `out/v8-parser-base.h`. - `out/master-trace.json` shows PodNotes under script IDs 162 and 173 in the same renderer and isolate, across the setup reload. The renderer performs two large `V8.ParseProgram` calls, 6.319 ms and 8.954 ms. - `out/edb9447-trace.json` and `out/untagged-trace.json` reuse script ID 162 across the reload. Each has only one renderer `V8.ParseProgram` call above 2 ms, 5.790 ms and 8.745 ms respectively. `out/trace-summary.json` records the event timestamps. Parse events lack script URLs, so use the surrounding compile events and plugin script IDs when inspecting the traces. - The single-expression ablation removes this difference without changing bundle byte count or regex text. The original production fix and the ablation are byte-identical. After #439, the independently built merged-fixed variant is also byte-identical to the normal production build. - `out/equiv.log` and `tools/equiv.mjs` contain the source-level equivalence proof. These trace durations are diagnostic only. All reported load-time comparisons below use uninstrumented production bundles. V8 can evict its in-memory cache, including through GC aging. The measured gain is for reloads seconds apart, not a promise for every reload hours into a session. ## Environment and method Linux x86_64, kernel 7.0.0-31-generic, Intel i5-12500 with 6 cores and 12 logical CPUs. Obsidian 1.14.4, Electron 43.7.7, Chromium 150.0.7871.250. Node 22.23.2, npm and the locked dependencies. Historical and current lockfiles matched. The host was shared; original-run load average varied from about 2 to 6.5, and the final ten-pair run after #439 ranged from 8.86 to 14.94 on 12 logical CPUs. Other agents were running Node and Obsidian processes. The paired runs retain that noise, without removing outliers. Measurements were not run alongside this unit's full test suite. The vault was isolated to this worktree, with a v2 default library, a fixed library ID, no feeds, no other community plugins, and core plugins disabled. Each sample stopped the instance, removed its profile through the runner, reset the seeded library and workspace, installed the named bundle, and launched a fresh process. OS page caches were not purged. Builds ran in fixed interleaved order within each round, so order bias is a limitation. `tools/loadtimes.py` drives Settings > General > Check now > Copy report. It reads PodNotes' own Startup time report entry, not CLI eval elapsed time. Number formatting is temporarily adjusted only while rendering the already-recorded report. Each sample asserts that PodNotes' API loaded and that the installed bundle character count matches; records the installed bundle SHA-256 and load average; and checks `dev:errors` after attaching debugger capture. That capture cannot exclude an error emitted before attachment. The pre-merge ten-round run also preserves launch logs and asserts that data.json remains equal to the seed. All final samples loaded successfully, preserved the seed, and reported no captured runtime errors. The ordinary runner starts in Restricted Mode. Turning Restricted Mode off first enables the plugin, then reloads the renderer. `out/first-load/*-first.txt` captured the preceding restricted renderer and confirmed that it has no loaded PodNotes and no PodNotes timing row. Thus a fresh profile does not make the later report a first plugin evaluation. For a genuine first evaluation, `tools/start-trusted.mjs` seeds only this worktree's own Obsidian trust Local Storage into a fresh profile. It copies no Code Cache. Its trust callback verifies that the API loaded without toggling Restricted Mode or reloading. The trust database is not included in the public archive. Each build uses the same seed and settings. ## Load-time results All numbers below are milliseconds for the PodNotes report entry. Ranges are min to max. | Run | Build | Samples | Median | Range | | --- | --- | ---: | ---: | --- | | Initial reproduction, normal setup | edb9447 | 5 | 3.70 | 3.1-4.4 | | Initial reproduction, normal setup | master | 5 | 12.70 | 11.2-18.2 | | Single-expression ablation, normal setup | master | 3 | 16.10 | 11.5-16.1 | | Single-expression ablation, normal setup | untagged | 3 | 4.40 | 4.3-4.6 | | Pre-merge comparison, normal setup | master | 10 | 12.65 | 11.8-18.4 | | Pre-merge comparison, normal setup | fixed | 10 | 4.20 | 3.3-5.3 | | Final comparison after #439, normal setup | merged-master | 10 | 13.10 | 11.3-21.7 | | Final comparison after #439, normal setup | merged-fixed | 10 | 3.95 | 3.2-9.8 | | Trusted first process | edb9447 | 5 | 14.10 | 13.6-14.2 | | Trusted first process | master | 5 | 14.20 | 13.0-16.7 | | Trusted first process | fixed, labeled untagged | 5 | 14.80 | 14.0-17.5 | | Trusted first process after #439 | merged-master | 5 | 17.80 | 13.2-24.9 | | Trusted first process after #439 | merged-fixed | 5 | 20.40 | 15.4-20.8 | Pre-merge paired samples, in execution order: | Round | Master | Fixed | | ---: | ---: | ---: | | 1 | 12.0 | 3.3 | | 2 | 12.1 | 3.5 | | 3 | 12.5 | 5.3 | | 4 | 11.8 | 3.3 | | 5 | 18.4 | 5.1 | | 6 | 13.2 | 5.2 | | 7 | 12.8 | 5.2 | | 8 | 16.8 | 4.7 | | 9 | 14.2 | 3.7 | | 10 | 12.3 | 3.4 | Final paired samples after #439: | Round | Master | Fixed | | ---: | ---: | ---: | | 1 | 16.9 | 8.4 | | 2 | 21.7 | 9.8 | | 3 | 13.8 | 3.5 | | 4 | 16.8 | 3.4 | | 5 | 11.3 | 5.3 | | 6 | 13.0 | 3.8 | | 7 | 13.1 | 4.6 | | 8 | 12.8 | 3.5 | | 9 | 13.1 | 3.2 | | 10 | 12.7 | 4.1 | All ten paired differences favor the fix in each ten-round comparison. An exact two-sided paired sign-flip test on summed differences gives p=0.001953125. The original five trusted first-process pairs give p=0.1875; the final five give p=0.9375. Neither demonstrates an improvement. Small samples, fixed order, and the shared host limit those statistics. `tools/summarize.py` regenerates `out/comparisons.json` from the raw samples. The pre-merge run's whole-app startup medians were 170.35 ms and 163.55 ms. The separate three-round ablation had whole-app medians of 155.9 ms and 156.2 ms. The final comparison after #439 measured whole-app medians of 174.90 ms and 163.65 ms. These noisy results do not establish a whole-app startup improvement. The clear result is removal of repeated PodNotes compilation from the plugin-load span. ## Candidate costs `out/methods-final/method-summary.json` summarizes three diagnostic samples per side on the original investigation base in the empty library. Values are inclusive median milliseconds at roughly 0.1 ms clock resolution. Rows overlap and must not be added together. | Operation | Master | Fixed | Calls per load | | --- | ---: | ---: | ---: | | Module evaluation, after compile | 0.7 | 0.5 | 1 | | onload | 3.8 | 1.6 | 1 | | loadSettings | 1.6 | 0.7 | 1 | | loadData | 0.3 | 0.4 | 1 | | decode | 0.8 | 0.2 | 1 | | protectPrivateFeeds | 0.2 | below resolution | 1 | | redactPrivateFeedAddresses | 0.2 | below resolution | 2 | | orphan sweep | 0.2 | below resolution | 1 | | listSecrets | below resolution | below resolution | 1 | | encode | 0.2 | below resolution | 1 | | toDocument | 0.2 | 0.1 | 1 | There were no `getSecret` or `setSecret` calls. Neither URL classifier executed because this library has no feeds. Source inspection confirms that #440's publisher classifier has no import-time table construction and that the private-address matcher is built only after a private address is found. These are measurements of the brief's empty startup workload, not claims about scrub cost for a large library. The repeated parse, about 9 ms in the diagnostic master trace, explains the measured 8.45 ms median reduction in the report entry. The scrub and SecretStorage candidates do not. Fresh-process compilation remains inherent to loading the full bundle. No deferred cleanup, load-path restructuring, or privacy relaxation was warranted. ## Privacy and independent review Four behavior cases cover a quoted address with a percent-encoded host, a Unicode host, a numeric IPv4 host, and an expanded IPv6 host. They require the feed address and its quoted alias to become the same placeholder while preserving a signed media URL. All 73 tests in the file pass. Removing the fallback alternatives makes exactly these four cases fail and leaves the other 69 passing (`out/privacy-mutation.log`). The mutation is diagnostic and is not present in the source. One independent interrogate pass used GPT-6 Astra and Claude Fable 5.1. Both confirmed string equivalence, the V8 mechanism, and privacy behavior. Neither found a blocking code defect. Their measurement warning is incorporated throughout this report. The comment was narrowed to cache reuse, and the IPv6 row was added from review. The reviews ran before #439 merged; the reviewed source diff is unchanged except for the accepted comment wording and fourth test row. The ordinary string remains preferable here because a regex literal would require altering an escape to satisfy lint or adding a suppression. Production behavior and bundle bytes are unchanged by those review refinements. Follow-ups are separate from this change: - [#441](https://github.com/chhoumann/PodNotes/issues/441): the existing scrubber misses quoted private-feed aliases with a terminal dot on the hostname. Confirmed on both base and fixed code, using a fake sentinel. - [#443](https://github.com/chhoumann/PodNotes/issues/443): reject tagged templates in the production bundle so a future source or dependency change cannot silently disable eval caching. Coordinate with the bundle syntax checker. The research delegate initially ruled out compilation using fresh Node evals. That inference did not test cache reuse and was rejected after the real Obsidian traces and expression ablation. ## Reproduction The archive uses paths relative to `.obsidian-e2e-artifacts/PERF3/` in this checkout. It contains the production bundles, measurement tools, raw report samples, traces, and gate logs. Tools assume the repository dependencies have been installed with Node 22 and use this worktree's isolated instance. Some trace/profile paths are specific to this host and must be adapted elsewhere. ```sh TMPDIR=/data/home/tmp/podnotes-PERF3 fnm exec --using 22 -- python3 .obsidian-e2e-artifacts/PERF3/tools/loadtimes.py rerun 10 merged-master merged-fixed fnm exec --using 22 -- node .obsidian-e2e-artifacts/PERF3/tools/equiv.mjs python3 .obsidian-e2e-artifacts/PERF3/tools/summarize.py python3 .obsidian-e2e-artifacts/PERF3/tools/trace-summary.py ``` The measurement harness restores the master bundle on exit. Run `npm run build` with Node 22 before testing or using the fixed plugin again. For trusted first-process runs, start the isolated instance once, copy only its `Local Storage` under `userDataPath` directory to `trusted-local-storage`, stop the instance, then invoke the harness with `PERF3_TRUSTED=1`. Confirm the actual profile layout from the runner before copying. Never use another vault's profile. Production bundle SHA-256 digests: | Build | SHA-256 | | --- | --- | | edb9447 | a4c29b1fd2560c09c4cbcfb40ef1b43167702d841660ea47977b72441fd7ff36 | | master | b4f40aa8a3cbca6cb42af0cfaf51032f3e5a9eb24f55808aa78b1401f300e1b6 | | fixed / untagged | 2efc4f2cc54173956ab9d112a471572e2a3017a755b341330d7b2e5e2e46186d | | merged-master, e484498 | 1739c90eca796c514d6eb57f1b4165624fb04f86250bcfe60275ed8a9f617d71 | | merged-fixed | 55fa6ac259e141b6414d6073bc495abb08fc155ae91d70ee6ce9e0b994bca9c1 | ## Local checks All npm commands used Node 22.23.2. The final checks include the merged #439 build configuration. | Command | Result | | --- | --- | | `npm run lint` | Pass; 0 warnings and 0 errors | | `npm run format:check` | Pass | | `npm run typecheck` | Pass | | `npm run build` | Pass; output matches the measured merged-fixed SHA-256 | | `npm run test` | Pass; Svelte checks have 0 errors/warnings; 107 files and 1,984 tests pass | | `npm run test:tooling` | Pass; 2 files and 11 tests pass | | `npm run docs:build` | Pass; 18 pages, internal links valid, all 112 legacy fragments resolve | | `npm run size` | Pass; 292,929 raw bytes and 93,106 gzip bytes, below 330,000/100,000 budgets | The first full 1.14.4 run on the original base passed all 89 tests. Its outer command exited 143 while the detached Vitest process continued to completion, so it was not accepted as a clean command pass. The next run was explicitly interrupted to merge #439. Both logs are retained. The final matrix below supersedes them. - `npm run test:e2e` on Linux with Obsidian 1.14.4: 13 files and all 89 tests pass, command exits 0, instance stops. - `PODNOTES_OBSIDIAN_APP=/opt/Obsidian/obsidian npm run test:e2e` on Linux with Obsidian 1.13.7: 13 files and all 89 tests pass, command exits 0, instance stops. Both runs include all four private-feed tests and all ten sync-persistence tests. The Supercast-shaped fixture is `http://priv3.supercast.com:/feeds/PRIV3_E2E_SENTINEL/feed.xml`, resolved locally by the isolated runner. The suite creates a subscription, plays media, writes a note, checks data.json and cache keys for the fake private sentinel, removes and restores its SecretStorage address, and checks persistence across reloads. No real private feed or user vault is used. `out/fixed-startup.png` is an illustrative screenshot from a separate final 1.14.4 launch, not an additional benchmark sample. The final capture and cleanup commands are in `out/capture-startup.log` and `out/final-stop.log`.