Skip to content

Make toString() side-effect-free, so stepping through the runtime does not mutate it - #569

Closed
lagergren wants to merge 8 commits into
masterfrom
lagergren/master-tostring-side-effects
Closed

lagergren wants to merge 8 commits into
masterfrom
lagergren/master-tostring-side-effects

Conversation

@lagergren

@lagergren lagergren commented Sep 1, 2026 •

Copy link
Copy Markdown
Contributor

The problem: you cannot currently debug the runtime without changing it

A debugger does not ask permission before calling toString(). IntelliJ, Eclipse and jdb render every variable in the Variables pane, every watch expression and every hover — automatically, on every step. Rendering is not something a developer opts into; it is the ambient cost of having a breakpoint.

Today, several of the runtime's toString() implementations mutate the program while rendering it. Not slowly — observably. The root is ObjectHandle.toString(), which every handle inherits:

ObjectHandle.toString():354  →  clz.getType().isImmutable()
  → TerminalTypeConstant.isImmutable()  → resolveTypedefs()
    → ensureResolvedConstant()          → m_constId = constId = resolved;

That last line is a write, performed because a debugger drew a line of text. Every handle in the frame, on every step.

It is not the only one:

rendering this did this
DeferredArrayHandle freeze() interned a fresh ImmutableTypeConstant on every render
xRTType.TypeHandle, xClass.ClassHandle, Proxy.ProxyHandle augmentType() / freeze() → interning
PropertyBody getAnnotationClass() → getImplicitlyImportedIdentity populated f_implicits and interned package/class constants; getPropertyAnnotations() forced buildAnnotationArrays()
Component.Contribution resolveTypedefs()
TypeInfo toString() was the full member dump — hovering a TypeInfo walked every method and property it has

Why startup is the worst place for this, and the reason for the "especially startup" in the title

Three things compound during startup, and each makes the others worse.

Nothing is cached yet, so every intern is a real intern. Later in a run most of these constants already exist and the mutation is invisible. During startup the pool is still filling, so rendering does the maximum amount of work at exactly the moment you most want to watch what is happening.

Structures are half-built, and the display path did not tolerate that. ExceptionHandle.toString() called getField(null, "text"), which allocates a DeferredCallHandle when the property is absent and throws when the field layout has not been computed. Put a breakpoint in container startup, look at the Variables pane, and you get errors where values should be — not because your program is broken, but because the renderer demanded state that does not exist yet. ObjectHandle.toString() likewise NPE'd on a composition-less handle.

TypeInfo construction is memoized, so a debugger can permanently change what the program computed. Forcing a TypeInfo to be built early — at a breakpoint, in a different order than production would have — caches that result. TypeInfo building also interns constants and invalidates stale TypeInfos (hence openRuntimeSynthesisWindow). Doing that from a debugger thread is not merely slow; it changes the artifact.

The practical effect is the one every developer knows and hates: a bug that reproduces without the debugger and not with it. When observation and mutation are the same operation, stepping through startup is not a diagnostic technique, it is a second experiment.

This is not only about debuggers

WrapperException is the Java exception the runtime throws to carry an XTC exception, and its toString() (ObjectHandle.java:690) is:

public String toString() {
    return getExceptionHandle().toString();
}

which reaches ExceptionHandle.toString() and its getField(null, "text"). So the allocating, layout-dependent path ran on every printed stack trace involving a runtime exception, not only under a debugger.

xFuture.FutureHandle.toString() had its own version: on the failure branch it called Utils.translate(e), allocating an exception handle in the owning container from the rendering thread, and it NPE'd when FutureTupleHandle.getFuture() returned null. (To be accurate: the get() there is guarded by isDone(), so it does not block — it is an allocation and a null hazard, not a hang.)

The change

One rule, stated once, on TypeInfo.toString() as the family contract: toString() is guaranteed side-effect-free and safe for a debugger to call implicitly; dump() is the explicit "give me the full thing" call.

dump() takes no argument. The old toString(boolean fRuntime) selected the runtime-optimized view — and fRuntime was the only thing that made rendering side-effecting, via ensureOptimizedMethodChain. It had zero callers anywhere in the repo. toString(boolean) is retained as a final @Deprecated delegate rather than removed, since it is a public abstract method on a public abstract class and an out-of-repo subclass could exist.

