Repository navigation
Compare byte-code objects slot-wise in equal and sxhash, like GNU's PVEC_CLOSURE - #326
Conversation
eval-exec
left a comment
There was a problem hiding this comment.
Thanks—this is the right overall direction. GNU Emacs compares PVEC_CLOSURE objects slot-wise in internal_equal_1 and hashes them through the corresponding vector path in sxhash_obj.
I found two correctness issues that should be fixed before merging:
-
The equal-table key loses environment presence.
bytecode_to_equal_keymaps bothenv: Noneandenv: Some(Value::NIL)toHashKey::Nil, althoughbytecode_equaldistinguishes them.Some(nil)is reachable when a closure captures a nil lexical environment. Consequently, two byte-code objects can be unequal while being treated as the same key in anequalhash table. -
Docstrings use Rust representation equality instead of GNU string equality.
The comparison atvalue/mod.rscomparesLispStringdirectly, including its internal unibyte/multibyte representation. GNU string equality compares character count, byte count and contents—not that representation flag. I verified with GNU Emacs that an ASCII unibyte string andstring-as-multibyteequivalent are equal, and byte-code objects containing them are also equal. Neomacs currently returns unequal here.
I reproduced both problems with release-mode regression tests. The PR's existing twenty-probe GNU test passes, but it does not exercise captured environments, decoded-operation fallback, or alternate equal string representations.
Please add regression coverage for:
env: Noneversusenv: Some(nil), includingequalhash-table lookup;- unibyte versus equivalent multibyte docstrings;
- byte-code objects without retained GNU byte strings;
- constants and captured environments both participating in
sxhash.
Longer-term, I recommend defining one typed structural view of the observable closure slots and deriving normal equality, fallible equality, sxhash, and equal-table keys from it. Currently the closure schema is encoded separately in four places, which makes semantic drift likely.
The core approach is good, but these two equality/key inconsistencies are blockers for merging as-is.
There was a problem hiding this comment.
🔵 Needs a closer look
It changes core equal/sxhash/equal-hash-table semantics affecting the whole runtime, so despite verified internal consistency and strong oracle tests, a human should give final sign-off.
Pull request overview
This PR fixes byte-code (closure) objects so equal, sxhash-equal, and equal hash tables compare them slot-wise instead of by identity, mirroring how GNU Emacs treats a PVEC_CLOSURE exactly like a vector (internal_equal_1 / sxhash_obj). Previously two distinct-but-identical make-byte-code/make-closure objects were never equal and never collided in an equal hash table, which broke lsp-mode's remove-hook on the global post-command-hook (the observable failure described in the PR and follow-up to #310). The change lives entirely in the core runtime value/hashtab layer and reuses existing HashKey variants, so the pdump codec is untouched.
Changes:
- Add slot-wise byte-code comparison in
equaland its error-propagating twin (bytecode_equal/try_bytecode_equal) in GNU's slot order (arglist, bytecode string, constants + captured env, max depth, doc, interactive, extras), gating on GNU'sASIZEslot count first. - Add the matching
equal-hash-table key builder (bytecode_to_equal_key) andsxhash-equalpath (emacs_sxhash_bytecode) so equal objects share a bucket and hash alike. - Add a 20-probe oracle test pinned to GNU Emacs 32 values covering twins, differing slots,
make-closurecaptures,delete/member/gethash, andsxhash-equal.
File summaries
| File | Description |
|---|---|
crates/neovm-core/src/emacs_core/runtime/value/mod.rs |
Adds ByteCode arms to equal_value_inner, try_equal_value_inner, and to_equal_key_depth_swp, plus bytecode_equal, try_bytecode_equal, and bytecode_to_equal_key. |
crates/neovm-core/src/emacs_core/runtime/hashtab/mod.rs |
Adds emacs_sxhash_bytecode and routes VecLikeType::ByteCode through it in emacs_sxhash_obj. |
crates/neovm-core/src/emacs_core/runtime/value/tests/mod.rs |
Adds equal_compares_byte_code_functions_element_wise_like_gnu_closures GNU-oracle test. |
I reviewed all four dispatch points for mutual consistency and verified the key invariants (equal ⟹ same HashKey for hash-table correctness, and equal ⟹ same sxhash) hold — including the deliberate slot‑2 env‑vs‑constants handling in emacs_sxhash_bytecode, which preserves the hash invariant because two equal closures always share env‑presence and equal values. The try_bytecode_equal walk matches bytecode_equal exactly in order and extra_depth weights, and bytecode_to_equal_key includes every field bytecode_equal compares. I found no objective defects to comment on.
Review details
- Files reviewed: 3/3 changed files
- Comments generated: 0
- Review effort level: Balanced
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
…ne slot view Review of eval-exec#326 found two places where the four hand-written walks over a byte-code object's slots had drifted from each other: - the `equal`-table key mapped both an absent captured environment and a present `nil` one to `HashKey::Nil`, while `equal` distinguished them (`Op::MakeClosure` stores `Some(lexenv)`, and a closure over no variables has a present, nil environment that `(aref FN 2)` reports as nil rather than as the constants vector), so two unequal objects could share a bucket; - docstrings were compared with `LispString::eq`, which includes the unibyte/multibyte representation flag; GNU string equality is character count, byte count and contents, so an ASCII docstring stored unibyte equals the same text stored multibyte -- verified with GNU Emacs on two `make-byte-code` objects. And a third the added coverage exposed: with a captured environment the hash read the environment INSTEAD of the constants, so `sxhash-equal` ignored the constants entirely. Take the review's structural advice: `ByteCodeFunction::structural_slots` is now the one place that spells the schema -- GNU's slot order, with the captured environment as its own element beside the constants, `Absent` distinct from a present `nil`, the docstring as `Text` under GNU string equality, and decoded instructions standing in for a missing GNU byte string. `bytecode_equal`, `try_bytecode_equal` (through one shared walk), `emacs_sxhash_bytecode` and `bytecode_to_equal_key` each read that sequence instead of re-deriving it. Tests (red first, each asserting `equal`, `sxhash-equal` agreement and `equal`-table lookup together): absent vs captured-nil environment and differing captures; unibyte vs multibyte ASCII docstrings (equal) and raw bytes vs the one multibyte character with those bytes (unequal, hash collides as GNU's byte hashing does); functions without retained GNU bytes comparing instructions; constants and environment both moving the hash. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0117NsCB7AbwgdEqduGF5Kww
|
Both issues confirmed and fixed in the follow-up commit, and I took the structural suggestion now rather than later.
Structure: Regression coverage added (each probe asserts
All were red first. |
`equal` on two distinct byte-code objects always answered nil, and an `equal` hash table never found a closure by a rebuilt twin. GNU compares a `PVEC_CLOSURE` exactly like a vector: `internal_equal_1` (src/fns.c:2984-2998 in emacs-31.1, :2987-3001 on master) checks `ASIZE` first -- the type-and-size test, so a five-slot closure never equals a four-slot one -- and then every slot element-wise: arglist, bytecode string, constants vector, max depth, doc, interactive spec, extras. `sxhash_obj` (:5525-5536) hashes the same slots through `sxhash_vector`, so two `equal` closures share a bucket. Here the pseudovector arm of `equal_value_inner`, its error-propagating twin, `emacs_sxhash_obj`, and the `equal`-table key builder all fell through to identity for `VecLikeType::ByteCode`, while interpreted closures were already compared structurally. Add the slot walk in all four places, in GNU's slot order, over the typed fields that stand in for GNU's vector (`bytecode_equal`, `try_bytecode_equal`, `emacs_sxhash_bytecode`, `bytecode_to_equal_key`). Two cases have no GNU counterpart and compare strictly: a function with no retained GNU bytes compares its decoded instructions, and a captured `env` alist is compared in addition to the constants. The equal-table key reuses existing `HashKey` variants (a tagged `EqualVec` with the bytecode keyed byte-exact) so the pdump codec is untouched. Lisp-visible consequence, measured with the user's lsp-mode: for requests sent with `:mode 'unchanged`, `lsp--send-request-async` installs a cancel closure on the GLOBAL `post-command-hook` and later removes it by rebuilding an `equal` closure and calling `remove-hook` (`delete`). With identity-only `equal` that hook was never removed; it fired on every later command -- and with eldoc-box's mouse mode setting `track-mouse`, every mouse motion is a command -- sending `$/cancelRequest` to a workspace whose server had since been shut down, flooding "LSP :: Sending to process failed with the following error: Process nixd-lsp not running: killed: 9" once per mouse movement. Regression: `equal_compares_byte_code_functions_element_wise_like_gnu_closures` pins a twenty-probe list (`make-byte-code` twins, differing constants / depth / doc / bytecode / arglist / slot count, `make-closure` instances, closure vs. plain vector, `delete`, `member`, `sxhash-equal`, and `equal` hash-table lookup) against the values GNU Emacs 32 returns for the same forms. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0117NsCB7AbwgdEqduGF5Kww
…ne slot view Review of eval-exec#326 found two places where the four hand-written walks over a byte-code object's slots had drifted from each other: - the `equal`-table key mapped both an absent captured environment and a present `nil` one to `HashKey::Nil`, while `equal` distinguished them (`Op::MakeClosure` stores `Some(lexenv)`, and a closure over no variables has a present, nil environment that `(aref FN 2)` reports as nil rather than as the constants vector), so two unequal objects could share a bucket; - docstrings were compared with `LispString::eq`, which includes the unibyte/multibyte representation flag; GNU string equality is character count, byte count and contents, so an ASCII docstring stored unibyte equals the same text stored multibyte -- verified with GNU Emacs on two `make-byte-code` objects. And a third the added coverage exposed: with a captured environment the hash read the environment INSTEAD of the constants, so `sxhash-equal` ignored the constants entirely. Take the review's structural advice: `ByteCodeFunction::structural_slots` is now the one place that spells the schema -- GNU's slot order, with the captured environment as its own element beside the constants, `Absent` distinct from a present `nil`, the docstring as `Text` under GNU string equality, and decoded instructions standing in for a missing GNU byte string. `bytecode_equal`, `try_bytecode_equal` (through one shared walk), `emacs_sxhash_bytecode` and `bytecode_to_equal_key` each read that sequence instead of re-deriving it. Tests (red first, each asserting `equal`, `sxhash-equal` agreement and `equal`-table lookup together): absent vs captured-nil environment and differing captures; unibyte vs multibyte ASCII docstrings (equal) and raw bytes vs the one multibyte character with those bytes (unequal, hash collides as GNU's byte hashing does); functions without retained GNU bytes comparing instructions; constants and environment both moving the hash. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0117NsCB7AbwgdEqduGF5Kww
GNU Emacs compares PVEC_CLOSURE objects element-wise and requires structurally equal closures to produce the same sxhash. The PR's shared structural view established that contract, but its equal-table projection encoded an absent field as the ordinary text key #<absent>. A byte-code object with a present Lisp string of that value therefore aliased an unequal object whose environment was absent. Represent byte-code table keys as a dedicated HashKey::ByteCode containing domain-tagged ByteCodeKeyPart values. Preserve observable shape, Lisp values, retained bytes, decoded operations, constant vectors, GNU string contents, and absence as distinct enum variants, and teach the pdump conversion and object codec to round-trip that typed representation. Replace the allocating structural Vec with an ExactSizeIterator over a fixed array plus extra slots. Derive Eq and Hash for decoded Op values so sxhash no longer allocates a debug-formatted string, and keep observable closure shape in the same structural evidence stream consumed by equality, sxhash, and equal-table keys. Add a red-first regression proving absent state cannot collide with Lisp-reachable sentinel text, correct the tests to honor the one-way sxhash contract instead of demanding distinct hashes for unequal values, and add a GNU 31.0.90 differential oracle covering byte-code objects, make-closure, equal tables, and delete. Verified with release-mode neovm-core checks, seven focused release tests including pdump codec round-trip, and the live release differential oracle against the pinned GNU Emacs 31.0.90 binary.
83e06fd to
3764732
Compare
eval-exec
left a comment
There was a problem hiding this comment.
Approved after the updated implementation and the typed structural-key follow-up. The byte-code equality, sxhash, equal-table, and sequence-removal paths now share one structural contract; absence is domain-tagged rather than encoded as Lisp-reachable sentinel text; the hot equality/hash walk is allocation-free; and the behavior is covered by red-first regression tests plus a release differential oracle against GNU Emacs 31.0.90.
Summary
equalon two distinct byte-code objects always answered nil, and anequalhash table never found a closure by a rebuilt twin. GNU compares aPVEC_CLOSUREexactly like a vector:internal_equal_1(src/fns.c:2984-2998 in emacs-31.1, :2987-3001 on master) checksASIZEfirst, then every slot element-wise (arglist, bytecode string, constants, max depth, doc, interactive spec, extras), andsxhash_obj(:5525-5536) hashes the same slots throughsxhash_vector. Here the pseudovector arm ofequal_value_inner, its error-propagating twin,emacs_sxhash_obj, and theequal-table key builder all fell through to identity forVecLikeType::ByteCode, while interpreted closures were already compared structurally.This PR adds the slot walk in all four places, in GNU's slot order, over the typed fields that stand in for GNU's vector (
bytecode_equal,try_bytecode_equal,emacs_sxhash_bytecode,bytecode_to_equal_key). Two cases have no GNU counterpart and compare strictly: a function with no retained GNU bytes compares its decoded instructions, and a capturedenvalist is compared in addition to the constants. The equal-table key reuses existingHashKeyvariants (a taggedEqualVecwith the bytecode keyed byte-exact), so the pdump codec is untouched.User-visible failure
After a language server shut down cleanly (follow-up to #310), every mouse movement produced
For requests sent with
:mode 'unchanged,lsp--send-request-asyncinstalls a cancel closure on the globalpost-command-hookand later removes it by rebuilding anequalclosure and callingremove-hook(delete). With identity-onlyequalthe hook was never removed; it fired on every later command, and with eldoc-box's mouse mode settingtrack-mouseevery mouse motion is a command, so each one sent$/cancelRequestto the dead workspace.Oracle
Same probe under GNU Emacs 32 and neomacs (
-Q --batch), before and after:(t t 1 t)(nil nil 2 nil)(t t 1 t)With the user's real lsp-mode (plists) and real nixd in batch: the cancel closure count on the global hook goes 1 → 0 after one command in another buffer, and stays 0 after the server is shut down, identical to vanilla. Before the fix the closure stayed on the hook (count 1) and
remove-hookwith the factory's rebuilt closure was a no-op.Test
equal_compares_byte_code_functions_element_wise_like_gnu_closures(red first) pins a twenty-probe list against GNU's values:make-byte-codetwins; differing constants, depth, doc, bytecode, arglist, and slot count (ASIZE);make-closureinstances with equal and different captures; closure vs. plain vector;delete,member,sxhash-equal, andequalhash-table lookup.Ran on macOS (Darwin 25.6.0, arm64): the new test plus the
value::tests,hashtab,equal_including,sxhash,bytecode::tests,make_closure, andclosurefilters. One slow test (expanded_cache_replay_preserves_oclosure_define_class_registration) failed once in the parallel run and passed solo three times.🤖 Generated with Claude Code
https://claude.ai/code/session_0117NsCB7AbwgdEqduGF5Kww