Skip to content

Say so when a schema will not load, and never wait for one on the UI thread - #39

Merged
MarcelInTO merged 1 commit into
mainfrom
schema-unavailable
Aug 29, 2026
Merged

MarcelInTO merged 1 commit into
mainfrom
schema-unavailable

Conversation

@MarcelInTO

@MarcelInTO MarcelInTO commented Aug 29, 2026

Copy link
Copy Markdown
Owner

Closes #35, closes #36, closes #37 — one condition seen from three places.

What was wrong

sandbox/testdata/emp-invalid.xml with its hint retargeted at 192.0.2.1 (TEST-NET-1,
unroutable by RFC 5737, so packets are dropped rather than refused):

$ time ./bin/fux --validate emp-blackhole.xml
 Validation: 0 errors, 1 warning   (Enter: go to node)
  [W] emp-blackhole.xml:1,91  Error loading schema 'http://192.0.2.1/fux/emp.xsd'
One or more errors occurred. (A task was canceled.)
2:00.22 total          exit=0

Two minutes, not the 60 seconds #35 estimated: a validation pass resolves each hint
twice — once by URI in LoadXsiSchemas, again by namespace in LoadSchemasForNamespace
and nothing cached the failure. In the editor that pass runs after every command. And
the answer, after two minutes, was 0 errors, exit 0: indistinguishable from a pass.

Same fixture now:

$ time ./bin/fux --validate emp-blackhole.xml
 Not validated: 1 schema unavailable   (Enter: go to node)
  [W] emp-blackhole.xml:1,91  Error loading schema 'http://192.0.2.1/fux/emp.xsd'
Timed out after 5s
5.33 total             exit=3

#35 — the UI thread does not go to the network

Not "with a shorter timeout": at all. A fetch on the thread drawing the screen is a
freeze however short its budget.

  • XmlProxyResolver.OfflineThread, thread-scoped, set for the life of the process in
    BuildUi. A remote fetch from that thread raises SchemaOfflineException before a
    socket is opened — deliberately not a failure, since nothing has been learned.
  • New Fux.Schemas resolves the document's remote hints on a background thread and
    Compile()s them, so a schema whose own xs:include is remote is warmed too rather
    than being left for a thread that may not fetch it.
  • The two never overlap. Revalidate does nothing while Ui.SchemaPending is set, and
    that is the whole of the mutual exclusion keeping both threads off the shared
    SchemaCache — plain dictionaries, no locking.
  • One shared HttpClient with a 5-second per-request token, not a fresh client per fetch
    with a 60-second timeout that was then disposed while still holding the stream it had
    returned. --schema-timeout=N changes it.
  • SchemaResolver remembers a failure for the session. ClearFailures is what Retry is.
  • --validate stays synchronous and on-thread: it is a CI entry point and has to give
    the same answer every run.

#36 — a document nothing checked does not report a pass

Checker records what did not load as SchemaLoadFailure — location, resolved URI,
reason, position — rather than only as warning text, so "was this document checked
against anything?" is answerable. A warning among warnings is not.

before after
pane title / stdout Validation: 0 errors, 1 warning Not validated: 1 schema unavailable
some loaded, one did not Validation: 4 errors, 1 warning Validation: 4 errors, 1 warning — 1 schema unavailable
--validate exit 0 3 (errors still win with 1)

XmlSchema.Read returning null without throwing is now a failure too — that is a
captive portal's sign-in page or an HTML 404 body arriving where a schema was asked
for, and it used to come out as "no schema, no errors, all is well".

AggregateException's One or more errors occurred. is unwrapped, so the DNS or
status text is what the user reads.

#37 — the person who opened the file is told

