Tcl Source Code

View Ticket
Login
Ticket UUID: 0c82728f85760f36361fe42a15f55453c94d285d
Title: lappend/linsert ignore the abstract-list Replace hook on some code paths
Type: Bug Created on: 2026-10-13 22:02:13
Submitter: rocketship98 Assigned to: apnadkarni
Subsystem: 14. List Object Severity: Minor
Priority: 5 Medium Last modified: 2026-11-04 14:58:54
Status: Closed Closed by: oehhar
Resolution: Fixed Closed on: 2026-11-04 14:58:54
Version: 9.1.1
Description:

Summary

An abstract list whose type provides a replaceProc can lose its type when elements are appended. In this report:

  • What happens: "append at the end" takes one of two paths in the core.
    • Tcl_ListObjReplace: the type is kept and the Replace hook is called.
    • Tcl_ListObjAppendElement / TclListObjAppendElements: the object is converted in place to an ordinary list, and the hook is never consulted.
  • What decides the path: only how the command is evaluated.
    • linsert of one element at the end: kept if bytecode-compiled, lost if not compiled.
    • lappend: lost when compiled inside a proc (compiled local variable), kept at global or namespace level.
  • Effect: the resulting list is always correct. Only the representation differs, and with it memory use and speed.
  • Reproduction: stock Tcl only, using the core's own lstring test type in the tcltest shell (below).
  • Open question: I do not know whether this is intended. doc/ObjectType.3 documents ReplaceProc only as corresponding to Tcl_ListObjReplace and says nothing about append, so I am reporting it as an inconsistency to be judged, not as a certain bug.

Reproduction (no extension needed)

The core test build has an abstract list with a Replace hook, lstring (generic/tclTestABSList.c, used by tests/abstractlist.test). It exists only in the tcltest shell (make tcltest); in an ordinary tclsh or wish the script fails with invalid command name "lstring".

# Reproduction with stock Tcl only: needs a tcltest shell (test commands built in), which provides [lstring].
# [lstring] makes an abstract list that HAS a Replace hook.   Usage: tcltest lstring_repro.tcl
proc type {v} { lindex [tcl::unsupported::representation $v] 3 }
proc mk {} { lstring abcdef }
proc compiled {} {
    set b [mk]; set c [linsert $b end z];  puts "linsert end 1 elem  compiled (INST_LREPLACE)     : [type $c]"
    set b [mk]; lappend b z;               puts "lappend 1 elem       compiled in a proc           : [type $b]"
    set b [mk]; lappend b y z;             puts "lappend 2 elems      compiled in a proc           : [type $b]"
}
proc interpreted {} {   ;# command name in a variable => not compiled => the Tcl_*ObjCmd functions run directly
    set li linsert; set la lappend
    set b [mk]; set c [$li $b end z];      puts "linsert end 1 elem   NOT compiled (Tcl_LinsertObjCmd): [type $c]"
    set b [mk]; set c [$li $b end y z];    puts "linsert end 2 elems  NOT compiled                  : [type $c]"
    set b [mk]; set c [$li $b 2 z];        puts "linsert middle       NOT compiled                  : [type $c]"
    set b [mk]; $la b z;                   puts "lappend 1 elem       NOT compiled (Tcl_LappendObjCmd): [type $b]"
}
compiled; interpreted

Run as ./tcltest lstring_repro.tcl. Output:

linsert end 1 elem  compiled (INST_LREPLACE)     : lstring
lappend 1 elem       compiled in a proc           : list
lappend 2 elems      compiled in a proc           : list
linsert end 1 elem   NOT compiled (Tcl_LinsertObjCmd): list
linsert end 2 elems  NOT compiled                  : lstring
linsert middle       NOT compiled                  : lstring
lappend 1 elem       NOT compiled (Tcl_LappendObjCmd): lstring

Which core code does what

Form, then path taken in the core, then the type afterwards:

kept  linsert $l end X, compiled
      -> INST_LREPLACE -> Tcl_ListObjReplace
LOST  linsert $l end X, NOT compiled (dynamic command name, {*} command word, ...)
      -> Tcl_LinsertObjCmd, special case "insert one element at the end" -> Tcl_ListObjAppendElement
kept  linsert with 2+ elements, or not at the end
      -> Tcl_ListObjReplace
LOST  lappend v X, compiled in a proc (compiled local), 1 element
      -> lappendScalar -> ListAppendInVar -> Tcl_ListObjAppendElement
LOST  lappend v X Y, compiled in a proc
      -> lappendList -> lappendListDirect -> TclListObjAppendElements
kept  lappend compiled at global/namespace level (source, eval, uplevel)
      -> lappendListStk -> Tcl_ListObjReplace
kept  lappend, NOT compiled
      -> Tcl_LappendObjCmd

Both paths marked "lost" end in TclListObjGetRep -> SetListFromAny (generic/tclListObj.c), which converts the object to a real list in place. Tcl_ListObjReplace instead calls the type's Replace hook (TclObjTypeHasProc(listObj, replaceProc)).

Side observation that made this look random at first: the main script given to tclsh is, from my reading of Tcl_FSEvalFileEx (TclEvalEx), evaluated directly and not compiled, while a sourced file and procs are compiled, so the same line of code behaves differently at the top of the main script than inside a proc. I did not confirm that in a debugger.

Why it matters

An extension that provides a compact abstract list (I found this with a packed bit-array type that stores 1 bit per element) loses its representation silently on a single lappend. For a ten-million-element array that turns a few MB into an ordinary list (I measured +76 to 152 MB for {*}$a expansion of such an array; I did not measure the cost of the in-place conversion itself).

lseq (arithseries) cannot show this, because it has no Replace hook and appending to an arithmetic sequence has to leave the type anyway; that is why the reproduction uses lstring.

Test coverage

tests/abstractlist.test already checks type preservation, so this is not an untested area:

  • It has many "no shimmer ..." tests, and some that assert the opposite on purpose ("shimmer lassign", "shimmer lset", "!no shimmer lreplace").
  • Its linsert tests (abstractlist-2.7, 3.7 and the 4.7 variant) insert several elements in the middle of an lstring (positions 11, 35 and 12). That goes through Tcl_ListObjReplace, and the tests confirm the type is kept.

What I found no test for (lstring appears in no other test file):

  • lappend onto an lstring.
  • linsert of a single element at the end.
  • The same operation run both compiled and not compiled.

Those would be natural places for a regression test, whichever way this is resolved.

Possible directions (for discussion only)

  • Have TclListObjAppendElements (and so Tcl_ListObjAppendElement) use the Replace hook when the object has one, the way Tcl_ListObjReplace does; or
  • document that append ignores the hook, so extension authors know to expect the conversion.

Environment

  • Clean checkout of github.com/tcltk/tcl at e36db3376a2d7d4857f6c766bd6133fc7b29e821 (2026-10-02, "Update to the 'official' utf8proc 2.12.0"), git status clean.
  • Linux x86-64, gcc, ./configure --disable-shared && make tcltest.
  • The same results were also seen with an extension on an earlier trunk (3d77be8951).
  • Independently reproduced by the reporter on a second Linux machine (a virtual machine), with a stock tree built from a Fossil check-in whose tcl::build-info reads 9.1.1+4991a47b94705ddff07bd81cab83291e5f6d219a8a75b54a32c954e73b05eaae.gcc-1303.tommath-0103 (shared build, gcc); ./tcltest lstring_repro.tcl printed the same seven lines. The reporter built that tree from a stock, unmodified checkout fetched a day or two before 2026-10-13. It is a Fossil id, so it is not directly comparable with the git commit above.
  • Not tested: other platforms, Tcl 9.0.x (the version-2 abstract-list hooks are 9.1 only), other compilers.

Note on how this was produced

The analysis and the reproduction script were produced with Claude (an AI assistant made by Anthropic) working with me; the results above are from runs of the script, and the source references are from reading the files named.

User Comments:
rocketship98 added on 2026-10-16 23:17:03:
NOTE: this is a claude fix, I can't be certain this is correct, sorry but he has built and then tested this with and without the patch. This is not something I understand, but the fix is small and hopefully someone will benefit from this and the tests. Where it says I, it means Claude