Production diff is 399 lines across 21 files — mostly a non-interning peek* variant added beside an existing method, and a render path switched onto it. Type.dump() still returns the full member dump through the Ecstasy-visible API.

Safety of the TerminalTypeConstant.getValueString() change

Worth stating up front, because it is the one edit that changes a widely-used method: the rendered string is provably identical.

  • master: resolved != constId && resolved != null → return resolved (and write it back); otherwise return constId.
  • branch: resolved == null → constId; otherwise resolved.

Three cases, all returning the same object: resolved == null → both constId; resolved == constId → master returns constId, branch returns resolved, which is constId; resolved != constId && != null → both return resolved. The only behavioural difference is that the display path no longer writes back to m_constId, and no longer runs the assert !constId.containsUnresolved().

Residual risk, stated honestly: anything relying on getValueString() to advance resolution would now behave differently. Resolution is not starved — 45 other call sites of ensureResolvedConstant() remain in that class — and depending on a display method to advance state would itself be the bug this PR is about.

Tests, and what they do and do not prove

DisplayPurityCensusTest enumerates every ObjectHandle subclass declaring its own toString() and requires each to be exercised or listed in NOT_EXERCISED with a reason, failing on a stale exclusion too. The enumeration walks the compiled class output — getProtectionDomain().getCodeSource(), then isAssignableFrom and getDeclaredMethod("toString"). No source text is read.

That matters: an earlier draft scanned .java source and found 48 classes. The classfile scan finds 49, because it sees anonymous classes source-scanning could only guess at — including ObjectHandle$1, the DEFAULT singleton, whose toString() was being silently attributed to the base class.

The purity assertion renders 20+ live handles and attributes any pool growth to the exact class. Three details make it real rather than decorative:

  • it does not warm up first. An ensure*Constant intern is idempotent, so a handle that interns on its first render and then finds the constant present is invisible to a check that only looks at repeat renders — and the first render is the one a debugger performs;
  • it renders everything a second time, to catch anything interning fresh on every call;
  • a negative control interns a fresh parameterized type and asserts the pool grew, so a green run cannot mean the instrument is dead.

What this does not prove

Each of the 18 production hunks was reverted individually and the suite re-run. Nine turn something red; nine turn nothing red — PropertyBody.toString, Component.Contribution.toString, the ensureCaches() avoidance in TypeInfoReal.toString, ExceptionHandle.toString, Proxy.ProxyHandle.toString, xRTType.TypeHandle.toString, xClass.ClassHandle.toString, xFuture.FutureHandle.toString, xString.StringHandle.toString. Half of this branch is verified by reading the call chain, not by a failing test, and that list is in the census's javadoc rather than only here.

The reason is structural for the reflection handles: constructing an xRTType.TypeHandle or xClass.ClassHandle runs ensureClass/resolveClass, which interns the very type toString() would have frozen — so the novelty is consumed before you can render. That was established by reverting those files and confirming the tests stayed green, not assumed.

Purity coverage is 13 of 49 exercised, 36 excluded with reasons. The enumeration half is a true ratchet; the purity half proves the 13 a fixture can reach.

Two tests that were green for the wrong reason

Both found by the revert sweep, both fixed, and worth stating because they are the reason to trust the rest:

  • an early census draft warmed up before measuring, which hid one-shot interning — it passed against the unfixed code;
  • renderingAHandleNeverAsksItsCompositionForATypeConstant did not catch its own revert: the old body was m_fMutable || clz.getType().isImmutable(), so on a mutable handle the || short-circuits and getType() is never reached. A sibling caught it by accident. Fixed by freezing the handle under test.

The suite was consolidated on that evidence — 8 files/1491 lines down to 5 files/1406, merging tests whose reverts were caught by others and deleting one that was the unique catcher for nothing. No surviving assertion was loosened.

:javatools:test 455 → 470, same 42 pre-existing skips, ./gradlew build green, and an end-to-end xcc + xec run confirms Type.dump() still returns the full member list.

@ggleyzer ggleyzer left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Too complex and risky at the moment. My recommendation is to sit on this one unless it becomes a blocker.

