Skip to content

fix: guard against null in SimpleUri(String) and StringRepresentationTypeHandler - #5360

Merged
Cervator merged 3 commits into
developfrom
fix/simpleuri-null-npe
Aug 10, 2026
Merged

fix: guard against null in SimpleUri(String) and StringRepresentationTypeHandler#5360
Cervator merged 3 commits into
developfrom
fix/simpleuri-null-npe

Conversation

@soloturn

@soloturn soloturn commented Aug 9, 2026

Copy link
Copy Markdown
Contributor

AI-assisted change proposal. Filed by agent driven by @soloturn via GDD.

Summary

Spawning a character (loading player entity data) can crash the whole game with:

java.lang.NullPointerException: Cannot invoke "String.split(String, int)" because "simpleUri" is null
	at org.terasology.engine.core.SimpleUri.<init>(SimpleUri.java:66)
	at org.terasology.engine.persistence.typeHandling.reflection.ModuleEnvironmentSandbox.doesSubclassMatch(ModuleEnvironmentSandbox.java:69)
	at org.terasology.engine.persistence.typeHandling.reflection.ModuleEnvironmentSandbox.lambda$findSubTypeOf$1(ModuleEnvironmentSandbox.java:46)
	...
	at org.terasology.engine.persistence.typeHandling.extensionTypes.ComponentClassTypeHandler.getFromString(ComponentClassTypeHandler.java:24)
	at org.terasology.persistence.typeHandling.StringRepresentationTypeHandler.deserialize(StringRepresentationTypeHandler.java:22)
	at org.terasology.persistence.typeHandling.coreTypes.CollectionTypeHandler.deserialize(CollectionTypeHandler.java:45)
	...
	at org.terasology.engine.persistence.internal.PlayerStoreInternal.restoreEntities(PlayerStoreInternal.java:48)
	at org.terasology.engine.logic.players.PlayerSystem.update(PlayerSystem.java:80)
	at org.terasology.engine.core.modes.loadProcesses.AwaitCharacterSpawn.step(AwaitCharacterSpawn.java:37)

Root cause: CollectionTypeHandler.deserialize is walking a persisted list of component-class references attached to the player entity. One entry no longer resolves to a real class (e.g. a component that existed in an older engine/module build was renamed or removed since the save was written). PersistedData.isString() can report true for that entry while getAsString() still yields null, and StringRepresentationTypeHandler.deserialize forwards that null straight into getFromString() without checking - which for ComponentClassTypeHandler ends up calling new SimpleUri(null), and SimpleUri's String constructor calls .split() on it unconditionally.

Confirmed reproducible on a genuinely fresh world (new seed, not just a stale save) - the corrupt reference lives in the player's global profile data, not the per-world save.

Changes

  • SimpleUri(String): treat a null input the same as a malformed one - mark the URI invalid instead of throwing. SimpleUri already has this "invalid but not exceptional" behavior for non-null malformed input (see its own javadoc: "If the string does not match this format, it will be marked invalid") - this just extends that same contract to null.
  • StringRepresentationTypeHandler.deserialize(): also guard at the actual entry point where the null appears, so any subclass of it (not just ComponentClassTypeHandler) gets Optional.empty() for a null-valued string entry instead of forwarding null into its own getFromString().

Neither change alters behavior for well-formed input; both are purely defensive against data that no longer round-trips cleanly.

Test plan

  • Compiled both changed files cleanly.
  • Patched the compiled classes into a running engine-5.4.0-SNAPSHOT.jar / TypeHandlerLibrary-5.4.0-SNAPSHOT.jar locally and confirmed the malformed component-class reference is now skipped (Optional.empty()) instead of throwing, letting character spawn proceed past the point that previously crashed the whole engine.
  • Not covered by an automated unit test yet - SimpleUri and StringRepresentationTypeHandler don't currently have a test file I found in this pass; happy to add a new SimpleUri(null).isValid() == false regression test if a reviewer wants one before merge.

Related

  • Found while diagnosing Windows/ARM64 launcher and engine startup issues: TerasologyLauncher#727, Terasology#5359. This bug itself is architecture-independent (pure reflection/deserialization code, no native code involved) and would reproduce identically on any platform.

…TypeHandler

Loading a saved player entity can crash the whole game with:

    java.lang.NullPointerException: Cannot invoke "String.split(String, int)" because "simpleUri" is null
        at org.terasology.engine.core.SimpleUri.<init>(SimpleUri.java:66)
        at org.terasology.engine.persistence.typeHandling.reflection.ModuleEnvironmentSandbox.doesSubclassMatch(...)
        ...
        at org.terasology.engine.persistence.typeHandling.extensionTypes.ComponentClassTypeHandler.getFromString(...)
        at org.terasology.persistence.typeHandling.StringRepresentationTypeHandler.deserialize(...)
        at org.terasology.persistence.typeHandling.coreTypes.CollectionTypeHandler.deserialize(...)
        ...
        at org.terasology.engine.persistence.internal.PlayerStoreInternal.restoreEntities(...)

