Skip to content

Effect C names its own definitions with HID, and a module that uses HID must (#1533) - #2017

Open
Lorenzobattistela wants to merge 3 commits into
mainfrom
fix-1533-effect-names
Open

Lorenzobattistela wants to merge 3 commits into
mainfrom
fix-1533-effect-names

Conversation

@Lorenzobattistela

@Lorenzobattistela Lorenzobattistela commented Nov 2, 2026 •

Copy link
Copy Markdown
Collaborator

Fixes #1533 for packages that adopt HID; supersedes #1546.

Problem

-o pastes every effect .c into one C file. A program that imports two versions of one hub package pastes the same C twice, so clang refuses the second definition (redefinition of 'wall_raw_run'), while bend main.bend runs fine. In the real bend-kit-time case it is also silently wrong: the helper sits behind #ifndef TIME_EFFS, so version 2 would quietly use version 1's helper.

Fix

  • HID(name) in an effect's C is a C id of the module that imports it (c_ids, same pass as CID/FID). The key is the module, not the file: bend_tensors shares a guard and a struct across five .c files of one module.
  • An imported module whose C says HID (outside comments and strings, found in effect_src's existing scan) is held to it. compile_book pastes its C twice, and the second copy is dead: constructor/destructor become __unused__, and -Wtentative-definition-compat is an error inside it. A name defined without HID therefore fails the author's own build, instead of the first program that meets two versions.
  • Each copy push/pops the macros it defines, so include guards and macros need no HID.
  • C without HID, Base, and the root module are emitted exactly as before.

Cost: in the two-version bend-kit-time build, the dead copy adds 136 bytes to the binary and no measurable build time. Runtime is unchanged.

Commits

  1. refactor(comp): sheds 386 ttok from comp.ts. The C and JS emitted for every test are byte-identical to main's.
  2. fix(effects): the feature, tests/io/effect_versions (two versions with the same names, each keeping its own counter: 8 71 9 72 in the interpreter, JS and native), and a paragraph in guide/EFFECTS.md.
  3. refactor(effs): Base's own effect C (bend2/effs/*.c, 281 names) uses HID, so the reference packages copy shows the rule, and a package's names cannot clash with Base's. The three tests/gfx C files that reach into window.c internals say HID too. Base and the program's own file share namespace "", so HID(BendWin) there is Base's. Base's macros keep their names. With HID undone, the diff is token-identical to main, including the Linux X11/ALSA and CUDA paths.

comp.ts ends at 65,987 / 66k.

Verified

  • Gates on d094958: repo 54/54, ping 81/81, perf 124/124 (one contention-only miss, clean on rerun), test 1822/1822, safe 0 false rejects.
  • Copies of the two real bend-kit-time versions with HID adopted build and run natively together.
  • Removing any single HID (helper, global, entry, constructor, tentative global) fails the build, even with a single version imported.
  • Probes from three review rounds pass: macro names containing push_; __constructor__/destructors in the dead copy; HID( in a comment; external HID_* macros; a package's own unused macro.
  • Base conversion: every emitted test C syntax-checks on macOS, on macOS with -DBEND_METAL=1 (the 44 ! programs), and on Debian clang 14 with X11/XI/ALSA headers. A throwaway compiler that forces the dead copy on Base reports no unwrapped Base definition on either OS, and covers all 281 names.

Limits

  • Published packages still clash until they adopt HID and republish.
  • An HID module's constructors cannot take a priority.
  • The tentative-global check needs clang 21+.
  • The test gate cannot express an expected native build failure, so the enforcement was verified by hand.
  • The dead copy does not apply to Base (namespace "" is shared with the program's own file, which must stay free to skip HID), so a future bare name in bend2/effs is not caught by the build.
  • Separate translation units were considered and dropped: the effects paste into the runtime and call the evaluator, so it would need a runtime header/body split.

🤖 Generated with Claude Code

Lorenzobattistela and others added 3 commits November 2, 2026 17:43
Behavior-preserving simplifications to make room under comp.ts's 66k
cap: term_use takes a term, single-use helpers inlined (arr_leaf,
val_brw), switch defaults return after the switch, set "seen" checks use
size, empty checks use !length, Seg flags optional, type and table
declarations packed. The C and JS emitted for every test are
byte-identical to main's.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ses it must (#1533)

Two versions of one hub package paste the same C into one file, so `-o`
failed with `redefinition` while the interpreter ran. HID(name) is now a
C id of the importing module. An imported module whose C says HID
(outside comments and strings) pastes it twice, the second copy dead
(constructor and destructor attributes become __unused__), so a name
defined without HID fails the author's own build instead of the first
program that meets two versions: `redefinition`, or, under
-Wtentative-definition-compat made an error there, a repeated global
without an initializer. Each copy push/pops the macros it defines, so
include guards and macros need no HID. C without HID builds as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Every name Base's effect sources define at file scope (functions, the
effects and their constructors, globals, types and tags, enum constants,
the BendView class) is now HID(name), so bend2/effs is the reference a
package's C copies, and a package's functions, globals and types cannot
clash with Base's. Base's macros keep their global names, as before. The
gfx tests that reach into window.c's internals say HID too; Base and the
program's own file share a namespace, so HID(BendWin) there is Base's.
EFFECTS.md's examples use HID. No runtime or emitter change; window_dev
the CUDA kernel is the runtime's and keeps its name.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@nicolas-abril nicolas-abril left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All issues are in the dead-copy logic in compile_book/effect_src (cf0d171). I found nothing wrong in the refactor(comp) commit or the Base HID conversion. I reproduced the tentative-definition error with Homebrew clang 22 and the unknown-warning message with Apple clang 17. The rest come from reading the code.

Comment thread bend2/comp.ts
.join("") + `\n#pragma clang diagnostic ${d}\n`;
return at("push") + c_ids(src, m) + at("pop") + at("push")
+ dead.replace(/\S+/g, "#define $& __unused__\n")
+ `#pragma clang diagnostic error "-Wtentative-definition-compat"\n`

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Making -Wtentative-definition-compat an error in the dead copy also rejects valid HID code: a static global declared first and defined later.

static const struct N HID(a);
static const struct N HID(b) = {&HID(a)};
static const struct N HID(a) = {&HID(b)};

Copy 1 compiles. In copy 2, Homebrew clang 22 reports error: duplicate declaration of 'HID2_..._a' is invalid in C++, so a correct package can't be built natively.

Separately, this pragma isn't guarded. Clang before 21, including Apple clang 17 (the macOS default clang, which cc_find picks first), prints warning: unknown warning group '-Wtentative-definition-compat', ignored once per HID module on every -o build. The check then does nothing. Wrapping it in #if __has_warning("-Wtentative-definition-compat") fixes that.

Comment thread bend2/comp.ts
...src.match(/^[ \t]*#[ \t]*(define|undef)[ \t]+\w+/gm) ?? []]
.map((x) => `\n#pragma ${d}_macro("${x.split(/\s/).pop()}")`)
.join("") + `\n#pragma clang diagnostic ${d}\n`;
return at("push") + c_ids(src, m) + at("pop") + at("push")

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

push_macro saves an include guard's earlier state but never #undefs it. If the guard is already defined, the HID copy skips its own guarded body.

Example: a program imports bend-kit-time v1 (no HID, #ifndef TIME_EFFS guard) and v2 (HID, same guard, since the guide says guards don't need HID). v1 is in the non-HID part, so it's pasted first and defines TIME_EFFS. v2's copy then skips its own helpers and fails with an undeclared HID_..._helper. If the guarded block only has macros, v2 silently uses v1's, which is #1533 again. Emitting #undef NAME after each push_macro fixes it.

Comment thread bend2/comp.ts
const src = FL.js ? raw : raw.replace(/\\[ \t]*(\r\n?|\n)/g, "");
FL.effs.set(path, [m, src]);
if (!FL.js) c_ids(src, m, (p, q) => {
if (p === "HID" && m) FL.hids.add(m);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This skips HID enforcement when the root module imports the package's C (m === ""). So the author's own build never catches a bare name, contrary to the PR's claim. A package author who runs bend -o out lib.bend on the file that does import "./lib.c" (like effect_version_a.bend) gets no dead copy, and a bare static int count; passes. The clash only shows up when a user imports two versions.

Comment thread bend2/comp.ts
return t;
}
if (p === "HID") {
return use ? (use(p, k), t) : name_id(h, m + ":" + k);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

HID(k) always resolves in the importing module's namespace, so a package's C can't name Base's effect internals, which are now HID-renamed, or another package's.

A hub package whose C extends windows or channels by naming Base's BendWin, WinQueue or a chan.c helper breaks after the Base conversion. The bare name is undeclared, and HID(BendWin) in the package gives HID_pkg_BendWin, not Base's HID__BendWin. Only the root file (namespace "") can reach Base, so there's no supported way to depend on another module's C.

Comment thread bend2/comp.ts
const reqs = effs.map(([m, s]) => FL.hids.has(m) ? "" : c_ids(s, m)).join("")
+ [...FL.hids].map((m) => {
const src = effs.filter((e) => e[0] === m).map((e) => e[1]).join("\n");
const dead = "constructor destructor __constructor__ __destructor__";

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The dead copy only neutralizes constructor and destructor attributes. Objective-C in it still runs or registers at image load: +load methods run and categories attach.

Example: a macOS package with an #ifdef __OBJC__ path defines @implementation NSWindow (HID(Ext)) or a class with +load. The dead copy's +load runs at startup a second time. Its category methods share copy 1's selectors and may win, and they read HID2_ globals whose constructor never ran.

Comment thread bend2/comp.ts
return at("push") + c_ids(src, m) + at("pop") + at("push")
+ dead.replace(/\S+/g, "#define $& __unused__\n")
+ `#pragma clang diagnostic error "-Wtentative-definition-compat"\n`
+ c_ids(src, m, undefined, "HID2_") + at("pop");

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The dead copy keeps every external-linkage function and the data it reaches, so a package's initialized tables end up twice in every consuming binary. For example, a non-static Term HID(run)(...) that reads a 2 MB static const u8 HID(font)[] gets a second, unused 2 MB HID2_ copy, and -O3 can't drop it because the HID2_ entry point has external linkage. The 136-byte cost in the PR description only holds for tiny packages.

Comment thread bend2/comp.ts
// (no constructor or destructor), so a name defined without HID clashes
// in its own build; each copy keeps its macros.
const effs = [...FL.effs.values()];
const reqs = effs.map(([m, s]) => FL.hids.has(m) ? "" : c_ids(s, m)).join("")

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Non-HID effect sources are still joined with "" (the HID path uses "\n"), so a .c file without a trailing newline merges into the next one. #endif followed by Term foo(...) becomes #endifTerm foo(...), an invalid directive. If the first file ends in a // comment, the next file's first line becomes part of the comment.

Comment thread bend2/comp.ts
}).join("\n\n");
const reqs = [...FL.effs.values()].map(([m, src]) => c_ids(src, m)).join("");
// An imported module whose C says HID pastes it twice, the second dead
// (no constructor or destructor), so a name defined without HID clashes

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Design: this enforces an author-side lint by compiling every HID package's C twice in every downstream build. That costs compile time and binary size, needs clang 21, and still misses Base and root. It also brings the issues above: ObjC load side effects, no constructor priorities, and false tentative-definition errors. All to catch a mistake only the publisher can fix.

The same check, run once when a package is tested or published on the hub, or as a TS-side scan of top-level definitions, would enforce the rule with no per-build cost.

Comment thread bend2/comp.ts
+ [...FL.hids].map((m) => {
const src = effs.filter((e) => e[0] === m).map((e) => e[1]).join("\n");
const dead = "constructor destructor __constructor__ __destructor__";
const at = (d: string) => [...dead.split(" "),

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

at() reruns the #define/#undef regex over the whole module source and re-splits dead on each of its four calls per module. Computing const names = [...dead, ...macros] once per module and mapping it to push and pop avoids that. Making dead an array also removes the split and the replace.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

-o fails when two versions of one hub package define the same C effect: redefinition of <effect>_run

2 participants