@lagergren

lagergren commented Sep 24, 2026 •

Copy link
Copy Markdown
Contributor Author

I seriously want to be able to attach and debug in the IDE, and this keeps getting so much in the way with mysterious "it just stopped working IDK - there's an exception somewhere due to bootstrapping/first call to toString modified state."

I can try to resubmit this as different smaller PRs, @cpurdy and @ggleyzer, but I really think the problems with complexity/debuggability are worse now than they would be with this fix.

@lagergren

Copy link
Copy Markdown
Contributor Author

It is also very challenging to understand the constant pool at runtime (but same issue at compile time, but somewhat easier to pin down), if just stepping in the debugger in vscode has side effects. The waveform collapsed on observation, so to speak. I very much doubt that I will be the last newcomer to the XTC code base who will feel a bit impared that just stepping through the code - not necessarily hunting for a bug - but just trying to understand how it works, can break the world I was hoping to explore. _

toString()/getValueString()/getDescription() are called IMPLICITLY - by string
concatenation, by Throwable.toString() while a stack trace prints, and by an IDE
debugger rendering a row in the Variables view. Five display methods in the ASM
plane changed state while doing it, so observing the program changed the
program: a breakpoint altered behaviour and the debugger session stopped being
trustworthy.

ParameterizedTypeConstant.getValueString asked m_constType.isA(pool.typeFunction())
and then pool.extractFunctionParams/Returns(this) just to pick the pretty
"function R(P)" spelling. typeFunction()/typeMethod() lazily intern the canonical
types, isA() writes the type's relation cache and can call pool.register(this),
and each extractFunction* runs isA() again. The pretty spelling is now derived
structurally from the constant's own fields, so the text is byte-for-byte what it
always was and nothing is interned.

TerminalTypeConstant.getValueString went through ensureResolvedConstant(), which
STORES the resolution into m_constId; rendering a type mid-compilation collapsed
an unresolved constant. It now reads the resolution into a local and leaves the
write-back to the non-display callers.

Annotation.getValueString/getDescription had the same defect via
getAnnotationClass(), which stores into m_constClass; they now go through a new
private peekAnnotationClass().

ParamInfo.toString compared its constraint against
typeConstraint.getConstantPool().typeObject() - which interns the canonical
Object type - and then called isTuple(), which interns clzTuple(), forces the
class structure to load, writes the resolved constant back, AND throws
IllegalStateException when the structure is not loaded, taking down the render of
the whole enclosing TypeInfo. Both suffixes are now decided on the
already-computed value string.

BinaryAST.toString nagged "TODO implement toString() for ..." through a
process-global raw HashSet that it ADDED to, and printed to System.err. That made
merely LOOKING at a node mutate shared, unsynchronized process state. The nag (and
the Set, whose only caller was this toString) is gone; the node names its node
type.

Proof, red on origin/master fd7eb58 with DisplayPurityTest (6/6 fail):
  renderingAParameterizedTypeDoesNotGrowThePool
      rendering List<Int> interned into the ConstantPool ==> expected: <22> but was: <30>
  functionTypesStillRenderTheirPrettySpelling
      rendering function types interned into the pool ==> expected: <34> but was: <37>
  renderingATypeParameterDoesNotGrowThePool
      java.lang.IllegalStateException: no ClassStructure for
      Class{class=Int, package=ecstasy:numbers, module=ecstasy.xtclang.org}
  renderingATypeDoesNotConsumeItsUnresolvedConstant
      rendering stored the resolution back into the type constant ==> expected: <2> but was: <1>
  renderingAnAnnotationDoesNotConsumeItsUnresolvedConstant
      rendering stored the resolution back into the annotation ==> expected: <2> but was: <1>
  renderingAnAstNodeWritesNothingGlobal
      expected: <> but was: <TODO implement toString() for UnrenderedAST>

All six are green here. Each pool assertion carries a negative control that
interns a fresh type and asserts pool.size() moved, so a green cannot mean a dead
instrument. The tests run against a cold FileStructure pool, because a warmed pool
already holds the canonical constants a display path reaches for and hides the
growth entirely.
MethodStructure.getDescription is the funnel every method rendering goes through
(XvmStructure.toString delegates to it), and it reported "line-count=" via
Source.getLineCount(). That calls Source.normalize(), which chops the source text
into lines, interns ONE StringConstant PER SOURCE LINE into the ConstantPool, and
publishes m_aconstSrc/m_anIndents through non-final fields with no
synchronization. Expanding a method node in a debugger therefore grew the pool by
the size of that method's own source - and did so from the debugger's thread,
racing whatever the compiler was doing with the same pool.

Add Source.peekLineCount(), the non-forcing counterpart of getLineCount(), and
report "line-count=<deferred>" until the source has actually been chopped up. Once
something else has legitimately normalized it, the description reports the real
count again, so nothing is lost from a normal compile's diagnostics.

Proof, red on origin/master fd7eb58 with MethodDisplayPurityTest:
  renderingAMethodDoesNotNormalizeItsSourceIntoThePool
      an un-normalized source must report a deferred line count rather than
      chopping itself up to answer: host="PurityTest", id="void hello()", ...
      line-count=5

Green here. The test then calls getSourceLineCount() deliberately and asserts the
pool DID grow, so the purity assertion above is proven to be measuring something.
The no-arg TypeInfo.toString() - the one Java and an IDE debugger call
implicitly - produced the FULL member dump. That dump mutates exactly the state
being inspected:

  - the Properties and Methods sections call key.resolveNestedIdentity(pool, null)
    per member, which interns into the shared ConstantPool and memoizes
    IdentityConstant.m_canonicalNid;
  - it renders every MethodInfo, and MethodInfo.toString called isOp(), which
    reaches for the AMBIENT pool (MethodBody.pool() is a thread-local read) to
    intern the Op class identity via getImplicitlyImportedIdentity, and forces
    MethodBody.getMethodStructure() to load and cache the method's component;
  - with fRuntime set it calls MethodInfo.ensureOptimizedMethodChain(this), which
    computes and CACHES optimized chains onto the live MethodInfo.

Because isOp() reads a thread-local, rendering a TypeInfo on a thread with no pool
bound - precisely a debugger's situation - did not merely mutate state, it threw
NullPointerException and took down the rendering of everything holding it.

  toString()      is now the PURE one-line header (identity, progress, format,
                  flags), declared abstract on TypeInfo so a new subclass cannot
                  silently inherit a member-walking toString().
  dump(boolean)   is the full member dump, byte-for-byte the historical
                  toString(fRuntime) output.
  MethodInfo.toString() drops the @op prefix (deciding it is the ambient/interning
                  call above) and MethodInfo.dump() keeps it, so the TypeInfo dump
                  is unchanged.
  xRTType         "dump" now calls dump(false), so Ecstasy's Type.dump() - the only
                  production consumer of TypeInfo.toString() in javatools/src/main
                  - returns exactly the same string as before.

The header also stopped calling isAbstract(), which forces ensureCaches() (walks
every method, populates the shared id/nid caches, computes m_fImplicitAbstract).
It reports the explicit flag always and the implicit one only once it has been
computed; the forced dump still passes the real isAbstract().

Why a separate name rather than keeping the dump on toString(boolean): the arity
would have carried the safe/unsafe distinction invisibly, and an overload implies
the two outputs are variants of one document when they are two different
documents. A named method also cannot be reached by muscle memory from a log
statement. "dump" is the repo's existing vocabulary for forcing renderings
(XvmStructure.dump, MethodStructure.dump) and it is literally what backs Ecstasy's
Type.dump(). fRuntime keeps its original, single meaning: raw bodies vs optimized
chains.

Proof, red on origin/master fd7eb58:
  TypeInfoDisplayPurityTest.toStringIsAPureHeaderAndDumpIsTheFullMemberList
  DisplayPurityRuntimeTest.renderingWithoutAnAmbientPoolDoesNotThrow
      java.lang.NullPointerException: Cannot invoke "org.xvm.asm.ConstantPool.clzOp()"
      because the return value of "org.xvm.asm.constants.MethodBody.pool()" is null
        at org.xvm.asm.constants.MethodBody.isOp(MethodBody.java:632)
        at org.xvm.asm.constants.MethodInfo.isOp(MethodInfo.java:1455)
        at org.xvm.asm.constants.MethodInfo.toString(MethodInfo.java:1615)
        at org.xvm.asm.constants.TypeInfoReal.toString(TypeInfoReal.java:2219)
        at org.xvm.asm.constants.TypeInfo.toString(TypeInfo.java:707)