This happens when deserializing a persisted list of component-class
references and one entry no longer resolves to a real class (e.g. a
component that existed in an older engine/module build was renamed or
removed) - PersistedData.isString() can report true while
getAsString() still yields null for that entry, and that null was
passed straight through getFromString() into "new SimpleUri(null)",
which called .split() on it unconditionally.

Two layered fixes:
- SimpleUri(String): treat a null input the same as a malformed one -
  mark the URI invalid instead of throwing. SimpleUri already has this
  "invalid but not exceptional" behavior for non-null malformed input,
  this just extends it to null, consistent with the class's own
  documented contract ("If the string does not match this format, it
  will be marked invalid").
- StringRepresentationTypeHandler.deserialize(): also guard at the
  actual entry point where the null appears, so any subclass (not just
  ComponentClassTypeHandler) gets Optional.empty() instead of a null
  forwarded into its own getFromString().

Verified against a real save that reproduced this exact crash while
testing Windows/ARM64 support (see TerasologyLauncher#727,
Terasology#5359): patched the compiled classes into a running
engine-5.4.0-SNAPSHOT.jar / TypeHandlerLibrary-5.4.0-SNAPSHOT.jar
locally; the malformed reference is now skipped instead of crashing
character spawn.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@github-actions github-actions Bot added the Type: Bug Issues reporting and PRs fixing problems label Aug 9, 2026
@coderabbitai

coderabbitai Bot commented Aug 9, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 67b9da7d-7d9d-43b2-8682-fdeb579dc645

📥 Commits

Reviewing files that changed from the base of the PR and between cfbc30e and cf44ccf.

📒 Files selected for processing (1)
  • subsystems/TypeHandlerLibrary/src/main/java/org/terasology/persistence/typeHandling/StringRepresentationTypeHandler.java
🚧 Files skipped from review as they are similar to previous changes (1)
  • subsystems/TypeHandlerLibrary/src/main/java/org/terasology/persistence/typeHandling/StringRepresentationTypeHandler.java

📝 Walkthrough

Summary by CodeRabbit

  • Bug Fixes

    • Improved handling of null values when creating URIs, returning an invalid URI instead of causing errors.
    • Improved deserialization of string data by safely handling null content and invalid persisted values.
    • Clarified documentation for invalid URI formats, including null inputs.
  • Tests

    • Added coverage for null, invalid, and valid URI inputs.
    • Added coverage for valid, null, and non-string deserialization scenarios.

Walkthrough

The changes add explicit null handling to SimpleUri construction and StringRepresentationTypeHandler.deserialize. Tests cover valid, invalid, null, and non-string inputs.

Changes

Null handling

Layer / File(s) Summary
URI constructor null handling
engine/src/main/java/org/terasology/engine/core/SimpleUri.java, engine-tests/src/test/java/org/terasology/engine/core/SimpleUriTest.java
The string constructor documents null as invalid input and returns before parsing. Tests cover valid, invalid, and null URI inputs.
String deserialization null handling
subsystems/TypeHandlerLibrary/src/main/java/org/terasology/persistence/typeHandling/StringRepresentationTypeHandler.java, subsystems/TypeHandlerLibrary/src/test/java/org/terasology/persistence/typeHandling/StringRepresentationTypeHandlerTest.java
deserialize returns Optional.empty() for null content, logs one warning per handler instance, and calls getFromString only for non-null string content. Tests cover null and non-string persisted data.

Estimated code review effort: 2 (Simple) | ~10 minutes

Poem

A rabbit checks each string with care,
Null values find an empty lair.
Invalid URIs stay safely still,
Non-null text passes by the hill.
Tests watch every hop and trail.

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly identifies both null-handling fixes in the changeset.
Description check ✅ Passed The description explains the null-pointer cause, both fixes, verification, and related regression coverage.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/simpleuri-null-npe

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🧹 Nitpick comments (2)
engine/src/main/java/org/terasology/engine/core/SimpleUri.java (1)

61-69: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add an automated regression test for the null input path.

The implementation correctly leaves new SimpleUri((String) null) invalid. Add a test that verifies isValid() returns false, so this deserialization fix remains protected from regression.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@engine/src/main/java/org/terasology/engine/core/SimpleUri.java` around lines
61 - 69, Add a regression test for the String constructor of SimpleUri that
passes a null value and asserts isValid() returns false, preserving the existing
invalid-state behavior.
subsystems/TypeHandlerLibrary/src/main/java/org/terasology/persistence/typeHandling/StringRepresentationTypeHandler.java (1)

22-30: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add an automated regression test for null persisted content.

Provide string-typed PersistedData whose getAsString() returns null. Assert that deserialize(data) returns Optional.empty(). Keep a non-null case to verify that valid values still reach getFromString.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@subsystems/TypeHandlerLibrary/src/main/java/org/terasology/persistence/typeHandling/StringRepresentationTypeHandler.java`
around lines 22 - 30, Add a regression test for
StringRepresentationTypeHandler.deserialize using string-typed PersistedData
whose getAsString() returns null, asserting Optional.empty(). Also retain or add
a non-null input case that verifies valid content is passed to getFromString and
deserialized successfully.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@engine/src/main/java/org/terasology/engine/core/SimpleUri.java`:
- Around line 61-69: Add a regression test for the String constructor of
SimpleUri that passes a null value and asserts isValid() returns false,
preserving the existing invalid-state behavior.

In
`@subsystems/TypeHandlerLibrary/src/main/java/org/terasology/persistence/typeHandling/StringRepresentationTypeHandler.java`:
- Around line 22-30: Add a regression test for
StringRepresentationTypeHandler.deserialize using string-typed PersistedData
whose getAsString() returns null, asserting Optional.empty(). Also retain or add
a non-null input case that verifies valid content is passed to getFromString and
deserialized successfully.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 2f658fd9-7e45-48b0-b405-90e6ca3afbc2

📥 Commits

Reviewing files that changed from the base of the PR and between 186a39a and 962df32.

📒 Files selected for processing (2)
  • engine/src/main/java/org/terasology/engine/core/SimpleUri.java
  • subsystems/TypeHandlerLibrary/src/main/java/org/terasology/persistence/typeHandling/StringRepresentationTypeHandler.java

`PersistedString` returns true from `isString()` unconditionally and hands back whatever it was constructed with, so `new PersistedString(null)` reproduces the reported NPE condition with a real type rather than a mock — which also confirms the guard is reachable and not dead code.

The handler test asserts `getFromString` is never entered, not just that the result is empty; a subclass tolerating null would pass the weaker assertion while the contract was broken.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@Cervator Cervator self-assigned this Aug 10, 2026
@agent-refr

Copy link
Copy Markdown
Collaborator

Agent-assisted review, driven by @Cervator via GDD.

Added regression tests for both guards, covering the two nitpicks raised above.

What pinned it down

PersistedString returns true from isString() unconditionally and hands back whatever it was constructed with:

public String getAsString() { return data; }
public boolean isString()   { return true; }

So new PersistedString(null) reproduces the reported condition with a real type rather than a mock. That matters beyond convenience: it answers whether the new guard is actually reachable. It is — and the Gson implementation can't produce this shape, because its isString() requires a genuine JSON string primitive. PersistedString is the path.

Tests

StringRepresentationTypeHandlerTest (new, subsystems/TypeHandlerLibrary)

  • string content deserializes normally
  • null content returns Optional.empty()
  • non-string data returns Optional.empty()

The null case asserts getFromString is never entered, not merely that the Optional is empty — a subclass that tolerated null and returned null would pass the weaker assertion while the actual contract was broken.

SimpleUriTest (new, engine-tests)

  • parses module:object
  • a string with no separator is invalid
  • null is invalid and lands in the same empty state as the no-arg constructor

Both green, 3 tests each, verified from the JUnit XML rather than the exit code.

One suggestion, take it or leave it

Neither guard logs anything, so a stale or renamed reference now disappears silently — the component quietly fails to restore and nothing says why. That trades an NPE for a harder-to-diagnose symptom. A single logger.warn naming the type would make it findable; worth considering warn-once or debug if a large save could emit it per-entity.

Also trimmed the inline comment to two lines and pointed it at PersistedString rather than the ComponentClassTypeHandler example, since that's the concrete implementation that produces the null.

Returning empty on null content trades an NPE for a component that quietly fails to restore, which is harder to diagnose than the crash was.

Bounded by construction rather than by a cap: the bucket is the handler, and there are 14 subclasses, so the worst possible load emits 14 lines however many entities carry the damaged reference. No sample counters or reset window needed, unlike `WorldProviderCoreImpl.logDroppedWrite` whose bucket space is chunk positions.

Per-instance, not static — MTE builds a fresh handler library per environment, and a static flag would silence every test after the first.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

@Cervator Cervator left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I had my agent add the extra logging that'll be one-time per type per instance - useful, but not overwhelming. CodeRabbit is happy and the prior commit ran fine including new tests.

@Cervator
Cervator merged commit f99bb26 into develop Aug 10, 2026
3 of 4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Type: Bug Issues reporting and PRs fixing problems

Projects

Status: No status

Development

Successfully merging this pull request may close these issues.

3 participants