Skip to content

Link guests importing semver-compatible interface versions - #69

Merged
andreaTP merged 4 commits into
roastedroot:mainfrom
andreaTP:compatible-version-linking
Oct 21, 2026
Merged

andreaTP merged 4 commits into
roastedroot:mainfrom
andreaTP:compatible-version-linking

Conversation

@andreaTP

@andreaTP andreaTP commented Oct 20, 2026 •

Copy link
Copy Markdown
Contributor

Guests built by real toolchains import older patch versions of WASI interfaces, for example Rust 1.96's std imports wasi:io/poll@0.2.6, while our host provides @0.2.12. Import names were matched exactly, so linking failed.

This resolves host-provided imports by the Component Model's canonical interface name rule, the same one Wasmtime (alternate_lookup_key) and jco use:

  • An exact name always wins.
  • Otherwise the version is cut after the major (@1.2.3 → @1) or, for 0.x, after the minor (@0.2.6 → @0.2), and the highest provided version with the same key is used. Versions are compared numerically.
  • 0.0.x, pre-releases and unversioned names only match exactly. Build metadata is ignored, and two candidates that differ only in it fail as ambiguous.
  • It applies only at the host boundary. Nested instantiation stays exact.
  • The existing structural type check still runs, so a host missing a function or providing a mistyped one is rejected. The error then names both the requested and the supplied name.

Not included: export-side matching, bindgen version selection (the first half of #53), and parsing the versionsuffix attribute.

Tests: InterfaceVersionsTests (48) and CompatibleVersionImportTests (20). Refs #53.

@jeremyg484 jeremyg484 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.

Looks great!

@Phoenixxo Phoenixxo 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.

Overall, looks good. Just some simplifications that might be better to keep in mind

Comment on lines +288 to +294
Map.Entry<String, Object> resolved = builder.instance().resolveImport(imp.name());
try {
processImport(builder, imp, resolved);
} catch (LinkageException e) {
if (resolved == null || resolved.getKey().equals(imp.name())) {
throw e;
}

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.

resolveImport now runs for every import before the kind-specific code. An ambiguous set of host names fails the link even when the import wouldn’t have used a host value. An eq-bound type import or an instance type that demands nothing is synthesized when nothing is supplied, and it used to link regardless of what else the host provided. I think raising the ambiguity error only on the paths that consume the value would be best here.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

With this change a compatible host name counts as supplied for every kind, so an eq-bound type or a demand-nothing instance that finds one checks it the same way it would check an exact name. With two candidates tied on version there's no single value to check, and falling back to the synthesized one would quietly ignore both. It also takes a host that registers two names differing only in build metadata, which I'd rather report than work around. Happy to revisit if you have a real case.

processImport(builder, imp, resolved);
} catch (LinkageException e) {
if (resolved == null || resolved.getKey().equals(imp.name())) {
throw e;

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.

Small nitpick, but I think the rethrown exception puts e.getMessage() in its own message and also passes e as the cause, so the original text shows up twice in a stack trace.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

This follows the existing wrap at ComponentLinker.java:107. Keeping the reason in the message lets callers that only log getMessage() see it, and the cause keeps the frames where the mismatch was found.

Comment on lines +58 to +92
static String resolve(String requested, Collection<String> provided) {
if (provided.contains(requested)) {
return requested;
}
String key = compatibleKey(requested);
if (key == null) {
return null;
}
Version best = null;
Version tied = null;
for (String name : provided) {
Version candidate = Version.parse(name);
if (candidate == null || !candidate.key.equals(key)) {
continue;
}
int order = best == null ? 1 : PRECEDENCE.compare(candidate, best);
if (order > 0) {
best = candidate;
tied = null;
} else if (order == 0) {
tied = candidate;
}
}
if (tied != null) {
throw new LinkageException(
"Import \""
+ requested
+ "\" is ambiguous between \""
+ best.name
+ "\" and \""
+ tied.name
+ "\", which differ only in build metadata");
}
return best == null ? null : best.name;
}

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.

When there’s no exact match, resolve will run the semver regex over every provided name, and it does this for each import. A host with a few dozen WASI interfaces and a guest importing most of them at an older patch means well over a thousand matches per link. We could move to using an index on the host names by compatible key once, in addImports or lazily on first lookup, which would make each import a single map lookup.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Good catch. Older patch versions are exactly what Rust guests import, so every WASI import took the scan. Host names are now indexed by compatible key once per instantiation, lazily on the first lookup that misses an exact name, and each import is one map lookup. 3933ee7

*/
Map.Entry<String, Object> resolveImport(String name) {
String supplied;
if (parent == null) {

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.

parent == null repeats the check in ComponentLinker.atHostBoundary. If the definition of the host boundary ever changes, the two could disagree, so version matching and the host-only type checks would no longer apply at the same level. Can both go through one method?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Moved to ComponentInstance.atHostBoundary(). The linker uses it everywhere now, including the parent() == null check that was already in processComponentImport. 67237ef

default:
throw new LinkageException(
"Import type " + imp.externDesc().kind() + " not supported yet");
Map.Entry<String, Object> resolved = builder.instance().resolveImport(imp.name());

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 adds a Map.Entry parameter to five process*Import methods and allocates an entry per import, but the key is only used to build the error message in the catch block. Wouldn't it be simpler to resolve to the supplied name and read the value from the instance, or to keep the supplied name in one place for the error path?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

The process*Import signatures are back to what they were. hasImport/getImport resolve compatible names at the host boundary, and processImportSection keeps the supplied name only to word the error. 67237ef

@andreaTP

Copy link
Copy Markdown
Contributor Author

@Phoenixxo I addressed most of your comments, I'll move forward and merge this, feel free to add any additional comment and we can fix on top in follow up PRs

@andreaTP
andreaTP merged commit d2fd850 into roastedroot:main Oct 21, 2026
3 checks passed
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.

3 participants