Both green here. DisplayPurityRuntimeTest is the empirical half of the gate: it
builds 100+ real type-system objects over the compiled XDK, renders them all three
times through toString()/getValueString()/getDescription(), and asserts the shared
ConstantPool did not grow - with a negative control proving pool.size() detects
interning. It complements DisplayPurityTest, whose cold-pool assertions catch the
interning a warmed pool hides.
Two more ASM display paths that mutate what they render.

PropertyBody.toString() classified four annotations by INTERNED IDENTITY:
isInjected()/isExplicitOverride()/isExplicitReadOnly() reach
getConstantPool().clzInject()/clzOverride()/clzRO(), and isExplicitAbstract()
reaches TypeInfo.containsAnnotation(), whose getImplicitlyImportedIdentity()
populates the pool's f_implicits map and interns package and class constants.
getPropertyAnnotations() additionally forces the lazy property/ref annotation
split, which itself interns via ensureTerminalTypeConstant() and typeProperty().
Rendering a single property row grew the pool by several constants.

Display now compares annotations by NAME, off the raw contributions:
PropertyStructure.peekHasAnnotation(String) walks getContributionsAsList() - a
plain field read - and Annotation.peekAnnotationName() reads the resolution
without the getAnnotationClass() write-back. Comparing by name is what removes
the interning entirely; the trade is that it does not distinguish "into Property"
annotations from Ref annotations, which is exactly the part that has to intern,
and which a debug string does not need.

Component.Contribution.toString() called m_typeContrib.resolveTypedefs(), which
runs ensureResolvedConstant() and writes the resolved constant back into the
terminal type's m_constId. Every structure's toString() reaches that line through
getDescription(), so rendering any class in a debugger advanced resolution state.
A typedef now renders as itself, which is also what the source says.

HONEST LIMITATION: neither fix has a red-on-master test. The mutation is real and
mechanical (read the call chains above), but the observable this branch uses -
shared ConstantPool growth - cannot see it, because on a warmed container-zero
pool the canonical Inject/Override/RO/Abstract identities and the resolved
typedefs are already interned, so nothing grows. Both are verified by inspection
of the call chain, not by a failing test, and that is a weaker standard than the
rest of this branch. A cold-pool fixture that reaches PropertyBody would close it.
… dump()

A debugger's watch window renders HANDLES far more than it renders ASM
structures, so the runtime plane is the centre of the "toString() must never
trigger a side effect" guarantee, not its tail.

THE ROOT. ObjectHandle.toString() - inherited by nearly every handle - computed
getComposition().getType().isImmutable() only to decide whether to suppress an
"immutable " prefix. On a terminal type isImmutable() runs resolveTypedefs() ->
ensureResolvedConstant(), whose body is `m_constId = constId = resolved`, and on
the formal branches it also interns typeService() and writes a relation cache. It
now decides that from the handle's own m_fMutable, TypeComposition.isConst() (a
plain read of ClassStructure's format bits) and the already-rendered label. It is
also null-safe now, where the old body would NPE on a composition-less handle.

Fixing that one method also cleans ExceptionHandle, StringHandle and the element
half of TupleHandle, which only inherited it.

FREEZE OFF THE DISPLAY PATH. xRTType.TypeHandle, xClass.ClassHandle,
Proxy.ProxyHandle and DeferredArrayHandle all rendered via getType(), and
getType() augments: augmentType() calls freeze() whenever the handle is not
mutable, interning an ImmutableTypeConstant, and getParamType(i) falls back to
pool.typeObject() - another intern - when the index is out of range. They now
read the composition's own type and use the new, non-interning
TypeConstant.peekParamType(int), rendering "<deferred>" instead of interning.

ALLOCATION. xFuture.FutureHandle.toString() called toSafeString(), which JOINS
the future with get() and, when it had failed, ran Utils.translate(e) - allocating
a fresh exception handle in the owning container from whatever thread was
rendering. It also NPE'd when FutureTupleHandle.getFuture() returned null. It now
reports state via isDone()/isCompletedExceptionally()/getNow(), which neither
block nor allocate.

MEMOIZATION. ExceptionHandle.toString() called getField(null, "text"), which
ALLOCATES a DeferredCallHandle around a fresh exception handle when the property
is absent and throws when the field layout has not been built - and Java calls
this method itself, because WrapperException.toString() delegates here on every
stack-trace print. It now uses a new GenericHandle.peekField(String), guarded by
a new TypeComposition.isFieldLayoutComputed(). xString.StringHandle.toString()
memoized m_sValue; it now reads the cache if present and builds a throwaway
String if not.

THE API. dump() loses its boolean. fRuntime had no caller anywhere in the repo,
it selected a runtime-optimized VIEW rather than a verbosity (it printed FEWER
methods - capped ones were skipped), and the only mutating call in the dump,
ensureOptimizedMethodChain, sat under `if (fRuntime)`. So dump() is now
side-effect-free as well as explicit. The public abstract toString(boolean) is
NOT deleted - that would remove a public abstract method from a public abstract
class and break an outside subclass or embedder - it is retained as a final
@deprecated delegate to dump(). The purity contract is stated ONCE, on
TypeInfo.toString(), as the rule for the whole family rather than per site.

PROOF.

  ObjectHandleDisplayPurityTest, red with ObjectHandle.java at its pre-fix state:
    java.lang.AssertionError: a display path must not need the composition's
    TypeConstant
  (thrown by a stub TypeComposition that fails if toString() asks for a type).
  Green after. It also pins that the "immutable " prefix is still correct for
  frozen, mutable and const handles.

  DisplayPurityCensusTest is the ratchet the site list cannot be: it ENUMERATES
  every ObjectHandle subclass declaring toString() from the source tree plus
  reflection - 48 of them - and requires each to be exercised by a live population
  or listed in NOT_EXERCISED with a stated reason. It fails on a new toString()
  nobody has written yet, and it fails on a NOT_EXERCISED entry that has gone
  stale (that check already caught seven entries guessed at during development).
  censusDetectsAnImpureToString renders a deliberately-interning handle and
  requires the detector to fire, so a green cannot mean a dead instrument.

  renderingAHandleOverANovelTypeDoesNotFreezeIt, red with ObjectHandle.java at
  its pre-fix state:
    DeferredArrayHandle.toString() interned 1 constant(s) while rendering a type
    the pool had not yet frozen - it called getType()/augmentType()/freeze().
    Rendered: Deferred array initialization:
    ImmutableType{type=immutable Array<Map<String, Set<Byte>>>}
    ==> expected: <83681> but was: <83682>
  Rendering over a well-known type observes nothing, because container startup
  already interned its immutable form; rendering over a type the pool has never
  seen gives freeze() real work to do. The test asserts that precondition first -
  it freezes an equally-novel control type and requires the pool to grow - so it
  cannot silently degrade into "already interned, nothing to observe".

HONEST LIMITATIONS.

  Coverage: 12 of the 48 handle toString()s are exercised, 36 are excluded with
  individual reasons. The enumeration half is a true ratchet; the purity half only
  proves the 12 a fixture can reach.

  The pool observable is measured on the FIRST render, per object. An earlier
  draft warmed up first, which hid one-shot interning, and it passed against the
  unfixed code - it was wrong and was rebuilt.

  xRTType.TypeHandle and xClass.ClassHandle cannot be made red this way at all:
  constructing either runs ensureClass/resolveClass, which interns the augmented
  type their toString() would have frozen, so by render time nothing is left to
  observe however exotic the type. Verified by reverting both toString()s and
  watching the test stay green. Their fixes, and Proxy.ProxyHandle's, rest on the
  call chain rather than on a failing assertion; the assertions covering them are
  regression guards, not evidence. This is stated in the test's own javadoc.
…census

No behaviour change. Three review items.

COMMENTS. The 17 NOTE comments each restated the same rule before getting to
their own mechanism. The rule is now stated ONCE, on TypeInfo.toString(), marked
as its canonical statement; every other site is a one-line "Display purity (see
TypeInfo.toString())" pointer followed by only what is specific to it - the
mechanism, which is the half that stops someone reverting the change.