┏┥Schema unavailable┝━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
┃  This document declares a schema that could not be loaded,           ┃
┃                 so nothing is validating it.                         ┃
┃                                                                      ┃
┃ https://raw.githubuserconten.../sandbox/testdata/NO-SUCH.xsd         ┃
┃  Response status code does no...ate success: 404 (Not Found).        ┃
┃                                                                      ┃
┃             ⟦ Retry ⟧  ⟦ Continue ⟧  ⟦► Quit ◄⟧                      ┃
┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
  • Quit last, and therefore the default. MessageBox binds Enter to the last
    button — the trap Confirm before deleting a node that takes others with it #21 walked into — and of the two reflexes that is the safe one: a
    reflexive Enter that quits costs a relaunch, one that dismisses leaves the user
    editing a document nothing is checking.
  • Only when a hint failed. A document with no xsi:schemaLocation never prompts.
  • Once per condition, not once per validation, and again when the condition changes.
    Raised from a timeout after the loop is up, never from the load path — Main loads the
    document before the app exists, so nothing raised there would have had a UI to appear in.
  • It re-arms rather than stacking if another dialog is already open.
  • The URL is elided in the middle; MessageBox does not wrap, and an untrimmed URL takes
    its own buttons off-screen.
  • The pane title is the persistent half — it is all that is left saying so once the
    dialog is gone, which is why it must not go back to counting errors.
  • File > Reload Schemas reaches the same retry afterwards. Without it the dialog's
    Retry is a one-shot and a session that started offline stays unvalidated after the VPN
    comes up.

Verification

  • Drill §14c, 28 checks, every fixture local — the point of the fix is that the UI
    thread needs no route out, so a check needing one would measure the runner. The remote
    hint is asserted structurally: it comes back recorded as pending, which can only
    happen if the fetch was declined before a socket opened.
  • Mutation-tested, 11 mutations, each killing the checks it should and no others.
    Two that survived the first pass were weak — !contains("0 errors") stayed green when
    a mutation restored a title reading 2 errors, 1 warning — and are rewritten to assert
    that an unchecked document reports no count at all.
  • Drill passes on all four CI fixtures (435/422/409/414 checks).
  • CI gains an exit-3 regression check, with a missing sibling .xsd so it needs no network.
  • Verified interactively under a PTY: the real 1 MB document behind
    https://schema.fread.now/work/4.0/fread-work.xsd opens and validates clean; the
    black-holed one stays responsive to keystrokes throughout the wait, then prompts.

Left open

  • Warn on open when a document's schema is unreachable, and keep saying so #37's open question — whether the acknowledgement should be a persisted setting for
    people who habitually work offline — is not decided here. It is currently per document
    per session.
  • Unrelated and pre-existing: .NET's XmlSchemaSet treats an unresolvable xs:include
    inside a schema as non-fatal and compiles the rest, so that goes unreported. Milder
    than these issues (the document is still validated, against a partial schema), and a
    different mechanism.

🤖 Generated with Claude Code

https://claude.ai/code/session_0146fhu1MJdAWimomPpjX6WY

…thread

Closes #35, #36, #37 — one condition seen from three places.

An `xsi:schemaLocation` hint that cannot be resolved used to be reported as a
warning, counted as nothing, and retried forever. Measured against a host that
swallows packets (192.0.2.1, unroutable by RFC 5737), `--validate` took 120
seconds — a validation pass resolves each hint twice, once by URI and again by
namespace, at 60 seconds a go — and exited 0 saying "0 errors". In the editor
that pass runs after every command.

Remote schemas are worth using: one published on the web and referenced by every
document beats a copy beside each file. Being briefly unable to reach one is
therefore routine, and has to be survivable.

#35 — the UI thread no longer goes to the network at all. `XmlProxyResolver`
gets a thread-scoped `OfflineThread`, set for the life of the process in
`BuildUi`; remote hints are resolved on a background thread by the new
`Fux.Schemas`, which compiles them so nested includes are warmed too. The two
never overlap — `Revalidate` does nothing while `Ui.SchemaPending` is set, and
that is the whole of the mutual exclusion keeping both off the shared
`SchemaCache`. A fetch is capped at 5 seconds (`--schema-timeout=N`) on one
shared `HttpClient` rather than 60 on a fresh one per request, and a failure is
remembered by `SchemaResolver` for the session instead of being retried after
every keystroke. `--validate` stays synchronous: it is a CI entry point.

#36 — `Checker` now records what did not load as `SchemaLoadFailure` rather than
only as warning text, so "nothing checked this document" is answerable. The pane
title and `--validate`'s first line say `Not validated: 1 schema unavailable`
instead of `0 errors`, and `--validate` exits 3 for it. Errors still win the exit
code when there are any. `XmlSchema.Read` returning null — a captive portal's
sign-in page, an HTML 404 body — is now a failure too, not a silent pass.

#37 — a dialog on open naming the schema and why it failed, offering Retry,
Continue and Quit, with Quit last where MessageBox's Enter lands (#21): a
reflexive Enter that quits costs a relaunch, one that dismisses leaves the user
editing unvalidated. It fires once per condition, not once per validation, and
re-arms rather than stacking on another dialog. The pane title is the persistent
half. File > Reload Schemas reaches the same retry after the dialog is gone.

Drill §14c, 28 checks, all local fixtures — the point of the fix is that the UI
thread needs no route out. Mutation-tested: eleven mutations, each killing the
checks it should and no others; two checks that survived the first pass were
weak and are rewritten. CI gains an exit-3 regression check.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0146fhu1MJdAWimomPpjX6WY
Comment thread src/Fux/Schemas.cs
internal static int HintCount(XmlCache model)
{
int n = 0;
foreach (SchemaHint unused in Checker.GetSchemaHints(model?.Document)) n++;
Comment thread src/Fux/Drill.cs
// (a) A hint that resolves to nothing. "fux-drill-missing.xsd" and not
// "emp.xsd": Main copies every sibling .xsd into this directory, so the
// obvious name would resolve and the fixture would prove nothing.
var orphan = System.IO.Path.Combine(schemaScratch, "fux_drill_orphan.xml");
Comment thread src/Fux/Drill.cs
// timed — a remote hint comes back recorded as *pending*, which can only
// happen if the fetch was declined before a socket was opened.
Check(XmlProxyResolver.OfflineThread, "the UI thread is barred from fetching a schema");
var blackhole = System.IO.Path.Combine(schemaScratch, "fux_drill_blackhole.xml");
Comment thread src/Fux/Schemas.cs
Comment on lines +127 to +133
catch
{
// Every reason a schema did not load is already recorded — in the schema
// cache as an absence, in the resolver as a remembered failure. Nothing
// here is worth killing a background thread over, and the validation pass
// the callback triggers is what reports it.
}
Comment thread src/Fux/Schemas.cs
Comment on lines +138 to +142
catch
{
// The app can be torn down while a fetch is in flight — quit during the
// five seconds this is allowed to take. There is then no UI to update.
}
Comment thread src/Fux/Schemas.cs
// Recorded by the resolver; the next hint still deserves its chance.
}
}
try { set.Compile(); } catch { }
Comment thread src/Fux/Schemas.cs
Comment on lines +188 to +191
foreach (var f in failures)
{
if (!f.Pending) uris.Add(f.ResolvedUri + "|" + f.Message);
}
Comment thread src/Fux/Schemas.cs
var settled = new List<SchemaLoadFailure>();
if (failures != null)
{
foreach (var f in failures) if (!f.Pending) settled.Add(f);
Comment thread src/Model/Checker.cs
Comment on lines +431 to +442
foreach (var existing in this._schemaFailures)
{
if (existing.ResolvedUri == uri)
{
if (existing.Pending && !pending)
{
existing.Pending = false;
existing.Message = message;
}
return;
}
}
Comment thread src/Model/proxy.cs
Comment on lines +179 to +182
foreach (var inner in agg.InnerExceptions)
{
if (IsIn(inner)) return true;
}
@MarcelInTO
MarcelInTO merged commit 3665194 into main Aug 29, 2026
5 checks passed
@MarcelInTO
MarcelInTO deleted the schema-unavailable branch August 29, 2026 16:28
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

2 participants