Skip to content

Reading a literal-key property before its first write makes later reads return undefined #9024

Closed

Description

@proggeramlug

Reading a property with a literal key before its first write, then writing it, makes every later read return undefined — a silent wrong value, no error, no crash.

Reproducer

const c: any = {};
const b = c["k"];   // read before the key exists
c["k"] = 7;
console.log(c["k"]);   // node: 7      perry: undefined

Object.keys(c) and JSON.stringify(c) both show the property IS there (neverWritten, {"neverWritten":7}), so the value is stored correctly and only the read is wrong.

Characterization

variant node perry
c["k"] read-before-write 7 undefined
c.k read-before-write (static syntax) 7 undefined
read twice after the write 7 7 undefined undefined
non-empty receiver ({ z: 0 }) first 7 undefined
no read before the write 7 7 ✅
variable key (const n = "k"; c[n]) 7 7 ✅

The discriminator is the literal key plus a preceding absent-read. A variable key is fine, which points at a compile-time absence proof being established by the first read and reused after the write that adds the property — rather than a runtime cache.

Reproduces identically with PERRY_NO_AUTO_OPTIMIZE=1 and with the default auto-optimize build.

Pre-existing, on main

Found while auditing #9021 (which reworks computed reads), so I A/B'd before attributing it. Both builds are --profile perry-dev of -p perry -p perry-runtime-static -p perry-stdlib-static:

build result
#9021 branch undefined
main @ 40f63c96b3 undefined

main and the #9021 branch produce byte-identical output across my whole 7-case probe, so #9021 neither causes nor worsens this. Recording that explicitly because the bug sits squarely in the area that PR touches and would be easy to pin on it.

Why it matters

This is an ordinary shape — probe a field, then populate it:

if (!cache["key"]) { cache["key"] = compute(); }
return cache["key"];   // undefined

Unlike #9019 (a segfault on a patched .next()), this fails silently and returns a wrong value, which makes it the more dangerous of the two. It is not caught by the gap suite today.

Node used is the pinned oracle, v26.5.1.

Activity

  1. added a commit that references this issue on Aug 29, 2026
    f13e1dd
  2. proggeramlug commented on Aug 29, 2026

    @proggeramlug
    ContributorAuthor

    No longer reproduces on current main: the exact reproducer and the full 7-case probe (bracket/dot syntax, non-empty receiver, double read, the if (!cache[k]) cache[k] = ... pattern) now print node-identical output — verified on the tip with a clean perry-base build on the Linux box AND a local build, both flag states.

    The fix came in with #9028 (escape a scalar-replaced object when an undeclared field is written), which matches the mechanism this issue's characterization table pointed at: the literal-key read-before-write established a compile-time absence proof on a scalar-replacement candidate, and the later undeclared-field write went to storage the proven-absent read path never consulted. A variable key defeated the proof, which is why that variant always worked.

    Bisect-grade confirmation: the issue's SHA 40f63c96b3 (= #9020) still shows undefined; the current tip (with #9028) prints 7. Closing.

  3. added 2 commits that reference this issue on Sep 19, 2026
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

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions