Conversation
ggleyzer
left a comment
There was a problem hiding this comment.
Too complex and risky at the moment. My recommendation is to sit on this one unless it becomes a blocker.
|
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. |
|
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.
701fe9f to
cfd564c
Compare
…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.
The problem: you cannot currently debug the runtime without changing it
A debugger does not ask permission before calling
toString(). IntelliJ, Eclipse andjdbrender 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 isObjectHandle.toString(), which every handle inherits: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:
DeferredArrayHandlefreeze()interned a freshImmutableTypeConstanton every renderxRTType.TypeHandle,xClass.ClassHandle,Proxy.ProxyHandleaugmentType()/freeze()→ interningPropertyBodygetAnnotationClass()→getImplicitlyImportedIdentitypopulatedf_implicitsand interned package/class constants;getPropertyAnnotations()forcedbuildAnnotationArrays()Component.ContributionresolveTypedefs()TypeInfotoString()was the full member dump — hovering aTypeInfowalked every method and property it hasWhy 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()calledgetField(null, "text"), which allocates aDeferredCallHandlewhen 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.TypeInfoconstruction is memoized, so a debugger can permanently change what the program computed. Forcing aTypeInfoto 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 (henceopenRuntimeSynthesisWindow). 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
WrapperExceptionis the Java exception the runtime throws to carry an XTC exception, and itstoString()(ObjectHandle.java:690) is:which reaches
ExceptionHandle.toString()and itsgetField(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 calledUtils.translate(e), allocating an exception handle in the owning container from the rendering thread, and it NPE'd whenFutureTupleHandle.getFuture()returned null. (To be accurate: theget()there is guarded byisDone(), 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 oldtoString(boolean fRuntime)selected the runtime-optimized view — andfRuntimewas the only thing that made rendering side-effecting, viaensureOptimizedMethodChain. It had zero callers anywhere in the repo.toString(boolean)is retained as afinal @Deprecateddelegate 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()changeWorth stating up front, because it is the one edit that changes a widely-used method: the rendered string is provably identical.
resolved != constId && resolved != null→ returnresolved(and write it back); otherwise returnconstId.resolved == null→constId; otherwiseresolved.Three cases, all returning the same object:
resolved == null→ bothconstId;resolved == constId→ master returnsconstId, branch returnsresolved, which isconstId;resolved != constId && != null→ both returnresolved. The only behavioural difference is that the display path no longer writes back tom_constId, and no longer runs theassert !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 ofensureResolvedConstant()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
DisplayPurityCensusTestenumerates everyObjectHandlesubclass declaring its owntoString()and requires each to be exercised or listed inNOT_EXERCISEDwith a reason, failing on a stale exclusion too. The enumeration walks the compiled class output —getProtectionDomain().getCodeSource(), thenisAssignableFromandgetDeclaredMethod("toString"). No source text is read.That matters: an earlier draft scanned
.javasource and found 48 classes. The classfile scan finds 49, because it sees anonymous classes source-scanning could only guess at — includingObjectHandle$1, theDEFAULTsingleton, whosetoString()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:
ensure*Constantintern 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;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, theensureCaches()avoidance inTypeInfoReal.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.TypeHandleorxClass.ClassHandlerunsensureClass/resolveClass, which interns the very typetoString()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:
renderingAHandleNeverAsksItsCompositionForATypeConstantdid not catch its own revert: the old body wasm_fMutable || clz.getType().isImmutable(), so on a mutable handle the||short-circuits andgetType()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:test455 → 470, same 42 pre-existing skips,./gradlew buildgreen, and an end-to-endxcc+xecrun confirmsType.dump()still returns the full member list.