fix(markdown): make the code-region order an invariant instead of a sort - #434
Merged
Merged
Conversation
`in_code_region` is a `binary_search_by`, so `code_region_ranges` must return its regions in document order. It did — by calling `sort_unstable()` on the last line, after a second pass had appended every inline code span behind the fenced regions. Deleting that one line left `cargo test` at 144 passed, while markers inside a fenced block (`![[embed]]`, `[[wikilink]]`, `==highlight==`, `^[footnote]`, `$x$`) were reported as prose and rewritten. The order is now produced by construction: the scan records each plain segment's inline spans at the moment it closes that segment, immediately before the fence that ended it, so every push is at a higher offset than the last. The sort is gone, the `plain_segments` vector is gone, and a `debug_assert!` names the invariant at its one construction site. Four tests cover the consequence — one per consumer of `code_region_ranges`. Also in this change: - `convert_markdown` captures its parameter as `raw_buffer` before any preprocessing runs, and hands that to `annotate_task_checkboxes`. The fail-safe only works while its second argument is the unpreprocessed buffer, and the natural way to add a step — `let content = ...` near the top — silently retargeted it. A source-level test pins the three properties the capture depends on; provenance is not a type, so a source check is what is available. - `annotate_task_checkboxes`'s doc comment claimed the frontend "writes a `- [x]` marker into whatever happens to sit on that line". That describes the pre-#352 frontend. Rewritten to the current behaviour and to the two cases that still corrupt. - `read_file_content` is deleted: no call site since #379, and its defining property is that it hides the lossy-decode verdict. Its frontend guard was a hard-coded three-file allowlist; it is now a whole-tree scan plus an assertion that the command stays deleted. - `update_pinned_tags`'s comment said `localStorage` makes an RMW cycle atomic by construction. It does not — that claim came from #424, this project's own recent work — and the passage now states the real asymmetry. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
PathGao
force-pushed
the
fix/guard-the-load-bearing-invariants
branch
from
August 3, 2026 09:08
bf3d9c3 to
e40c9f4
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Four unguarded things, three of them load-bearing and one of them a wrong reason for a right conclusion. Each section states the mechanism, then what was measured.
1.
regions.sort_unstable()was the whole guard, and nothing tested itin_code_regionis abinary_search_by.code_region_rangestherefore has to return its regions in ascending document order. It did — viasort_unstable()on the last line of a ~90-line function, where it reads as tidiness.The vector genuinely needed it. Fenced regions were pushed by the line walk in document order; inline code spans were appended afterwards, by a second pass over
plain_segments. So for any document containing both, the vector was two sorted runs concatenated, and the sort was the only thing making the binary search correct.Measured, on master with that one line deleted:
cargo test→ 144 passed, 0 failed. Nothing in the suite noticed. What actually happens with it gone, for a document with an inline span above a fence (prose containing`a code span`, then a```textfence holding![[embed.md]],[[wikilink]],==highlight==,^[footnote]):regionsis[(33, 98), (11, 24)]. A probe at offset 40 sits inside the fence, but the search compares against index 1 first, decides the target lies to the right, and returnsErr. Every marker in the fence is reported as prose and rewritten — the exact bug classcode_region_rangeswas written to end (#375 / #389).Removed rather than tested
A guarded invariant is worse than one that cannot be violated, and the two-pass build was the only reason the vector was ever unsorted. The scan now records a plain segment's inline spans at the moment that segment closes — immediately before the fence that ended it — so the two kinds of region interleave and every push is at a higher offset than the last.
plain_segmentsis gone, the sort is gone, and the extractedpush_inline_code_spansis the old inner loop verbatim.A
debug_assert!at the single construction site names the invariant and prints the offending vector. It is a diagnosis, not the guard: it cannot be satisfied by re-sorting somewhere else, because there is no longer a somewhere else.The tests assert the consequence, not the sortedness
"the vector is sorted" is satisfiable by adding a sort back, which is why it is not what is asserted. Four tests, one per consumer of
code_region_ranges, each on a document with an inline span at a lower offset than a fence:..._to_embedsprocess_internal_embeds![[embed.md]]in the fence is not turned into<img>..._to_wikilinksprocess_wikilinks(4 passes)[[wikilink]],==highlight==,^[footnote]all stay literal..._to_autolinksprocess_parenthesized_autolinks..._to_mathmask_math_spans$x_1$in the fence is not maskedAll three markers in the wikilink case are checked because
process_wikilinksruns a separate pass per kind, each probing at its own offset — one probe landing inside the region says nothing about the ones beside it.Mutation check. Reintroducing the two-pass build order on top of this change:
The same tests also go red against master with
sort_unstable()deleted (verified separately: 144 passed → 3 failed, before the math test existed).2. The task-checkbox fail-safe depended on a binding name
annotate_task_checkboxes(html, markdown)is a fail-safe only whilemarkdownis the raw, unpreprocessed buffer. Its doc comment says so at length. What enforced it was that the last statement ofconvert_markdownhappened to spell the argumentcontent, which happened to still be the function parameter.The obvious way to add a preprocessing step deletes that:
Measured on master:
cargo test→ 144 passed, 0 failed. The guard is gone and nothing anywhere says so; the two sides it exists to cross-check now agree by definition. With a second, line-shifting step added later, a document renders a checkbox annotated as toggleable whose raw line 5 is"```"— reproduced directly.What was chosen, and why not the alternatives
Not a newtype. "This string is the one the caller passed in" is provenance, not a type.
RawBuffer(content)compiles just as happily around a shadowedcontent, so the wrapper would move the hazard rather than remove it.Not the parameter name alone. Any hardening that leaves the raw buffer reachable only through the name
contentis defeated by shadowing that name — which is exactly the accident.A capture, plus a source-level test.
convert_markdownnow copies its input toraw_bufferas its first statement and hands that to the fail-safe. The shadowing edit above becomes a no-op:raw_bufferstill points at the parameter and the guard still works. Verified — under the line-shifting shadow, the checkbox stays inert where before it was emitted enabled onto a closing code fence.That leaves three residual holes, and
convert_markdown_hands_the_fail_safe_the_raw_bufferre-readslib.rsand closes each. Mutation check, all three:the raw buffer must be captured before the first preprocessing step, or the step can shadow content above itraw_bufferbound a second timeraw_buffer is bound more than once — a second binding is the same hole under a new namecontentthe fail-safe is no longer handed raw_buffer; whatever it now receives can agree with the HTML by constructionA source test is not elegant. It is what is left when the property is provenance, and this file already uses the technique for the neighbouring contract (
every_convert_markdown_preprocessing_step_is_registered).The comment overstated the damage
It said a mis-aimed toggle "writes a
- [x]marker into whatever happens to sit on that line". That was true of the pre-#352 frontend.documentSession.toggleTaskCheckboxnow rewrites only lines already matching/^(\s*(?:>\s*)*(?:[-+*]|\d+[.)])\s+)\[( |x|X)\]/, so a mis-targeted prose line is a no-op and the toggle reports failure.The comment is corrected rather than deleted, and the guard is still worth having — an overstated justification is dangerous precisely because the next reader checks it, finds it false, and concludes the whole thing is theatre. What still corrupts is a mis-targeted line that is itself task-shaped, and neither spelling is exotic:
3.
read_file_contentdeletedZero
invoke('read_file_content'call sites insrc/(the checked variant has 7). Also checked and clear: no Rust-internal caller, no test that exercises it, nothing insrc-tauri/capabilities/(custom commands are not named there), nothing intauri.conf.json. The only other mention isAGENTS.md, where it is a syntax example rather than a reference — left alone, that file is the maintainer's.The command's defining property is that it returns the text without the lossy-decode verdict — the flag that stops Markpad writing U+FFFD over a GBK or Shift-JIS file. It survived #379 for callers re-reading an already-flagged tab, then lost its last call site and stayed registered: a one-
invoke-away way to fill an editable buffer with unflagged mojibake.Public-surface note for the maintainer: removing a registered Tauri command is a breaking change for anything outside this repository that invokes it. Nothing inside it does.
read_file_content_checked's doc comment absorbs the "deliberately async" rationale that was attached to the deleted function, so it is not lost.The frontend guard was an allowlist; it is now a scan
checkedReadMigration.test.tsguarded this with a hard-coded three-file list — the files #379 migrated. Adding the call in a fourth file passed, demonstrated.Deleting the assertion outright was the other option, on the grounds that the command no longer exists so an
invokeof it now throws. Rejected: the name would be free to come back, and the Rust command is six lines to re-add. The assertion is instead widened toreadSourceFiles('src')— the whole-tree patternsingleImplementationConvention.test.tsalready uses, which a new file cannot slip under — and paired with two assertions that the Rust command stays deleted and unregistered. That pairing is what makes it a fact rather than a convention.Verified: dropping an
invoke('read_file_content', …)into a new file undersrc/lib/utils/fails the test, naming the file.4. A wrong reason for a right conclusion, and it is ours
update_pinned_tagsexplained why the frontend's #405 recent-files fix needed only a re-read while Rust needs a lock:This is false, and it was written by this project — #424, the change that added this lock. It is also in that PR's body, in bold. Correcting it rather than softening it, because the conclusion it supports is right and someone reasoning from the stated reason about a different shared key would conclude they need no synchronisation at all.
Each document is single-threaded. Two Markpad windows are two documents sharing one origin's storage area. The storage mutex the HTML standard describes for exactly this case is not implemented by any shipping engine — WebKit and WebView2 included — so
getItem…setItemin one window interleaves with the other's and loses the same update the diagram above draws.The real asymmetry
The frontend fix is adequate, but on three empirical grounds rather than by construction:
awaitin it:getItem, aJSON.parseof at most nine short strings,setItem.None of the three holds on the Rust side. The cycle is a file read, a parse, a serialize and an
atomic_write— milliseconds of I/O on a preemptively scheduled thread pool, not microseconds of straight-line JS. The collision is not a coincidence but the ordinary shape of quitting, since ⌘Q makes every window write from its own close handler at once. And a dropped pin is a thing the user made, with nothing to recreate it from. #424 measured this cycle losing 4–7 of 8 updates unlocked.So: a difference of orders of magnitude in three independent dimensions, not a difference between atomic and not-atomic.
recentFiles.tsgets a matching note so the same misreading cannot start from the other end.This is not a finding that #405 is wrong. The residual race is real and deliberately accepted; no fix is proposed here.
Not covered
localStoragerace inupdateStoredRecentFiles. Documented, not fixed. Closing it needs something like a lease key with a compare-and-set retry — a design change and its own issue, and the three grounds above are why it has not been worth one.convert_markdowncould be split so the raw-buffer binding lives in a scope containing no preprocessing at all, making the shadowing structurally impossible rather than merely harmless. That repointsevery_convert_markdown_preprocessing_step_is_registeredat a new function name, andlib.rshas other changes in flight in this region. Left for a quieter moment.lossyDecodeSaveGuard.test.ts's per-fileinvoke('read_file_content'assertions are now redundant with the tree scan. They are still correct and locally meaningful in their own tests; not touched.AGENTS.mdusesread_file_contentas its Tauri-command style example. Not edited — maintainer's file, and separately reported in AGENTS.md: three stale statements send contributors the wrong way #385.debug_assert!compiles out in release. It is a development diagnosis for the invariant; the four behavioural tests are what hold the line, in every build.Verification
cargo test149 passed (144 → +5) ·npm test562 passed ·npm run check0 errors 0 warnings ·npm run buildclean ·cargo clippydelta 0 — same two warnings as the pre-change baseline (push_strsingle-character literal, collapsibleif), plussetup.rs's unusedEXE_NAMEundercargo test·cargo fmtdiff count unchanged at 53 (rustfmt is not enforced in this repo).Mutation checks for items 1 and 2 are in their sections above; each violation was re-applied and the suite confirmed red with a message that names the cause.
🤖 Generated with Claude Code