TypeInfo.toString() is kept as the canonical home rather than moving it to
ObjectHandle.toString(). ObjectHandle is the more-rendered site, but the rule
governs the compiler as well as the runtime, and 10 of the 17 sites are in
org.xvm.asm; sending a reader of ParameterizedTypeConstant to a runtime class
would be worse than the reverse. TypeInfo is also where the toString()/dump()
API decision that embodies the rule already lives.

CENSUS. DisplayPurityCensusTest enumerated by walking src/main/java and reading
every .java file. That is source inspection, and it is brittle in fact as well as
in principle: it can be fooled by formatting, by the token appearing in a comment
or a string literal, by a file moving, and by the working directory the build runs
from. It now enumerates the COMPILED CLASSES instead, via
ObjectHandle.class.getProtectionDomain().getCodeSource() - which the test already
relied on transitively - keeping classes where ObjectHandle.isAssignableFrom(c)
and c.getDeclaredMethod("toString") exists. No regex, no path assumptions.

The count moved 48 -> 49, and the difference is the interesting part: the
classfile scan finds the ANONYMOUS handle classes the source walk could only
guess at by probing "$1".."$6" ordinals. It gained ObjectHandle$1 (the
ObjectHandle.DEFAULT singleton, previously invisible - its toString() was being
attributed to the base class) and xRTFunction$FullyBoundHandle$1 (the public
NO_OP singleton). NO_OP is a public static field with a constant rendering, so it
is now EXERCISED rather than excluded. Coverage is 13 of 49 exercised, 36
excluded with individual reasons; the javadoc says so.

VAR. StringBuilder declarations in the toString() methods this branch already
edits use var: Component.Contribution, MethodInfo, ParamInfo, PropertyBody,
xString.StringHandle, plus the branch-authored first line of TypeInfoReal.dump().
Deliberately NOT applied to the getDescription() methods this branch touches, nor
to the other StringBuilder declarations in the same ten files - those are
untouched code and sweeping them would be scope drift.

In the branch's own test files: var for a spelled-out ClassStructure, and
list.addAll(struct.children()) in place of forEach(list::add), one line after two
addAll calls doing the same thing.

Left alone deliberately: the four-element ANNOTATIONS_DISPLAYED loop in
PropertyBody stays a loop - a stream over it would be a side-effecting forEach,
which is a loop with ceremony, and the map/joining form allocates for no benefit.
ExceptionHandle's new String(hString.getValue()) stays as it is: it mirrors
getStringValue()'s own body, and copying is what makes it safe to hand out
without aliasing the handle's live char[].
Trimmed by measurement, not by feel: each of the 18 production hunks on this
branch was reverted ON ITS OWN and the whole javatools suite run, recording which
tests went red. 8 files / 1491 lines -> 5 files / 1394 lines, with no assertion
weakened and nothing deleted that uniquely catches anything.

WHAT THE SWEEP FOUND

Nine reverts turn something red, and in every case the catcher is the ONLY test
that catches it - so no surviving test is redundant:

  ParameterizedTypeConstant.getValueString -> DisplayPurityTest x2
  TerminalTypeConstant.getValueString      -> DisplayPurityTest
  ParamInfo.toString                       -> DisplayPurityTest
  Annotation display                       -> DisplayPurityTest
  BinaryAST.toString                       -> DisplayPurityTest
  MethodStructure.getDescription           -> DisplayPurityTest (merged in here)
  MethodInfo.toString                      -> DisplayPurityRuntimeTest
  ObjectHandle.toString                    -> ObjectHandleDisplayPurityTest
  DeferredArrayHandle.toString             -> DisplayPurityCensusTest

Nine reverts turn NOTHING red: PropertyBody.toString,
Component.Contribution.toString, the ensureCaches() avoidance in
TypeInfoReal.toString, ExceptionHandle.toString, Proxy.ProxyHandle.toString,
xRTType.TypeHandle.toString, xClass.ClassHandle.toString,
xFuture.FutureHandle.toString and xString.StringHandle.toString. Each mutates by
a mechanism a warmed pool cannot show - an already-interned canonical constant, a
private lazy cache, an allocation, a memoized field - so they are
inspection-verified only. That list is now recorded in the census's own javadoc,
where a reader will find it; closing any of them needs an observable, not another
assertion over the same population.

