Skip to content

Nullability: a demand overruled by a null-tolerant analysed override (Joiner's useForNull) #85

Closed

Description

@bnaudts

#68's tolerance walk fails on a parameter the callee demands non-null (demanded(m, pi)). The call may be virtual: guava's Joiner.appendTo calls toString(parts.next()); the base Joiner.toString(part) does requireNonNull(part), but the useForNull override reads part and demands nothing (part == null ? nullText : ...). For the Kotlin printer a parameter must be nullable when some dispatch target accepts null, so a demand in the declaring method should be overruled by an analysed override that reads the parameter and demands nothing of it.

The rule was tried (reverted, not in any commit): in tolerates(), case ParameterInfo, if (demanded(..)) { if (toleratedByOverride(m, pi)) continue; return false; }, with implementations.get(m) giving the overrides. It did not decide Joiner on its own because the walk never reaches toString: nothing in the successors map connects the anonymous AbstractList.get()'s return to appendTo's parts.next(). That hop is the library's element slot (Iterable<E>'s Arg(.., 0) / iterator().next()). Two parts, then:

  1. an edge from the return of an override of AbstractList.get / Iterator.next to the element slot of the object (the anonymous class instance), so that iterable(..)'s return carries first/second/rest as elements;
  2. the override rule above, gated.

Until then the return of a library override is a stop (gate NOLIBRARYRETURN, ws/maddi-null-libreturn, fb21cf26): tolerant by construction, which is what guava's annotations say but not a derivation.

Check against #68's exclusions: hook (an empty override is no evidence: read(o, op) handles it) and overridesAnalysed.

Found while fixing the #72 4a follow-ups (the switch fork exposed Joiner's first/second/rest as UNSAFE in the oracle).

Activity

  1. added this to the 0.9.7 milestone on Nov 2, 2026
  2. bnaudts commented on Nov 4, 2026

    @bnaudts
    Author

    Implemented in 9c250e5 (ws/maddi-null, not merged yet), both parts:

    1. Edge into the element slot. GraphCompletion.overrideReturnsIntoSlots adds an edge from an anonymous class's override of a library superclass method that returns a type parameter (AbstractList.get → E) to the anonymous this's slot for that parameter. That slot already reached what the creation is assigned to (MethodWalker.anonymousThisTarget).
      • Two more changes were needed under the Kotlin policy. Contracts.overrideContracts no longer holds such a return non-null (overridesKotlinNonNullReturn had made it a contract). And the closure lets the value into the slot as content rather than as an asserted write (NullGraph.elementHandedOut). Otherwise the result would be get(): Any? overriding AbstractList<Any>.get(): Any.
      • Gate NOOVERRIDERETURNSLOTS.
    2. The override rule. A demand in the declaring method is overruled by an analysed override that reads the parameter and demands nothing. The walk then continues in that override's parameter. Gate NOOVERRIDETOLERATES.

    A third change was needed for the walk to get through:

    • Tolerance.declaredSlot no longer ends the walk at an anonymous class's type argument or at a private method's return slot. Neither is a declaration a caller sees. Gate NOINTERNALSLOTS.

    Result (TestTolerance.toleranceThroughOverrideAndAnonymousElement, Kotlin policy):

    • join(first, second, rest): first and second are Object?, rest is Object[].
    • $1.get(int) is Object? and iterable(..) is Iterable<Object?>.
    • Without the useForNull override, first stays Object, as it does with either of the two main gates set.
    • Before this change, the Kotlin policy had first as Object: the walk stopped at the contract on get's return.

    The analyzer suite passes (683, 0 failed). Pending: a guava/langchain4j printer comparison with all three gates set.

  3. bnaudts commented on Nov 5, 2026

    @bnaudts
    Author

    Two regressions from 9c250e5, both fixed on ws/maddi-null (measured by maddi-all on laser1, lanes o/a/c, against devel 4179d4e):

    1. Cycle of delegating overrides (d5f1cfd, gate NOOVERRIDECYCLES). guava's Ordering.min(Iterable) demands its argument; ReverseOrdering and ReverseNaturalOrdering only forward it to max(Iterable), whose overrides forward it back. Every override counted as tolerating, and none reads null. Effect: 62 oracle NOISE rows; ReverseOrdering.kt and ReverseNaturalOrdering.kt out of the J2K bytecode. Now an override overrules a demand only when it compares the parameter with null, or when its own walk ends tolerant (least fixpoint).
    2. Override return slot leaking into written type arguments (6a84d39, gate NOPRIVATECREATOR). jenkins' public RingBufferLogHandler.getView(): the nullable element reached SlaveLogFetcher extends MasterToSlaveCallable<List<LogRecord>, …> and an anonymous FeedAdapter<LogRecord>, which the family does not rewrite. That gave 3 Kotlin type errors. The slot now applies only to an anonymous object created in a private method; guava's Joiner.iterable is private.
    devel 6a84d39
    guava oracle noise (ratchet 1806) 1806 1779 (63 rows gone, 1 new)
    guava J2K compiling/bytecode 608/492 608/494
    jenkins J2K compiling/bytecode/typeErrors 1267/1258/127 1269/1260/124
    langchain4j, nacos – unchanged

    Joiner.kt still prints object : AbstractList<Any?>(). Analyzer suite 686/0; one flaky run of TestStaticAndSelfFields is filed as #95.

  4. bnaudts commented on Nov 5, 2026

    @bnaudts
    Author

    Merged into devel in dd13ad9: the original fix 9c250e5 and both regression fixes, d5f1cfd and 6a84d39. The guava oracle ratchet moves to 1,779 noise in a follow-up commit.

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions