[feature/igc3] weak hash table lookup can return a finalized (PVEC_FREE) object

[email protected]
Newsgroups gmane.emacs.devel
Message-ID <[email protected]>
Hi. This is about an immediate crash upon startup with igc3 and doom emacs.

What follows is a LLM-produced report and attached a suggested
fix that has been working stable for a few weeks now.

I can reproduce reliably and retrieve more debug information if needed.
Given the report and fix size I hope this is helpful.

----------------

Branch feature/igc3, commit 07b35c82b7d ("Move 'read_commit_limit' to
sysdep.c"), x86_64-pc-linux-gnu, --with-mps.

A lookup in a weak hash table can return an object that the collector
has already finalized and splatted into a PVEC_FREE.  Lisp then sees a
"function" that is not a function, and printing the resulting error
aborts Emacs.

Reproducer, "emacs -Q --batch -l repro.el", no init file involved:

    ;; -*- lexical-binding: t -*-
    (defvar h (make-hash-table :test #'equal :weakness 'value))
    (defun add (i) (puthash (list 'k i) (lambda () i) h))
    (dotimes (i 100) (add i))     ; values reachable only from H
    (igc--collect)                ; values die
    (igc--process-messages)       ; finalization splats them
    (princ (format "%s\n" (gethash (list 'k 0) h)))

This aborts in print_vectorlike_unreadable.  Without the final line,

    (let ((v (gethash (list 'k 0) h)))
      (princ (format "non-nil: %S  functionp: %S\n" (and v t) (functionp v)))
      (condition-case e (funcall v)
        (error (princ (format "funcall signalled: %S\n" (car e))))))

prints

    non-nil: t  functionp: nil
    funcall signalled: invalid-function

The sequence is:

1. weak_hash_put calls Figc__add_extra_dependency on the weakly held
   side of the entry (src/fns.c:5900-5903).  That goes through
   igc_external_header (src/igc.c:5763), which gives the object an
   external header and registers it with mps_finalize.

2. The value becomes unreachable.  MPS posts a finalization message.
   The weak reference in the table is *not* splatted in that trace: the
   final ring keeps the object reachable at a rank stronger than weak,
   so the entry survives the collection intact.

3. Emacs processes the message.  finalize (src/igc.c) restores the
   header from the external header and calls finalize_vector, which ends
   in splat_pvec (src/igc.c:4438) - the object's pseudovector tag is
   overwritten with PVEC_FREE, in place, while the table entry still
   points at it.

4. The entry is only removed one cycle later, when
   fix_weak_hash_table_weak_part (src/igc.c:3011) sees the weak
   reference go nil and calls weak_hash_splat_from_table.

So there is a window of at least a full GC cycle in which a splatted
object is still present in the table and still matches a lookup.  This
is not a race; in the reproducer it is deterministic.

DOHASH_WEAK (src/lisp.h:3066) already skips entries whose key or value
is PVEC_FREE, for exactly this reason.  The lookup path has no such
check: weak_hash_lookup_with_hash (src/fns.c:5863) matches on the key
alone and Fgethash (src/fns.c:6666) returns WEAK_HASH_VALUE
unconditionally.

How I ran into it: Emacs crashed about a second into starting a Doom
Emacs configuration, every time.  The victim was a combined method in
cl--generic-combined-method-memoization, which is

    (make-hash-table :test #'equal :weakness 'value)

Two of its values are splatted on every startup (the cl-generic-
generalizers methods for EIEIO defclass and for cl-deftype derived
types).  gethash returned one of them, funcall signalled
(invalid-function #<splatted closure>), and the error handler formatted
the error with %s, which reached print_vectorlike_unreadable and
emacs_abort.  In the core dump the table's kv.values vector still holds
two PVEC_FREE objects, next to a live key
(#s(cl--generic cl-generic-generalizers ...) . METHODS).

Proposed fix, attached as igc-weak-hash-fix.diff: skip dead entries in
the
lookup, the way DOHASH_WEAK already does.

With this, both reproducers above are clean (gethash reports a miss) and
the Doom configuration starts normally.  I instrumented the two skips
before removing the counters: exactly two lookups per startup matched a
key whose value had been splatted, reproducibly, on every start.

Two further points, which I have not tried to fix:

- finalize (src/igc.c) only dispatches to finalize_vector; everything
  else falls through "default: break".  So the PVEC_FREE marker exists
  only for pseudovectors.  A weak table whose weakly held side is a
  cons, string or symbol goes through the same window with no marker at
  all, and neither DOHASH_WEAK nor the check above can detect it - the
  lookup will hand back an object that should have been evicted.

- It may be worth asking whether the entry can be removed at the same
  time as the splat, rather than a cycle later, which would close the
  window instead of papering over it.

The attachment also contains a second, optional hunk: making
print_vectorlike_unreadable print "#<dead object>" for PVEC_FREE under
HAVE_MPS instead of calling emacs_abort, so that this class of bug does
not turn a Lisp-level error into a crash while the error is being
printed.

-----------------

In GNU Emacs 32.0.50 (build 2, x86_64-pc-linux-gnu, GTK+ Version
 3.24.52, cairo version 1.18.4) of 2026-08-28 built on amn
Repository revision: 07b35c82b7dfc9d0c044dc54dcc65675f49de87e
Repository branch: feature/igc3
System Description: Arch Linux

Configured using:
 'configure NATIVE_FULL_AOT=1 'CFLAGS=-O2 -g -fno-omit-frame-pointer
 -march=native' --prefix=/home/user/.local --with-native-compilation=aot
 --with-pgtk=yes --with-tree-sitter --with-small-ja-dic --with-mps
 --with-webkit'

Configured features:
ACL CAIRO DBUS FREETYPE GIF GLIB GMP GNUTLS GPM GSETTINGS HARFBUZZ JPEG
LCMS2 LIBOTF LIBSYSTEMD LIBXML2 MODULES MPS NATIVE_COMP NOTIFY INOTIFY
PDUMPER PGTK PNG RSVG SECCOMP SOUND SQLITE3 THREADS TIFF
TOOLKIT_SCROLL_BARS TREE_SITTER WEBP XIM GTK3 ZLIB

Important settings:
  value of $LC_ALL: en_US.UTF-8
  value of $LC_TIME: pt_BR.UTF-8
  value of $LANG: en_US.UTF-8
  locale-coding-system: utf-8-unix

-----------------
igc-weak-hash-fix.diff (text/x-patch, 2.2 KB)
diff --git a/src/fns.c b/src/fns.c
index 580d10907ab..77030bd8cab 100644
--- a/src/fns.c
+++ b/src/fns.c
@@ -5867,12 +5867,31 @@ weak_hash_lookup_with_hash (struct Lisp_Weak_Hash_Table *h,
   ptrdiff_t start_of_bucket = weak_hash_index_index (h, hash);
   for (ptrdiff_t i = WEAK_HASH_INDEX (h, start_of_bucket);
        0 <= i; i = WEAK_HASH_NEXT (h, i))
-    if (EQ (key, WEAK_HASH_KEY (h, i))
-	|| (h->strong->h.test->cmpfn
-	    && hash == WEAK_HASH_HASH (h, i)
-	    && !NILP (h->strong->h.test->cmpfn (key, WEAK_HASH_KEY (h, i),
-					      NULL /* XXX */))))
+    {
+      Lisp_Object k = WEAK_HASH_KEY (h, i);
+
+      /* An entry whose key or value has already been finalized and
+	 splatted into a PVEC_FREE is dead, it merely hasn't been
+	 removed from the table yet.  Never let such an object escape
+	 into Lisp; DOHASH_WEAK skips those entries for the same
+	 reason.  Comparing against a splatted key is not safe either.  */
+      if (PSEUDOVECTORP (k, PVEC_FREE))
+	continue;
+
+      if (!(EQ (key, k)
+	    || (h->strong->h.test->cmpfn
+		&& hash == WEAK_HASH_HASH (h, i)
+		&& !NILP (h->strong->h.test->cmpfn (key, k,
+						    NULL /* XXX */)))))
+	continue;
+
+      /* The key matched, but the value is already dead.  Report a miss
+	 rather than handing the dead object out.  */
+      if (PSEUDOVECTORP (WEAK_HASH_VALUE (h, i), PVEC_FREE))
+	continue;
+
       return i;
+    }
 
   return -1;
 }
diff --git a/src/print.c b/src/print.c
index 62359787960..f2e831224f2 100644
--- a/src/print.c
+++ b/src/print.c
@@ -2186,8 +2186,20 @@ print_vectorlike_unreadable (Lisp_Object obj, bool escapeflag, char *buf,
 #endif
     case PVEC_BIGNUM:
     case PVEC_BOOL_VECTOR:
-    /* Impossible cases.  */
+      break;
+
+#ifdef HAVE_MPS
+    /* A finalized object that was splatted by the GC.  It should never
+       become visible to Lisp, but if it does, aborting while printing
+       the error that reports it makes the bug much harder to find.  */
+    case PVEC_FREE:
+      print_c_string ("#<dead object>", printcharfun);
+      return;
+#else
     case PVEC_FREE:
+#endif
+
+    /* Impossible cases.  */
     case PVEC_OTHER:
     case PVEC_MODULE_GLOBAL_REFERENCE:
       break;
lmpx.com only provides a reader for public news (NNTP) servers. It is not affiliated with the servers or forums shown here and is not responsible for the content of articles, which is written by their respective authors.