--emit-obj retains __pxxAssert, so one AnsiString in it imports the whole ESP PAL
What breaks
test-emit-obj#src:test/test_emit_obj.pas@3 — the xtensa link step. The recipe
links the emitted object against newlib and a three-symbol C shim
(ext_notify, ext_aliased_link, main), deliberately with no ESP-IDF:
its own comment says these checks "need NO qemu and NO ESP-IDF". The object now
imports symbols only ESP-IDF provides, so ld fails with 25 undefined
references.
Measured
Reproduces at master (cd2d0f3ca, compiler/pascal26 = 0a20bae79296,
converged after 2 round(s)):
| object | UND symbols | PalBackend syms |
|---|---|---|
| riscv32 | 2 — exactly ext_notify + ext_aliased_link |
114 |
| xtensa | 35 — adds 18 lwip_*, vTaskDelay, esp_timer_get_time, rmdir |
114 |
riscv32 is not clean because it imports less — it carries the same 114 PAL symbols. It selects the POSIX backend, which defines those calls in-object (syscalls), where the ESP backend imports them from lwIP/FreeRTOS. The failure therefore looks target-specific and is backend-specific.
Bisected
Archive spans 2026-07-07 → 09-05, 1906 reports. This job is red in 7, all on
09-05; the recipe is unchanged across the window (0 diff lines naming
test_emit_obj), so the @3 key is stable and its earlier absence means "not
red" rather than "different key" — worth checking, because
[[bug-t-the-job-map-cannot-be-asked-whether-a-given-source-was-exercised]] fakes
exactly this shape.
Endpoints measured, not inferred (git bisect never tests its own):
b8e3b3010 riscv32_link=ok xtensa_link=ok xt_lwip_und=0
5b5fdb0b3 riscv32_link=ok xtensa_link=fail xt_lwip_und=18
The xtensa link RAN and PASSED at the good endpoint, which rules out the
tempting reading that this is coverage arriving because make used to abort at
the riscv32 link — the recipe's own comment says it once did, and that is not
what happened here.
Bisect over 158 commits, 7 probes, no skips, monotonic:
674bc0a1e 18 · 502f273d1 18 · d8afd1979 18 · f0a1a8be9 18 <- first bad
49194d2ab 0 · 7701b8d40 0 · a5b77e3b4 0
Confirmed at the commit's own boundary: f0a1a8be9~1 link ok / lwip=0,
f0a1a8be9 link fail / lwip=18.
Narrowed inside the commit
Run at f0a1a8be9 so no later change confounds it. Reverting builtin.pas
alone does not build — the parser injects a third argument — so the splits go
the other way:
| control | lwip_und |
|---|---|
| baseline | 18 |
A — pasparser_stmt.inc reverted (position composition at Assert sites) |
18 |
B — __pxxAssert BODY reverted, 3-arg signature kept |
0 |
So it is the body, not the position machinery: the new var text: AnsiString
plus text := text + pos. An earlier control that swapped only the
writeln(text, '.') line came back 18 and looked exonerating — it left the
concatenation in place and could not have isolated anything. A control that
leaves half the suspect standing cannot clear it.
Why this is nobody's gate failure, and why f0a1a8be9 should not be reverted
That commit is right: it made six assertion rows byte-identical to fpc 3.2.2,
having measured FPC rather than trusting the ticket's sketch, and it gated
properly — converged, quick GREEN, changed test-core rows run standalone,
plus NilPy and C probes against the signature change.
Nothing available to it could have shown this. The self-host fixedpoint never
targets ESP; gate.sh quick never links xtensa; on x86-64 every one of those
symbols resolves from libc without comment. This is only observable where
there is no OS to resolve them, which is the same structural blind spot as
bug-a-set-membership-32-bit-backends-truncate-the-set-constant and the
method-pointer width class: the dev loop, quick and the pin all run on the one
host that cannot see it.
Reverting would trade FPC parity for a link. Reshaping __pxxAssert to dodge
the string path is a compiler-appeasement workaround and is out.
The actual defect is the pairing
Two facts, each harmless alone:
-
Nothing is pruned on xtensa at all, so REACHABILITY is the only filter.
test_emit_objcarries 114PalBackendsymbols;test_esp_hellocarries 0; neither program contains anAssert.CORRECTION, 2026-09-06 — my first wording here said object output "keeps
__pxxAssertwhere a normal build drops it", and that is wrong. frankA measured the gate:dce.inc's first test isif TargetArch <> TARGET_X86_64 then why := 'target is not x86-64', because the pass only knows how to re-patch x86-64's rel32 call/jmp. Asked directly on this exact shape:--target=xtensa --emit-obj --dce-report -> dce: off: target is not x86-64 --emit-obj --dce-report -> dce: bodies 130 live 44 dead 85and it is not an
--emit-objproperty — plain executables behave the same (i386 106348B, riscv32 261996B, byte-identical with and without--dce). So the retention is not a mechanism holding these symbols; it is the absence of any pruning on every target but one.test_esp_hellocarries no PAL because it never REACHES it, not because anything dropped it. Mycode=336324Bon both sides is this, exactly. -
__pxxAssertacquired a PAL-reaching dependency. Its string path reaches the PAL's file I/O, and on ESP that is the same translation unit as the sockets — so file I/O dragslwip_socket.
Fixing either breaks the pairing. Candidates, none of them mine to choose:
- (A) make DCE work on xtensa — the general fix, and it shrinks every object on every non-x86-64 target. Now known to be much bigger than it looked: the prior question is whether the pass can re-patch xtensa's call shapes at all, which is a larger piece of work than the COMDAT option that ticket was weighing. COMDAT would not have freed this either way.
- (S/B) split the ESP
platform_backendso file I/O does not drag the socket surface — narrower, and useful independently.
Two measurements for [[feature-a-every-emit-obj-object-links-its-own-full-copy-of-crtl-so-n-objects-cost-n-runtimes]]
That ticket (A p55, HELD by frankA — not claimed here) owns option (A), its step (3). Two facts from this object refine its model rather than repeat it, and both were measured on a PASCAL object where frankA's were measured on a C one:
__pxxAssertandPalBackendSocketareLOCAL, not weak exports. frankA measured that DCE's residual is "PINNED BY BEING EXPORTED, not unpruned — a C object exports 286 crtl entry points WEAK,--dcedrops 269 LOCAL bodies and exactly ZERO weak ones." That explanation does not cover this case. These are exactly the local bodies that measurement says get dropped, and they are retained anyway. So the Pascal--emit-objpath has a second retention mechanism, or DCE is not reaching it.--dcechanges nothing here. With and without:code=336324Bbyte for byte,lwip_und=18,pal=114,__pxxAssertstill present.- Every symbol carries
SIZE 0. frankA already flagged this for the two init/fini thunks; it is general here, and it means--gc-sectionshas no extents to work with even once per-function sections exist. Their own measurement — the flag drops 168 bytes of 624888 — is consistent with that.
Not investigated further: that ticket is held, and this one exists to say the retention now has a RED attached to it, not to take its work.
Explicitly NOT the size regression
I tested this hoping to collapse two tier reds into one cause. It does not:
f0a1a8be9~1 test_esp_hello objbytes=112896 code=106908B pal=0 lwip=0
f0a1a8be9 test_esp_hello objbytes=112896 code=106908B pal=0 lwip=0
Byte-identical. Ordinary ESP programs are untouched, so this is not a cause of [[bug-a-the-esp32-bare-image-doubled-in-code-and-grew-half-again-in-bss]] and must not be cited as one.
Box
plexus, kernel 7.0.0-30-generic, xtensa-esp-elf 15.2.0 (esp-15.2.0_20251204),
against compiler/pascal26 = 0a20bae79296 at cd2d0f3ca. Every link above is a
real xtensa-esp32s3-elf-gcc invocation, not qemu. ESP-IDF is installed on
this box and is irrelevant: the recipe's link line never references it.
2026-09-06 (frankA) — THIS TICKET'S ROW IS GREEN NOW AND THE DEFECT IS NOT FIXED
Read this before reading test-emit-obj as evidence about this bug.
test-emit-obj passes. Nothing here was fixed. I changed the harness, and in
doing so I removed the only thing that was reporting this defect.
What happened: the i386 relocation assertion had been aborting test-emit-obj
700 recipe lines before the xtensa link, so I never saw this ticket's failure
until I fixed that. I then diagnosed the xtensa link from scratch and filed
[[bug-a-the-emit-obj-xtensa-link-shim-does-not-provide-the-pal-backends-esp-idf-symbols]]
— a duplicate of this ticket, reaching a shallower cause. Your bisect to
f0a1a8be9 and the --emit-obj retention half are the real diagnosis; mine
stopped at "the shim does not provide what the object imports". Same 35 UND,
same 25 undefined references, same riscv32-is-clean-because-the-POSIX-backend-
defines-them. I should have found this ticket first.
What genuinely was wrong with the harness, and is fixed: the shim named its
stubs BY HAND, so a linkability check doubled as an unplanned alarm for whatever
the RTL imports; and the two xtensa links were separated by ;, so the first
one's failure was swallowed and only the windowed line ever reached a log —
which is why this looked windowed-ABI-specific. tools/emit_obj_stub_shim.sh
now generates the shim from each object's own UND list.
The consequence for you is the part that matters. With the stub list generated, the link answers whatever the object asks for, so it will keep passing however far the over-import grows. The recipe therefore prints the count and names this ticket:
test-emit-obj: NOTE -- the xtensa object still imports N ESP-IDF symbols
for a routine it never calls.
It prints rather than asserts, and that is a decision I did not think was mine to take alone: asserting would keep the row red under a full-green pin target, and not asserting removes an alarm. Raised with frankuser (who holds the full-green ledger) and with you. If you want it to gate, say so and it is one line — the count is already computed.
Measurement that still shows the defect, unchanged:
./compiler/pascal26 -Fulib/rtl --target=xtensa test/test_emit_obj.pas /tmp/xt.o
readelf -sW /tmp/xt.o | awk '$7 == "UND" && ($8 ~ /^lwip_/ || $8 == "vTaskDelay" || $8 == "esp_timer_get_time")' | wc -l
Whatever fixes this should land its own assertion rather than relying on the link, since the link no longer has an opinion. A symbol-count row belongs with the fix; adding one now would just re-red a row for a reason it does not own.
Corroboration moved here from the folded duplicate
Measured 2026-09-06, linking each object against the OLD hand-written shim:
| object built by | ABI | undefined references |
|---|---|---|
stable_linux_amd64/default/pinned |
windowed | 25 |
HEAD (189e9b74036e) |
windowed | 25 |
| HEAD | default | 25 |
| HEAD, riscv32 | — | 0 |
The pin and HEAD agreeing at 25 is the not-a-regression control — this is
older than anything in the current tree, which your bisect to f0a1a8be9
already establishes from the other end. The two xtensa ABIs agreeing is what
rules the windowed ABI out as a variable, and that mattered because the recipe
made it look like one: the two links were separated by ;, so the first one's
rc was swallowed and only the windowed line could ever reach a log. Both are
|| exit 1 now. That is CLAUDE.md's &&-not-; rule producing a wrong
DIAGNOSIS rather than a missed failure, which is the more expensive half.
And the ratchet that replaces the accidental alarm
test-emit-obj now asserts the ESP-IDF import count is <= 20, the number
measured today, and PRINTS it every run beside this ticket's slug. It is a
ratchet, not a gate: it stays green at today's surface and reddens if the
over-import grows. Gating at zero would manufacture a red this row never carried
on purpose — the hand-written stub list reported it by accident, and that
accident is why the shim went stale — and would hold a row red against an open
compiler bug under a full-green target. frankuser ruled on that; a stricter
assertion on this ticket's own terms is yours to argue.
Whatever fixes this should move the 20 in the same commit, the way the size canary re-baselines, and can then assert on both targets rather than only xtensa: riscv32's object has 2 UND and 0 stubs against xtensa's 35 and 33, but carries the same 114 PalBackend symbols — it defines rather than imports them.
What the symptom-side search would have had to look like
I filed a duplicate of this ticket because I searched for the CAUSE I had just
inferred and not for the SYMPTOM I was looking at. The searches that would have
found this ticket from a bare ld failure, in the order they cost:
grep -rl 'lwip_' devdocs/progress/ # the undefined symbol itself
grep -rli 'esp.pal\|vTaskDelay' devdocs/progress/
tools/progress.sh ready --track A | grep -i 'emit-obj'
The first one hits this ticket's summary directly. The rule that generalises: search on the words the FAILURE gave you — an undefined symbol, an error string, a job name — never on the cause you have reasoned your way to, because the existing ticket is filed under a cause you do not know yet. A red row you are the first to SEE is not a red row nobody has FILED, and a failure that was unreachable behind an earlier one is indistinguishable from a new one.
2026-09-06 (frankF) — the retention is PER-UNIT, and that is what decides option (S/B)
Two things this ticket needed and did not have: a repro that runs, and a measurement of the GRANULARITY the split depends on.
First, the repro line in "Measurement that still shows the defect" is wrong —
it omits --emit-obj. As written it builds an executable, and on a directory
that does not exist it prints ok: and exits 0 having written nothing (that
part is bug-a-the-compiler-prints-ok-and-exits-0-when-it-wrote-no-output-file,
already filed by someone else; it reproduces on x86-64 too, not just xtensa).
With the flag added the defect reproduces exactly as filed at d6de711d1,
compiler/pascal26 = c9de36a3754e: xtensa 36 UND / 18 lwip_* / 114 PAL
against riscv32 2 UND / 0 / 114.
Second, and this is the part that decides the design. The ticket's option
(S/B) is "split the ESP platform_backend so file I/O does not drag the socket
surface", and whether that helps at all depends on a granularity nobody had
printed. Measured, --target=xtensa --emit-obj, each a whole program:
| program | PAL syms | lwip_* UND |
procs |
|---|---|---|---|
begin end. |
0 | 0 | 175 |
x := 'a'; x := x + 'b'; WriteLn(x) (AnsiString + concat) |
0 | 0 | 175 |
WriteLn(StdErr, 'x') |
0 | 0 | 175 |
Assert(i = 1) — five lines |
114 | 18 | 606 |
uses platform; with an empty body |
114 | 18 | 606 |
one Assign/Rewrite/WriteLn/Close on a Text |
114 | 18 | 606 |
uses platform; and begin end. — naming the unit is the whole cost. So
retention is per-UNIT: every procedure of a unit in the uses graph is emitted,
and nothing finer is consulted. Option (S/B) therefore works, and it is the
only one of the two candidates that does: moving the socket bodies to their own
unit takes them out of the graph for a program that never names it.
It also corrects this ticket's own account of the mechanism. The body above
says __pxxAssert's "string path reaches the PAL's file I/O" — but a bare
AnsiString concatenation and a WriteLn to either stream reach the PAL not at
all, on this target. Console output on xtensa does not go through platform.
What __pxxAssert reaches is the unit, and reaching it anywhere costs all 114.
A five-line program is now the repro, in place of test/test_emit_obj.pas:
one Assert drags 18 lwIP imports and 431 procedures into an object for a
routine the program never calls. That is worth having because it is not an
--emit-obj property — see the executable rows in
[[bug-a-assert-is-undefined-on-the-esp-bare-profile]], where the same Assert
costs +229,376 B on hosted riscv32 and +94,208 B on x86-64 against the same
empty program.
Not started, and not claimed here. frankA holds option (A) under
[[feature-a-every-emit-obj-object-links-its-own-full-copy-of-crtl-so-n-objects-cost-n-runtimes]]
and I have not touched dce.inc. The coordinator has confirmed (S/B) collides
with nobody: lib/rtl/platform/esp/platform_backend.pas and
lib/rtl/platform/posix/platform_backend.pas are different files that share a
basename, and the POSIX one is another seat's. The scope the split actually
has, which this ticket understates: platform.pas is a facade that re-exports
the net surface, so taking the sockets out of the graph means splitting the
FACADE too, and its interface is shared by the esp, posix and wasi
backends — three files, not one.
2026-09-06 (frankF) — the split, MEASURED into a plan, and why I did not land it tonight
I took option (S/B) and measured it before writing any of it. The design is settled and the payoff is exact; what changed my mind about the timing is the blast radius, which this ticket described as "narrower" and is not.
The payoff is exact: 20 ESP-IDF imports become 2
Only 22 of the ESP backend's 114 entry points reference lwIP, and they are cleanly all-net:
PalBackendSocket SetSocketReuseAddr SetSockOpt GetSockOpt SetSocketNonBlocking
BindIpv4 ConnectIpv4 Listen Accept AcceptIpv4 Recv Send Shutdown SocketClose
SendToIpv4 RecvFromIpv4 Poll PollSet GetSockError GetSockNameIpv4
GetPeerNameIpv4 Ioctl
Exactly two routines reference the other ESP-IDF imports —
PalBackendMonotonicMillis and PalBackendYield, for vTaskDelay and
esp_timer_get_time — and those must stay, because any program may ask the
clock. So the ratchet this ticket installed at <= 20 becomes 2, and the
<= 20 line moves with the fix as its author asked.
And the cost, which is the part that was not measured
| backends needing the split | 3 — esp, posix, wasi, at 114 entry points each |
facade platform.pas |
119 entry points, 27 of them net |
| files outside the PAL calling the net facade | 34 |
| of those, RTL layer rather than tests | 13 — sockets net asyncnet http dns_* (5) tls13_native tls13_ktls netconnect pxxcio |
The three backends are symmetric (114 each), so this is not an ESP change with
some tidying: platform.pas is one facade over all three, and taking the net
wrappers out of it means platform_net + platform_backend_net in every
backend directory, or the unit name fails to resolve on posix and wasi. There is
no smaller correct version — I looked for one. Splitting only the ESP backend
leaves platform.pas referencing the moved routines; splitting only the facade
leaves it referencing a backend that still carries them.
Why it is banked rather than landed
It fixes no red. This row is green today — the ratchet sits at the measured number by design. The defect is real and the object is 18 imports too fat, but nothing is failing on it.
And it would land unverifiable. It is a wide change to the networking layer
of lib/rtl/**, which is a compiler build input and half of the pin/RTL pair,
on a night whose newest full tier is 6d04b14cd88d from 18:37Z — the one
requested full published a verdict with no manifest. make lib-test and
gate.sh quick would catch a missing uses (the change is mechanical and the
compiler is loud about unresolved names), but neither covers the cross-target
matrix, and that is exactly where a PAL split can go wrong.
CLAUDE.md's own line for this: destabilising work goes behind a flag or lands incrementally. There is no flag here and the increments are not independently correct — the facade and the three backends have to move together or the tree does not compile. That is the argument for doing it deliberately with a tier underneath it, not for doing it at 22:00 before a beta.
What the next seat does not have to redo
The inventory above, the two-routines-must-stay finding, the per-unit retention
measurement earlier in this ticket, and the consumer list. The remaining work is
mechanical and the shape is: platform_backend_net.pas in each of the three
backend directories carrying those 22 (and their posix/wasi equivalents),
platform_net.pas over them carrying the 27 facade wrappers, and uses platform_net added to the 13 RTL consumers. Move the ratchet from 20 to 2 in
the same commit, which is what this ticket already asks of whoever fixes it.
2026-09-18 (frankB) — OPTION (A) IS MEASURED TO FIX THIS, AND (S/B) IS NOT NEEDED FOR IT
Before anyone starts the three-backend split: DCE drops all 114 PAL symbols.
Measured today on riscv32, which this ticket establishes carries the same 114
PalBackend symbols as xtensa and differs only in defining rather than
importing their callees:
--target=riscv32 --emit-obj, test/test_emit_obj.pas |
PAL syms | UND | object |
|---|---|---|---|
without --dce |
114 | ext_notify ext_aliased_link |
538428 B |
with --dce |
0 | ext_notify ext_aliased_link |
66008 B |
Exports byte-identical (readelf -sW, GLOBAL FUNC, diffed — that is what an
object is FOR, and DCE's own root loop exists to protect it). The five-line
Assert repro behaves the same: 114 -> 0, 536824 B -> 71524 B.
This became measurable today because --dce now runs on riscv32
(40ab2b91c). It was not measurable before, and that is why the ticket's
earlier --dce row could not see it.
The row above this one is not evidence against this, and it looks like it is
This ticket records "--dce changes nothing here. With and without:
code=336324B byte for byte, lwip_und=18, pal=114". That was measured on
xtensa, where the pass refuses the target — the correction section says so in
its own words two paragraphs earlier (dce: off: target is not x86-64). So it
is a true statement about a pass that did not run, and it reads as a measurement
of a pass that did. It is the instrument-answering-about-something-else shape:
nothing errored, and the number was real.
What this does to the two candidate fixes
- (A) make DCE work on the target — now demonstrated to remove the retention
entirely, on a target of the same family, with exports preserved. And it is
much smaller than this ticket's "now known to be much bigger than it
looked" assessment, which rested on the premise that each backend needs a
branch-patch arm written. That premise is false and is corrected at the top of
[[bug-a-dce-refuses-every-target-except-x86-64]]:
ApplyCallFixupshas been fully architecture-aware all along. riscv32 needed two one-line defects fixed, not an arm. - (S/B) split the ESP
platform_backend— frankF priced this honestly at three backends x 114 entry points, a 119-entry facade, and 13 RTL consumers, and banked it rather than landing it. That work is not required to fix this ticket. It remains independently useful (a program namingplatformfor the clock should not carry sockets under ANY pruning policy), but it should be ranked on its own merit and not as this bug's fix.
What is NOT claimed
That xtensa's 18 lwip_* imports are gone. They are not measured, because
DCE still refuses xtensa. What is measured is that the 114 PAL bodies which
reference them are droppable on a sibling target with the pass on. The import
list is a consequence of those bodies being retained, so the expectation is that
they follow — but that is a prediction and this section is not the place to
record it as a result. Re-derive the number from the built object, do not
write it from this paragraph; an assertion written from a prediction pins the
prediction.
The remaining xtensa work, now that riscv32 has mapped the shape
Verifiable here: qemu-xtensa is on this box, and
--target=xtensa --platform=posix builds a hosted image that runs under it
(hello, rc=0, measured). So a gate lift on xtensa can be verified
end-to-end rather than only linked.
EmitXtensaCallToCode/EmitXtensaCall8ToCodeneed their calls recorded, exactly asEmitRiscv32CallToCode's ten sites did.XtEntryPcAnchoris captured before the pass runs and DCE moves code — it must be re-derived throughDceNewOff, or the entry jump's long form is a delta from a stale anchor.- The one riscv32 did not have:
symtab.inc:17878's deliberate non-recording, for a BACKWARD call out ofCALLnreach. The anchored slot form the FORWARD case already uses (EmitXtensaLongCallSlot+RecordInternalCallAtwith an anchor) is what that case needs too —ApplyCallFixupshas a literal arm keyed on the anchor, which is precisely the Patch24-over-the-head problem that note gives as its reason.
Move the ratchet in the same commit, as this ticket already asks of whoever
fixes it, and assert on both targets. (The <= 20 UND ratchet this line named
is gone as of 2026-09-18 — see the relocation section below.)
2026-09-18 (frankB) — measured, not predicted: the link failure is gone, and my own prediction was WRONG
--dce runs on xtensa as of 095a7a794. This ticket's own failing scenario,
reproduced and then fixed:
xtensa --emit-obj, linked against the shim with NO ESP-IDF
without --dce ld rc=1 24 undefined references <- the original red
with --dce ld rc=0 0 undefined references 258556 B ELF
Windowed ABI the same. PalBackend 114 -> 0, object 381528 B -> 52476 B. The
five-line Assert repro: 114 -> 0, 380180 B -> 53492 B.
24, not the 25 in the pin-versus-HEAD control above, and the difference is the
SHIM and not the tree. That control ran against the HAND-WRITTEN shim; this
runs against the generated one with every ESP-IDF name stripped, which is a
different set of names left undefined. Re-measured 2026-09-18 and the 24 is
self-consistent with the relocation table below: ld reports one error per
relocation SITE, so 24 errors across 20 distinct names — the four symbols with
two relocations each are reported twice. An earlier pass of this section said
25 by carrying the older control's figure across two shims; it is 24 for this
one.
The prediction I refused to assert was wrong, and this is why it was refused
The section above says, of the 18 lwip_* imports: "that is a prediction and
this section is not the place to record it as a result. Re-derive the number
from the built object, do not write it from this paragraph."
Re-derived, and the import count does not move: 20 with --dce, 20 without.
DCE removes CODE; it does not prune the symbol table, so the object still
DECLARES imports that nothing in it references any more. What moved is the thing
the linker actually fails on:
| ESP-IDF UND entries | relocations naming them | total relocations | |
|---|---|---|---|
without --dce |
20 | 24 | 428 |
with --dce |
20 | 0 | 210 |
An ld error is an undefined reference, not an undefined symbol — which is
why the link goes green while the count this ticket ratchets on sits still.
So the ratchet was counting the wrong quantity. It was installed to notice the over-import growing, and it could not see the over-import being FIXED.
MOVED, later the same day: test-emit-obj now asserts on RELOCATIONS naming
the ESP-IDF symbols, ratcheted at 24, and PRINTS the UND count beside it. Both
readings are kept because they fail differently — the relocation count is the
one that tracks the fix and the one ld agrees with, and the UND count is what
a linker error message NAMES, so dropping it would lose the reading that says
which symbol widened. The row still builds without --dce, so 24 is today's
number and not a target; whoever turns the pass on moves it to 0 in the same
commit.
What is left, and neither part is the split
--dceis not on by default. A default--emit-objobject still carries all 114 and still imports 20. Whether--emit-objshould enable the pass is a goal question, not an engineering one, and it is one sentence: do we want an emitted object to contain only what it needs? If yes, this ticket closes by turning the pass on for that path and moving the ratchet to relocations.- The ratchet's quantity, above.
(S/B), the platform_net split, is not required for either. frankF priced it
honestly at three backends x 114 entry points, a 119-entry facade and 13 RTL
consumers, and banked it rather than landing it. It remains independently useful
— a program naming platform for the clock should not carry the socket surface
under ANY pruning policy, and a fix that depends on an optional flag is weaker
than one that does not — but it should be ranked on that merit, not as this
bug's fix.
2026-09-19 (frankS) — item 1's goal question, answered with a measurement: YES in principle, NOT YET in practice
The ticket asks "do we want an emitted object to contain only what it needs?" and calls it a goal question. It is stated correctly — no implementation noun in it — and the honest answer is that the goal half is not where the difficulty is. There is no competing want:
- An object's callers are outside it, so the pass roots at everything the object
EXPORTS, via
ObjProcIsExported— the object writer's own predicate, so the root set and the GLOBAL symbol block cannot disagree (dce.incsays so at the loop). - A LOCAL body that goes is unreachable from another object by definition: ELF locals do not resolve across objects.
- So the only thing that could break is a GLOBAL, and measured on three targets, none moves.
test_emit_obj |
object | relocations | GLOBAL defined syms LOST |
|---|---|---|---|
| x86-64 | 208,256 -> 37,864 | 496 -> 258 | 0 |
| riscv32 | 540,108 -> 66,252 | 455 -> 255 | 0 |
| xtensa | 382,904 -> 52,736 | 433 -> 208 | 0 |
(UND symbol-table entries are unchanged, as this ticket already records: the pass removes code, not symbol entries. Relocations are the quantity that moves.)
dce.inc names one non-loud failure for this path — a dropped symbol that
"resolves to another object's copy". Built two objects and linked them with a
C driver: identical program output both ways, and zero GLOBAL symbols defined
in both objects, because each exports only its own entry points and its
runtime copy is local. That mode is not reachable here.
And then the default was turned on, and the tier said no
All of the above is x86-64 and single-object. Turned on, make test-emit-obj
failed at fnp_386 — the callback-table pair, two objects, i386:
| i386 | x86-64 | |
|---|---|---|
--no-dce --emit-obj |
rc=0, 20 11 |
rc=0, 20 11 |
--dce --emit-obj |
rc=138, no output | rc=0, 20 11 |
The objects LINK cleanly and the program dies before main. It reproduces with
--dce spelled explicitly, so it is not a regression and not about the
default — it is an existing shipping path nobody had run on i386, and the
single-object rows pass there, so only a two-object link exposes it.
The default was reverted. Filed as
[[bug-a-dce-under-emit-obj-crashes-a-two-object-i386-link-before-main]] (p55),
with dce.inc's own init/fini-thunk note as the lead — "turning the refusal
off without them SEGFAULTS BEFORE main" is the observed symptom one target
over.
So item 1 is not blocked on a decision
It is blocked on that bug. When it is fixed, turning the pass on for
--emit-obj is one line beside the -O3 rule and the guard is already written
in this tier (same exports, smaller, still runs) — the one change it needs is
that its baseline arm must then say --no-dce explicitly, because otherwise the
comparison becomes x < x. That is not hypothetical: it is what happened on the
first run after the flip, and the row caught it.
What DID land from this measurement: the same pass is now on by default for
--esp-profile=bare (e1ffef211), where the evidence is complete — six images
booted on both chips under Espressif QEMU, UART byte-identical, and 14 bare-boot
rows green. Different path, different root set, fully measured.