Follow-up with a tested fix: a patch (9 lines added, 2 removed) and six tests, both below. Against trunk of 2026-10-15, commit 102a512.

Compiled lappend in a proc turns an abstract-list value into an ordinary list in every form I tried, on 9.1.1 and on trunk:

    lappend v x
    lappend v {*}$list
    lappend v {*}{a b}
    lappend A(k) x
    lappend A(k) {*}$list
    lappend $name x

The causes are two calls that convert the object in place and never reach the type's Replace hook:

* ListAppendInVar in tclVar.c calls Tcl_ListObjAppendElement. This handles lappend v x, array elements and lappend $name x.
* lappendListDirect in tclExecute.c calls TclListObjAppendElements. This handles lappend v {*}list, including array elements.

The patch changes both to Tcl_ListObjReplace (insert at the end). The branch of INST_LAPPEND_LIST that already calls Tcl_ListObjReplace keeps the type, as do uncompiled lappend and lappend at the top level of a script.

Checked, with the test type "lstring" from tclTestABSList.c (it has a Replace hook):
* The six tests below fail on unpatched trunk (the value becomes a "list") and pass with the patch.
* With the patch, abstractlist.test, listTypes.test, list.test, listObj.test and listRep.test: 11526 tests, 0 failed.

Not checked: whether any caller relies on the type being lost, whether the extra length lookup costs anything measurable for plain lists, and the other callers of TclListObjAppendElements.

Patch (apply with patch -p1):

--- a/generic/tclExecute.c
+++ b/generic/tclExecute.c
@@ -3624,7 +3624,7 @@ TEBCresume(
 	    varPtr->value.objPtr = objResultPtr = newValue;
 	    Tcl_IncrRefCount(newValue);
 	}