A BUG THE SWEEP FOUND IN MY OWN TEST

ObjectHandleDisplayPurityTest.renderingAHandleNeverAsksItsCompositionForATypeConstant
did NOT catch its revert. The pre-fix body was

    m_fMutable || clz.getType().isImmutable()

so on a mutable handle the || short-circuits and getType() is never reached: the
assertion held against the unfixed code and proved nothing. Only its sibling
caught the revert, by accident, because that one sets m_fMutable = false. The
test now freezes the handle first, and both halves catch. This is the second time
a purity assertion here has been green for the wrong reason; the comment says so.

WHAT MOVED

  MethodDisplayPurityTest (55)   -> merged into DisplayPurityTest; same fixture
                                    (cold FileStructure pool), same kind of
                                    assertion, still the unique catcher.
  TypeInfoDisplayPurityTest (84) -> merged into DisplayPurityRuntimeTest; both
                                    need the XDK-backed container.
  HandlePopulation (130)         -> folded into DisplayPurityCensusTest, its only
                                    consumer. That also settles the two-fixtures
                                    smell: DisplayPurityFixture is now the single
                                    fixture, and it does one thing - locate the
                                    built XDK and start a runtime.

DELETED OUTRIGHT: DisplayPurityRuntimeTest.renderingTheTypeSystemDoesNotGrowThe-
ConstantPool. It was the unique catcher for nothing, and it is the specific test
whose warm-pool design once passed against impure code. Its job is done properly
elsewhere - cold-pool assertions in DisplayPurityTest, and the per-object,
first-render version in the census. Its negative control is not lost; the
surviving cold-pool tests each carry one.

KEPT DELIBERATELY: every negative control, the no-warm-up comment, and the "what
this proves and does not prove" javadoc.
Blank-line-only changes. One is an artifact of resolving the BinaryAST conflict
during the rebase; the rest are in the display-purity tests, which predate the
spotless rules that master has since picked up.
@lagergren
lagergren force-pushed the lagergren/master-tostring-side-effects branch from 701fe9f to cfd564c Compare September 24, 2026 20:40
@lagergren lagergren closed this Sep 25, 2026
lagergren added a commit that referenced this pull request Sep 26, 2026
…tring

ExceptionHandle.toString() read its message with getField(null, "text") and rendered it with
StringHandle.getStringValue(). Both do more than display should.

getField() looks up the field layout, which is null until ensureFieldLayout() has built it, and
when the property is absent it calls missingPropertyException(), allocating a DeferredCallHandle
around a freshly constructed exception handle - so rendering an exception could construct another
one. getStringValue() memoizes into the handle:

    return sValue == null ? (m_sValue = new String(m_achValue)) : sValue;

This matters more than a debugger convenience because Java calls this method itself:
WrapperException.toString() delegates here, so it runs on every stack-trace print.

peekField() returns the same handle in the case that renders - both end at
m_aFields[field.getIndex()] for a non-transient field of a built layout - and null in every case
where getField() would allocate, throw, or route through a null frame. At the call site null and a
DeferredCallHandle are both "not a StringHandle", so the rendered text is the same either way. A
transient "text" would previously have called getTransientField(null, field) with a null frame, so
that path was already broken rather than newly unsupported.

new String(hString.getValue()) is the same content getStringValue() would have produced and cached,
since getStringValue() is exactly that expression with a store.

isFieldLayoutComputed() is the guard peekField() needs: a default of false on TypeComposition, and
the real answer on ClassComposition, which owns the map.

Verified by running a module that throws one caught and one uncaught exception, under master's
javatools and this branch's: both renderings, including message text and stack frames, are identical
once the wall-clock timestamp in the unhandled-exception banner is normalized.

The rest of the ObjectHandle work from #569 is not here. ObjectHandle.toString() swaps
clz.getType().isImmutable() for clz.isConst(), and DeferredArrayHandle.toString() renders the
composition instead of the type; both change output and need their own measurement.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants