Skip to content

[BREAKING] Report malformed JSON responses as load errors - #9205

Closed
mvaligursky wants to merge 4 commits into
mainfrom
mv-json-load-errors
Closed

[BREAKING] Report malformed JSON responses as load errors#9205
mvaligursky wants to merge 4 commits into
mainfrom
mv-json-load-errors

Conversation

@mvaligursky

@mvaligursky mvaligursky commented Aug 21, 2026

Copy link
Copy Markdown
Contributor

Fixes #1175.

A JSON asset whose body is malformed or empty loaded "successfully" with asset.resource set to null and no error fired. Asking XHR to parse the body itself makes a parse failure unreportable: it represents a failure and the valid document null identically — as a null response — and the raw text cannot be read back once the response type is JSON.

Not merged. See Risks below: the fix is sound and tested, but it changes the behaviour of the most heavily used loading path in the engine, and the risk was judged not worth taking for an edge case that has been benign since 2018.

Changes:

  • Request the raw text for JSON responses and parse it in the engine, so a malformed or empty body fails the load with a SyntaxError (carrying the character position) while the valid document null stays a successful load. Through the asset pipeline a broken body now fires error with Error loading JSON resource: <url> [SyntaxError: ...] instead of loading with a null resource. The JSON intent is checked before the content type, so a .json served with a binary content type (1.62 is unable to load JSON files on FB Instant #5264) still parses. overrideMimeType('text/plain; charset=utf-8') forces UTF-8 decoding, since reading text would otherwise honour a declared charset where the JSON response type always decodes as UTF-8.
  • Deliver one callback per network failure. A failed request reaches the engine twice, because the XHR spec's request error steps fire readystatechange before firing error, and the existing guard only covered the retry path — so with retries off (the http.get default) the callback ran twice with 'Network error'.
  • Report, rather than silently replace, an exception thrown while handling a successful response. It was being caught and re-delivered as a load error with the original stack discarded; it is now logged, and the request still fails so that a consumer which threw part way through its own chain is not left with a load that never completes.

Breaking changes:

  • The XHR returned by Http#get / Http#request for a JSON request reports responseType === 'text', and its response / responseText hold the unparsed JSON text rather than the parsed value. The callback — the documented way to receive the data — is unaffected. This is the observable cost of parsing in the engine: reading the raw text is the only way to tell a parse failure apart from the valid document null. Documented on the options.responseType parameter. Nothing in src/, scripts/ or utils/ captures the return value of http.get, so this affects external callers only.
  • A malformed, truncated or empty JSON body at HTTP 200 now fails the load. Previously it resolved to null. Every valid JSON document is unchanged, including null, false and 0.

Risks:

  • Blast radius. Every JSON asset load in the engine goes through this path — scenes, hierarchies, templates, materials, animations, sprite/font/texture atlases, sog and gsplat-octree metadata, and config.json. A regression here breaks loading rather than degrading it.
  • Peak memory on large JSON, unmeasured. Reading the text materialises the whole body as a JS string alongside the parsed object, where the JSON response type kept only bytes plus the object. For a multi-megabyte scene JSON that raises peak memory by roughly the size of the body (up to 2 bytes per character). This could not be quantified under jsdom, whose XHR is pure JS, and was not measured in a real browser.
  • Charset behaviour is untested. The overrideMimeType call is verified only by hand against a live server — nise's fake XHR does not implement charset-dependent decoding, so no unit test covers it. A future refactor of the response-type handling would not notice it regressing.
  • Content previously tolerated now fails. Anything served at a .json url that is not valid JSON used to resolve to null and may have been silently ignored by an app; it now fails the asset load.
  • test/assets/test-malformed.json is deliberately invalid JSON committed to the repo. CI is green today (no JSON lint or formatter gate), but adding one later would fail on it, and the .json extension is load-bearing for the test.
  • Residual cleanups not applied, since this is not being merged: options.retrying is now redundant with the per-request completion flag, and the JSON branch of the test helper's fake XHR is unreachable.

A JSON asset with a malformed or empty body loaded successfully with a
null resource. Also deliver one callback per network failure, and stop
catching exceptions thrown by the request callback.
@github-actions

github-actions Bot commented Aug 21, 2026

Copy link
Copy Markdown

Build size report

This PR changes the size of the minified bundles.

Bundle Minified Gzip Brotli
playcanvas.min.js 2371.9 KB (+0.3 KB, +0.01%) 609.5 KB (+0.1 KB, +0.02%) 473.2 KB (−0.1 KB, −0.02%)
playcanvas.min.mjs 2369.2 KB (+0.3 KB, +0.01%) 608.4 KB (+0.1 KB, +0.02%) 472.7 KB (−0.0 KB, −0.00%)

@mvaligursky mvaligursky left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Automated PR review by Codex (GPT-5).

I traced JSON decoding, duplicate XHR terminal events, retries, concurrency-slot release, and callback exception propagation. The focused Http and AssetRegistry suites pass; changed-file ESLint, diff validation, and all CI checks are green.

One backward-compatibility issue remains: the public Http API now rejects a valid JSON document. Because the new test explicitly codifies this behavior, this would be a permanent API regression rather than an uncovered edge case.

Comment thread src/platform/net/http.js Outdated
// delivered as a successful load with a null resource. The one false positive is a body
// of literal `null`, which is valid JSON but indistinguishable here: the raw text is
// not readable once the response type is JSON.
if (xhr.responseType === Http.ResponseType.JSON && response === null) {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1] Preserve valid JSON null

This condition makes every successful response containing the valid JSON document null fail through the public Http API. Existing callers can legitimately request nullable JSON, and the accompanying test explicitly locks in the breaking behavior. The ambiguity comes from asking XHR to parse the response: please retain/request the raw text and run JSON.parse in the engine (including the .json/octet-stream case), or otherwise detect malformed bodies without redefining valid JSON as an error.

Requesting the raw text and parsing it here distinguishes a parse
failure from the valid document `null`. The JSON intent is checked
before the content type, so a .json served as application/octet-stream
still parses.

@mvaligursky mvaligursky left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Automated re-review by Codex (GPT-5).

Re-reviewed the changes since commit 8c4682c. The previous finding is resolved: valid JSON null now succeeds while malformed and empty bodies produce parse errors, including the octet-stream case. The focused Http suite (25 tests) and AssetRegistry suite (24 tests) pass; changed-file ESLint, diff validation, and all CI checks are green.

A targeted compatibility probe found one new public-API regression in the raw-text implementation, noted inline.

Comment thread src/platform/net/http.js
xhr.open(method, url, options.async);
xhr.withCredentials = options.withCredentials !== undefined ? options.withCredentials : this.withCredentials;
xhr.responseType = options.responseType || this._guessResponseType(url);
xhr.responseType = xhr._jsonResponse ? Http.ResponseType.TEXT : responseType;

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1] Preserve the returned XHR's JSON contract

Http#get and Http#request publicly return this XHR, so substituting text is observable beyond the callback: a request made with responseType: Http.ResponseType.JSON now immediately reports xhr.responseType === 'text', and after completion xhr.response is the raw JSON string instead of the parsed value. Existing callers that attach their own load handler or inspect the returned request therefore break even though the engine callback still receives parsed data. A targeted probe expecting the previously requested type failed with expected 'text' to equal 'json'. Please keep raw-text parsing behind a loader-specific/internal path or otherwise preserve the documented returned-XHR behavior; if that cannot be preserved, this needs to be treated as an explicit breaking public API change rather than shipped as transparent error handling.

@mvaligursky mvaligursky changed the title Report malformed JSON responses as errors instead of a null resource [BREAKING] Report malformed JSON responses as load errors Aug 21, 2026
The returned request reports a text response type for JSON requests, so
state that on options.responseType and cover it with a test. Also fix
the retry test, which asserted a synchronous request count and so could
not distinguish one retry from two.
Reading the body as text honours the charset declared in Content-Type,
where the JSON response type always decodes as UTF-8, so a server
wrongly declaring a charset produced silent mojibake. Override the mime
type to force UTF-8.

Letting an exception from the callback escape left the request with no
completion at all, so a parser throwing after a successful fetch hung
the load instead of failing it. Deliver the failure, and log the
original error rather than silently replacing it.
@mvaligursky

Copy link
Copy Markdown
Contributor Author

Closing unmerged. The fix works and is covered by tests, but it changes the behaviour of every JSON load in the engine, and the risks listed in the description — chiefly the unmeasured peak-memory cost on large scene JSON and the untested charset handling — outweigh fixing an edge case that has been benign since 2018. Branch mv-json-load-errors is kept for reference if this is revisited.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Invalid json in case of failed load

1 participant