-	if (TclListObjAppendElements(interp, objResultPtr, objc, objv)
+	if (Tcl_ListObjReplace(interp, objResultPtr, len, 0, objc, objv)
 		!= TCL_OK) {
 	    TRACE_ERROR(interp);
 	    goto gotError;
--- a/generic/tclVar.c
+++ b/generic/tclVar.c
@@ -1872,7 +1872,14 @@ ListAppendInVar(
 	Tcl_IncrRefCount(oldValuePtr);	/* Since var is referenced. */
     }
 
-    return Tcl_ListObjAppendElement(interp, oldValuePtr, newValuePtr);
+    {
+	Tcl_Size len;
+
+	if (TclListObjLength(interp, oldValuePtr, &len) != TCL_OK) {
+	    return TCL_ERROR;
+	}
+	return Tcl_ListObjReplace(interp, oldValuePtr, len, 0, 1, &newValuePtr);
+    }
 }
 
 static inline void

Tests (they could go at the end of tests/abstractlist.test; they use its constraints testobj and lstring):

# lappend must keep an abstract list's type (the type has a Replace hook).  The tests use apply so the
# lappend is compiled inside a proc body, which is where the type used to be lost.
test abstractlist-lappend-1.1 {lappend of one element keeps the abstract list type} -constraints {testobj lstring} -body {
    apply {{} {set l [lstring abc]; lappend l d; list [testobj objtype $l] $l}}
} -result {lstring {a b c d}}
test abstractlist-lappend-1.2 {lappend {*}$list keeps the abstract list type} -constraints {testobj lstring} -body {
    apply {{} {set l [lstring abc]; set x {d e}; lappend l {*}$x; list [testobj objtype $l] $l}}
} -result {lstring {a b c d e}}
test abstractlist-lappend-1.3 {lappend {*}{literal list} keeps the abstract list type} -constraints {testobj lstring} -body {
    apply {{} {set l [lstring abc]; lappend l {*}{d e}; list [testobj objtype $l] $l}}
} -result {lstring {a b c d e}}
test abstractlist-lappend-1.4 {lappend to an array element keeps the abstract list type} -constraints {testobj lstring} -body {
    apply {{} {set A(x) [lstring abc]; lappend A(x) d; list [testobj objtype $A(x)] $A(x)}}
} -result {lstring {a b c d}}
test abstractlist-lappend-1.5 {lappend {*}$list to an array element keeps the abstract list type} -constraints {testobj lstring} -body {
    apply {{} {set A(x) [lstring abc]; set x {d e}; lappend A(x) {*}$x; list [testobj objtype $A(x)] $A(x)}}
} -result {lstring {a b c d e}}
test abstractlist-lappend-1.6 {lappend $name keeps the abstract list type} -constraints {testobj lstring} -body {
    apply {{} {set l [lstring abc]; set n l; lappend $n d; list [testobj objtype $l] $l}}
} -result {lstring {a b c d}}

apnadkarni added on 2026-10-20 09:53:57:

Considering other C list functions have been changed to account for abstract list hooks, I can only surmise this was an oversight. Will take a look.

It's a bit curious that there is no equivalent abstract list method for "lappend". The lreplace is a superset but I can imagine abstract list implementations where it's overhead is non trivial when compared to a lappend hook.


rocketship98 added on 2026-10-20 21:57:57:
Thanks for looking at this, Ashok.

Some context on who is hitting it. The bit-array type I mentioned is a real extension that implements all eight abstract-list hooks of TCL_OBJTYPE_V2 (length, index, slice, reverse, getElements, setElement, replace and the in/ni operator). lappend, and linsert in the uncompiled case, are the only commands I have found where the core converts the value to an ordinary list itself instead of calling one of the hooks.

On your point that there is no lappend hook: the Replace hook is indeed enough for us, since an insert at the end is cheap for this type, so routing lappend through it (as in the patch above) is all we need. I can see types where a dedicated hook would be faster, and if one is added we would be happy to implement it as well. Note that the patch I posted only covers the lappend paths; the uncompiled single-element linsert at the end goes through Tcl_LinsertObjCmd, which I have not patched, so that case still converts.

An update on the "not tested" note in the ticket: I have since tried Tcl 9.0.4, and it differs a little from 9.1. A plain lappend of one element keeps the type at the top level of a script but loses it under eval and uplevel (9.1 keeps it there) as well as in a proc; lappend {*}$list keeps the type everywhere on 9.0.4, including in a proc, where 9.1 loses it. If a fix goes in, it may be worth backporting to 9.0.x.

rocketship98 added on 2026-10-20 22:14:25:
one quick note, having claude do builds (binary search he says) between 9.0.4 and 9.1 to pinpoint when the behavior changed.

rocketship98 added on 2026-10-21 00:01:55:
While looking at why linsert behaves differently from lappend for us, I believe I found the cause of the linsert case (claude is more certain, and he's the I below in this comment).

In Tcl_LinsertObjCmd (tclCmdIL.c) there is a special case for inserting exactly one element at the end of the list:

    if ((objc == 4) && (index == len)) {
        result = Tcl_ListObjAppendElement(NULL, listPtr, objv[3]);

Tcl_ListObjAppendElement does not call the Replace hook of an abstract list, so the value is converted to an ordinary list. Every other form of linsert (two or more elements, middle, start) calls Tcl_ListObjReplace, which does call the hook, and keeps the type. The code is the same in 9.0.4 and 9.1.1, apart from the argument declarations.

Changing that one call to the following made all the linsert forms I tried keep the type, on both a 9.0.4 and a 9.1.1 build (compiled at top level, command name in a variable, via eval, in a proc). The core's cmdIL, listObj, list and linsert test files also pass with it (324 tests, 0 failed); I have not run the rest of the core test suite:

    result = Tcl_ListObjReplace(NULL, listPtr, len, 0, 1, &objv[3]);

Separately, for lappend: I believe the places where 9.1 already keeps the type (eval, uplevel #0) are where lappendListPtr in tclExecute.c calls Tcl_ListObjReplace instead of TclListObjAppendElements, which 9.0.4 still does:

    9.0.4:  TclListObjAppendElements(interp, valueToAssign, objc, objv)
    9.1:    Tcl_ListObjReplace(interp, valueToAssign, len, 0, objc, objv)

Changing just that call in a 9.0.4 build gave the 9.1.1 results for those two cases. I think the change may come from the fix for INST_LAPPEND_LIST with zero-length lists (dkf, trunk, May 2025), but I could not confirm that from the repository history. The case that still loses the type on both versions is a plain lappend inside a proc, which is what this ticket covers.

apnadkarni added on 2026-10-22 13:17:28:

It appears as though the public list related functions (Tcl_Xxx) have afaict been all changed to check for abstract list methods. However, some internal functions / macros (TclXxx) have been modified similarly but others have not. The unmodified ones depend on the caller to make the check. Not sure whether that is by intent or oversight.

Seems to me it would make sense to make it consistent throughout.

Started on branch bug-0c82728f85 to make it so.


apnadkarni added on 2026-10-23 06:47:09:

Just a note that makes me a bit wary of using lstring for testing after inspecting the source. Its implementation breaks EIAS. Consider

% set l1 [lstring abc]
a b c
% set l2 [list a b c]
a b c
% lappend l1 xyz
a b c x
% lappend l2 xyz
a b c xyz

That's a no-no.

Additionally, it's implementation of replaceProc is incomplete in that it does not handle multiple indices. It may suffice for very specific test cases like appending single characters but still caution is advisable.

We don't seem to have any other test harness object that implements the entire abstract list API.


oehhar added on 2026-10-23 07:32:25:

Thanks Ashok!

What is lstring? Is is an abstract list, which presents a string as a list where each character is a list element?

Then, you can not add "xyz", as it violates this abstract list. Ok, I understnd the example.

There are two possible solutions:

  • error out
  • transform to tradional list

Take care, Harald


apnadkarni added on 2026-10-23 09:31:30:
Harald,

lstring is only part of the test suite used to test abstract lists.

Erroring out is not an option IMO as a command must treat two string representations ("a b c" in both cases) exactly the same. It cannot accept one and not the other. For something like lstring, only alternative is the second solution you proposed.

I did not mean to imply lstring needs to be fixed, it's only used in tests. Rather, my point was more that it cannot be used to test all code paths to test for shimmering of abstract lists.

oehhar added on 2026-10-23 10:17:54:

Thanks for the explanation, I appreciate! Take care, Harald


apnadkarni added on 2026-10-27 05:31:01:

Branch bug-0c82728f85 has been merged into 9.0 to pick up committed and verified fixes.

Please don't close branch bug-0c82728f85. The abstract list test object lstring implementation test suite is missing tests which result in crashes within lstring itself preventing the missing tests from being added. These are still being worked on.

9.1/trunk still pending because of significant differences in both the bytecode engine and list implementation between 9.0 and 9.1. I don't think a direct merge via cherrypick is feasible and needs a separate implementation being worked on in branch bug-0c82728f85-91.


apnadkarni added on 2026-10-29 11:56:06:
Fixed for 9.1 in [54a03368ce] and 9.0 in [d27dbcd1b3].

apnadkarni added on 2026-10-29 12:01:25:
Just a note: not all shimmers have been eliminated. In particular, nested indices and the lset implementation not tackled. Partly because not worth fixing in practice imo and partly because of complexity. lset for instance works on an unshared value while the corresponding abstract list method returns a new object. (Shimmer will occur with the C API, not with the script level command.)

oehhar added on 2026-10-29 12:21:45:

Great work, thank you!

Perhaps the documentation of lseq may talk a bit about conversion to normal list. I suppose, that is always the case if a list element is written (lset).

Thanks for all, Harald


apnadkarni added on 2026-10-30 05:11:54:

Perhaps the documentation of lseq may talk a bit about conversion to normal list.

I don't think we ever document internal shimmering details and imo nor should we. As far as scripts are concerned, a list is a list is a list. The internal implementation should not matter. Note the same is also true for string which can internally shimmer into various forms.


oehhar added on 2026-11-04 14:58:54:

Or a document on the tcl core wiki?

I see the next command (concat) being handled...

Thanks for all, Harald