Skip to content

Commit 4735908

Browse files
committed
wasm32-emscripten names its launcher .js and carries the .wasm as a link output (#622 A5)
- artifact_naming grows an `emscripten` row (triple.cppm): exeSuffix ".js", lib prefix/ext as ELF's, sharedLibExt empty with the shared-unsupported marker set. Independent of host naming (Linux/Windows both answer ".js"), and a "none" OS still falls back to the host answer unchanged. - LinkIntentFlavor gains Wasm, rendered exactly as Elf (-L, -l); the switch in flags.cppm has no default arm. - ninja_backend.cppm declares bin/<name>.wasm as an implicit output of the same link edge that writes bin/<name>.js, for every Binary/TestBinary link unit on a Wasm-object-format triple. - prepare.cppm refuses `kind = "shared"` on a Wasm triple by name, before any toolchain is resolved (same site/reasoning as the Apple SDK check it sits beside), naming -sSIDE_MODULE as the unrendered mechanism. - stage_tree.cppm adds emscripten_stem_family(): the launcher is the executable, the family is every other <name>.<anything> the link also wrote, read from the directory rather than a fixed extension list. pack.cppm gains a wasm packaging path (Plan::targetIsWasm, run_wasm) that stages the launcher plus the whole stem family, with no dependency-closure step (an ordinary Emscripten link is one static image). Verified end to end on this machine's installed emsdk@6.0.9 payload: `mcpp build/run/pack --target wasm32-emscripten` names bin/app.js + bin/app.wasm, runs and prints its marker (fixture declares its own `[target.wasm32-emscripten] runner = ["node"]`), and stages both files under `--format dir` and `--format tar` with both listed in .stage-manifest. The `shared` refusal is reached with MCPP_NO_AUTO_INSTALL=1 (no network). Tests: tests/unit/test_artifact_naming.cpp (new emscripten/none-OS cases), tests/unit/test_pack_stage_tree.cpp (emscripten_stem_family, positive and negative), tests/e2e/650_wasm_row_names_its_launcher.sh.
1 parent 2062ddb commit 4735908

9 files changed

Lines changed: 446 additions & 9 deletions

File tree

‎modules/toolchain-model/src/triple.cppm‎

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1124,6 +1124,35 @@ inline ArtifactNaming artifact_naming(const Triple& t, const ArtifactNaming& hos
11241124
.sharedNeedsImportLib = false,
11251125
};
11261126
}
1127+
if (t.os == "emscripten") {
1128+
// `artifact_naming`'s one sentence, unchanged: the executable is the
1129+
// file a runner executes, in the row's own convention. On this row
1130+
// that file is the JavaScript launcher — what the emsdk payload's
1131+
// `node` runs and what a browser loads — so the row's convention for
1132+
// "the executable" is `.js`, not the ELF/host-borrowed bare name this
1133+
// used to fall back to (a different file on Linux vs. Windows hosts).
1134+
//
1135+
// Read, not recalled: Emscripten's own CMake toolchain fixes
1136+
// `CMAKE_EXECUTABLE_SUFFIX ".js"` (`Platform/Emscripten.cmake`) and
1137+
// Rust's `wasm32-unknown-emscripten` target spec sets
1138+
// `exe_suffix: ".js"` — both because emcc's `-o` extension selects
1139+
// what it emits, and anything but `.js`/`.html`/`.wasm` falls back to
1140+
// the `.js` case. A future `wasm32-wasi` row would answer `.wasm` by
1141+
// the same sentence, the way Rust's other wasm targets do, because
1142+
// there the module IS the executable a runner executes.
1143+
//
1144+
// `sharedLibExt` is empty and `sharedNeedsImportLib` is set as the
1145+
// "shared libraries are not supported for this target" marker (see
1146+
// the field comment above): a wasm side module needs `-sSIDE_MODULE`,
1147+
// a link contract this engine does not render, so the fallback answer
1148+
// (a `.so`-shaped file that does not work as one) is refused instead
1149+
// of produced — see the plan-time refusal in prepare.cppm.
1150+
return ArtifactNaming{
1151+
.exeSuffix = ".js", .libPrefix = "lib",
1152+
.staticLibExt = ".a", .sharedLibExt = "",
1153+
.sharedNeedsImportLib = true,
1154+
};
1155+
}
11271156
// Outside the triple language: fall back to the host answer rather than
11281157
// guessing. A wrong guess here silently misnames every artifact.
11291158
return hostNaming;

‎src/build/flags.cppm‎

Lines changed: 11 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -128,7 +128,7 @@ struct CompileFlags {
128128
}
129129
};
130130

131-
enum class LinkIntentFlavor { Elf, MachO, PeGnu, PeMsvc };
131+
enum class LinkIntentFlavor { Elf, MachO, PeGnu, PeMsvc, Wasm };
132132

133133
// Spell a provider-neutral LinkIntent for one output format. Kept pure so
134134
// every platform contract can be asserted on every CI host. deployFiles are
@@ -758,14 +758,16 @@ CompileFlags compute_flags(const BuildPlan& plan) {
758758
case mcpp::toolchain::triple::ObjectFormat::Pe:
759759
return LinkIntentFlavor::PeGnu;
760760
case mcpp::toolchain::triple::ObjectFormat::Wasm:
761-
// No `LinkIntentFlavor::Wasm` exists, and inventing one
762-
// here would be a link-contract decision rather than a
763-
// format question -- what `link_lib` and a search path
764-
// even mean for an Emscripten link is the open half of
765-
// #597. `Elf` is the wrong answer and is the one this
766-
// returns; it is named here so the gap is visible rather
767-
// than reached by falling off the end of a switch.
768-
return LinkIntentFlavor::Elf;
761+
// #622 A5 closes the open half of #597: what `link_lib`
762+
// and a search path mean for an Emscripten link is ELF's
763+
// own spelling. `-L` for a search directory and `-l<name>`
764+
// for a library are both flags emcc's driver accepts
765+
// unchanged (`-lidbfs.js` is one of its own JS system
766+
// libraries), and `frameworks` and `link_library_dirs`
767+
// carry no Emscripten-specific rendering either. So the
768+
// flavor exists to give the switch below an honest arm —
769+
// not because the rendering differs from `Elf`'s.
770+
return LinkIntentFlavor::Wasm;
769771
case mcpp::toolchain::triple::ObjectFormat::Elf:
770772
return LinkIntentFlavor::Elf;
771773
}

‎src/build/ninja_backend.cppm‎

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2148,6 +2148,20 @@ std::string emit_ninja_string(const BuildPlan& plan) {
21482148
}
21492149
if (!plan.resourceUnits.empty()) append("\n");
21502150

2151+
// #622 A5: on `wasm32-emscripten` the link command is a single emcc
2152+
// invocation that writes the named output (`<name>.js`, this plan's
2153+
// artifact naming — see triple.cppm) AND `<name>.wasm` beside it in one
2154+
// run. The `.wasm` is not a second edge (nothing would sequence it against
2155+
// the first) and not a naming convention applied after the fact — it is
2156+
// declared as an implicit output of the SAME edge, so `ninja -t clean`,
2157+
// the incremental check and `mcpp pack`'s graph walk all see it.
2158+
const auto planTargetTriple =
2159+
mcpp::toolchain::triple::parse(plan.toolchain.targetTriple);
2160+
const bool wasmTarget =
2161+
planTargetTriple
2162+
&& planTargetTriple->object_format()
2163+
== mcpp::toolchain::triple::ObjectFormat::Wasm;
2164+
21512165
// Link units
21522166
for (auto& lu : plan.linkUnits) {
21532167
std::string ins;
@@ -2213,6 +2227,16 @@ std::string emit_ninja_string(const BuildPlan& plan) {
22132227
std::string implicitOut;
22142228
if (!lu.importLibrary.empty())
22152229
implicitOut = " | " + escape_ninja_path(lu.importLibrary);
2230+
// The `.wasm` module emcc writes beside the JavaScript launcher for
2231+
// every executable link on this row (see the comment above the loop).
2232+
// Not the test binary's rule alone and not the `bin` rule alone: both
2233+
// ARE the executable form on this row, so both get the sibling.
2234+
if (wasmTarget
2235+
&& (lu.kind == LinkUnit::Binary || lu.kind == LinkUnit::TestBinary)) {
2236+
auto wasmSibling = lu.output; wasmSibling.replace_extension(".wasm");
2237+
implicitOut += (implicitOut.empty() ? " | " : " ")
2238+
+ escape_ninja_path(wasmSibling);
2239+
}
22162240
// The link map, DECLARED rather than merely written. It is produced by
22172241
// a flag on the link command, so it cannot be its own edge — a second
22182242
// edge claiming to produce it would run the link twice. Declaring it

‎src/build/prepare.cppm‎

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2521,6 +2521,32 @@ prepare_build(bool print_fingerprint,
25212521
parsed->str(), which, which));
25222522
}
25232523
}
2524+
// A `shared` TARGET NAMES A LINK CONTRACT THIS ENGINE DOES NOT RENDER.
2525+
//
2526+
// `-sSIDE_MODULE` is a different Emscripten link mode from the
2527+
// ordinary one (one static image, `artifact_naming`'s `.js`+`.wasm`
2528+
// pair) and mcpp emits no flag for it. Falling through to the
2529+
// ordinary link would still WRITE a `.so`-shaped file — the fallback
2530+
// naming's `sharedLibExt` is empty, so the linker would be asked for
2531+
// an empty-named output — so this is caught here, by NAME, rather
2532+
// than reached as an obscure link failure.
2533+
//
2534+
// REFUSED HERE AND NOT AT PLAN TIME, same reasoning as the Apple SDK
2535+
// check above: `parsed` and the manifest's own target list are both
2536+
// already known, resolving neither an emsdk payload nor any other
2537+
// toolchain, so an offline build (no emsdk installed) gets this
2538+
// sentence instead of downloading the SDK first.
2539+
if (parsed && parsed->object_format()
2540+
== triple::ObjectFormat::Wasm) {
2541+
for (auto const& t : m->targets) {
2542+
if (t.kind != mcpp::manifest::Target::SharedLibrary) continue;
2543+
return std::unexpected(std::format(
2544+
"[targets.{}] kind = \"shared\" is not supported on "
2545+
"wasm32-emscripten: a side module needs -sSIDE_MODULE, "
2546+
"which mcpp does not render",
2547+
t.name));
2548+
}
2549+
}
25242550
// Known, supported — and IMPOSSIBLE ON THIS HOST.
25252551
//
25262552
// Without this the target falls through to the host toolchain and the

‎src/pack/pack.cppm‎

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,11 +40,13 @@ import mcpp.config;
4040
import mcpp.pack.binfmt;
4141
import mcpp.pack.host_requirements;
4242
import mcpp.pack.relocate;
43+
import mcpp.pack.stage_tree;
4344
import mcpp.pack.strip;
4445
import mcpp.pack.zip;
4546
import mcpp.platform;
4647
import mcpp.xlings;
4748
import mcpp.manifest;
49+
import mcpp.toolchain.triple;
4850

4951
export namespace mcpp::pack {
5052

@@ -167,6 +169,13 @@ struct Plan {
167169
// Is the artifact a PE? Read from the FILE, not inferred from the triple —
168170
// the file is the thing being packaged, and a triple is a request.
169171
bool targetIsPe = false;
172+
// Is the artifact a wasm32-emscripten launcher? Read from the TRIPLE, not
173+
// the file: the packed file is `bin/<name>.js`, plain JavaScript text
174+
// carrying none of ELF/PE/Mach-O's magic, so `binfmt::identify` cannot
175+
// answer this the way it answers `targetIsPe`. The triple is what named
176+
// the file `.js` in the first place (see `artifact_naming`), so it is the
177+
// one fact this format IS recorded under.
178+
bool targetIsWasm = false;
170179
// The search set the PE closure resolves names against, after the
171180
// contract has had its say (see make_plan).
172181
std::vector<std::filesystem::path> searchDirs;
@@ -378,6 +387,11 @@ make_plan(const mcpp::manifest::Manifest& manifest,
378387
p.targetIsPe =
379388
mcpp::pack::binfmt::identify(builtBinary).format
380389
== mcpp::pack::binfmt::Format::Pe;
390+
// The triple, not the file — see the field comment on `targetIsWasm`.
391+
if (auto t = mcpp::toolchain::triple::parse(p.triple)) {
392+
p.targetIsWasm =
393+
t->object_format() == mcpp::toolchain::triple::ObjectFormat::Wasm;
394+
}
381395

382396
// THE CONTRACT REACHES PACKAGING. Until now it stopped at the compile and
383397
// link flags, so the step that decides which files travel could not see
@@ -1055,6 +1069,64 @@ run_pe(const Plan& plan)
10551069
return {};
10561070
}
10571071

1072+
// The wasm32-emscripten half of `run`. No dependency closure: the ordinary
1073+
// Emscripten link produces one static image with everything embedded (a
1074+
// `shared` target on this row is refused at plan time, before packaging ever
1075+
// sees it — see prepare.cppm), so there is nothing here to trace under a
1076+
// dynamic linker the way the PE and ELF halves do.
1077+
//
1078+
// THE STEM FAMILY RULE. The launcher (`<name>.js`) is the executable; the
1079+
// family is every other `<name>.<anything>` the link wrote beside it. `.wasm`
1080+
// is required — the link edge declares it as an implicit output
1081+
// (ninja_backend.cppm), so its absence names a build directory that does not
1082+
// match the graph rather than a program that legitimately has none. A
1083+
// `.data` (from `--preload-file`), a `.worker.js` or a `.wasm.map` are
1084+
// optional and travel exactly when the link wrote them: this packer stages
1085+
// whatever it finds, and asks the link, never a fixed extension list.
1086+
std::expected<void, Error>
1087+
run_wasm(const Plan& plan)
1088+
{
1089+
std::error_code ec;
1090+
std::filesystem::remove_all(plan.stagingRoot, ec);
1091+
std::filesystem::create_directories(plan.stagingRoot / "bin", ec);
1092+
if (ec) return std::unexpected(Error{std::format(
1093+
"cannot create staging '{}': {}", plan.stagingRoot.string(), ec.message())});
1094+
1095+
auto stagedJs = plan.stagingRoot / "bin" / plan.binaryName;
1096+
std::filesystem::copy_file(plan.builtBinary, stagedJs,
1097+
std::filesystem::copy_options::overwrite_existing, ec);
1098+
if (ec) return std::unexpected(Error{std::format(
1099+
"copy launcher failed: {}", ec.message())});
1100+
1101+
const auto builtDir = plan.builtBinary.parent_path();
1102+
auto family = mcpp::pack::emscripten_stem_family(builtDir, plan.binaryName);
1103+
if (!family.hasWasm) {
1104+
const auto stem = plan.builtBinary.stem().string();
1105+
return std::unexpected(Error{std::format(
1106+
"'{}.wasm' is missing beside the built launcher '{}' -- the "
1107+
"wasm32-emscripten link declares it as an implicit output, so its "
1108+
"absence means the build directory does not match the graph",
1109+
stem, plan.builtBinary.string())});
1110+
}
1111+
for (auto const& name : family.siblings) {
1112+
std::error_code fec;
1113+
std::filesystem::copy_file(builtDir / name,
1114+
plan.stagingRoot / "bin" / name,
1115+
std::filesystem::copy_options::overwrite_existing, fec);
1116+
if (fec) return std::unexpected(Error{std::format(
1117+
"failed to copy {} -> {}: {}", (builtDir / name).string(),
1118+
(plan.stagingRoot / "bin" / name).string(), fec.message())});
1119+
}
1120+
1121+
if (auto r = stage_runtime_files(plan, stagedJs.parent_path()); !r) return r;
1122+
1123+
copy_if_exists(plan.projectRoot / "README.md", plan.stagingRoot);
1124+
copy_if_exists(plan.projectRoot / "LICENSE", plan.stagingRoot);
1125+
1126+
if (plan.opts.format != Format::Tar) return {};
1127+
return make_tarball(plan.stagingRoot, plan.archivePath);
1128+
}
1129+
10581130
} // namespace detail
10591131

10601132
std::expected<void, Error>
@@ -1066,6 +1138,13 @@ run(const Plan& plan, const mcpp::config::GlobalConfig& cfg)
10661138
// the host: `LD_TRACE_LOADED_OBJECTS` cannot trace a PE from Linux either.
10671139
if (plan.targetIsPe) return detail::run_pe(plan);
10681140

1141+
// wasm32-emscripten, before the Mach-O refusal and the ELF closure below:
1142+
// this artifact is neither. `binfmt::identify` reports `Unknown` for the
1143+
// `.js` launcher (plain text, none of the three magics), which is exactly
1144+
// the branch the ELF path's own comment warns cannot be assumed away by
1145+
// exclusion any more.
1146+
if (plan.targetIsWasm) return detail::run_wasm(plan);
1147+
10691148
// A Mach-O artifact is REFUSED, on every host including macOS.
10701149
//
10711150
// The closure below asks the dynamic linker for the dependency list by

‎src/pack/stage_tree.cppm‎

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,4 +122,39 @@ bool is_builtin_pack_format(std::string_view name) {
122122
return std::ranges::find(kBuiltinPackFormats, name) != kBuiltinPackFormats.end();
123123
}
124124

125+
// #622 A5 — the wasm32-emscripten stem family, read from the directory the
126+
// link wrote into rather than assumed from a fixed extension list.
127+
//
128+
// THE RULE: the launcher (`<name>.js`) is the executable; the family is every
129+
// other `<name>.<anything>` the SAME link also wrote. `.wasm` is required —
130+
// the link edge declares it as an implicit output (see ninja_backend.cppm),
131+
// so a missing one names a build directory that does not match the graph,
132+
// not a program that legitimately produced none. A `.data`
133+
// (`--preload-file`), a `.worker.js` (`-pthread`) or a `.wasm.map` are
134+
// optional and travel exactly when the link wrote them — this asks the link,
135+
// never a list this module would have to keep in step with emcc's.
136+
struct EmscriptenStemFamily {
137+
std::vector<std::string> siblings; // sorted; excludes the launcher itself
138+
bool hasWasm = false;
139+
};
140+
141+
EmscriptenStemFamily emscripten_stem_family(const std::filesystem::path& builtDir,
142+
std::string_view launcherName) {
143+
EmscriptenStemFamily out;
144+
const auto stem = std::filesystem::path(launcherName).stem().string();
145+
const auto prefix = stem + ".";
146+
std::error_code ec;
147+
for (auto const& entry : std::filesystem::directory_iterator(builtDir, ec)) {
148+
if (ec) break;
149+
auto name = entry.path().filename().string();
150+
if (name == launcherName || !name.starts_with(prefix)) continue;
151+
std::error_code fec;
152+
if (!entry.is_regular_file(fec)) continue;
153+
if (name == stem + ".wasm") out.hasWasm = true;
154+
out.siblings.push_back(std::move(name));
155+
}
156+
std::ranges::sort(out.siblings);
157+
return out;
158+
}
159+
125160
} // namespace mcpp::pack

0 commit comments

Comments
 (0)