Skip to content

Adjust allocation strategy for TinyGo-derived modules so they don't immediately crash. #2243

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 4 commits into
base: main
Choose a base branch
from

Conversation

erikrose
Copy link
Contributor

@erikrose erikrose commented Jun 18, 2025

Should fix fastly/Viceroy#491. Please see that (plus commit messages) for context.

@erikrose

This comment was marked as outdated.

@erikrose

This comment was marked as outdated.

@erikrose erikrose force-pushed the tiny-go-realloc branch 2 times, most recently from 0986d4b to 77891db Compare July 10, 2025 14:19
erikrose added 4 commits July 10, 2025 12:47
…mmediately crash.

You can run the TinyGo empty project in Viceroy now (once Viceroy is updated to depend on the new wit-component herein); it immediately crashed before. It serves a request and returns a 0 exit code, somewhat obscured by a backtrace because an empty project doesn't implement a Reactor.

The crux of this update is that the GC phase of adapter application now takes a `ReallocScheme` arg to its `encode()` method. This represents slightly richer advice on how to find or construct a `realloc()` function for the adapter to use to allocate its state. Before, it took only an `Option`: `None` meant "use `memory.grow`", and `Some(such_and_such)` meant "use a realloc function called `such_and_such`, provided in the module being adapted". Now we can also say "construct a realloc routine using the given malloc routine found in the module being adapted". This lets us communicate to TinyGo's GC that we have reserved some RAM, so it doesn't stomp on us later.
…loc if the former is provided.

This allows TinyGo programs to take control over reallocation if they desire, elevating an explicit API over the heuristic identification of `malloc()`.

It turns out it was already doing this, through the call to `realloc_to_import_into_adapter()`, which returns an (even more specific) `cabi_realloc_adapter()` if there is one and otherwise `cabi_realloc`.
In this case, we have no non-crashing way of allocating memory for the adapter state.
@erikrose erikrose marked this pull request as ready for review July 10, 2025 17:00
@erikrose erikrose requested a review from a team as a code owner July 10, 2025 17:00
@erikrose erikrose requested review from pchickey and removed request for a team July 10, 2025 17:00
Copy link
Member

@alexcrichton alexcrichton left a comment

Choose a reason for hiding this comment

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

Can you detail a bit more here why this is necessary? We've so far survived with zero language-specific configurations/hacks (AFAIK) throughout component toolchain tooling and personally I'd like to see it remain that way. More specifically, why can't this be fixed in the Go-specific tooling? Or why can't this be an opt-in option that the Go tooling passes?

@erikrose
Copy link
Contributor Author

erikrose commented Jul 11, 2025

Can you detail a bit more here why this is necessary?

Absolutely! Basically, we would like to run existing TinyGo-compiled modules as components. So, while an upstream fix would be ideal for the future, we have a back catalog of compiled artifacts to somehow make work.

TinyGo makes an assumption in its memory management that violates no specs but conflicts with assumptions made by the memory.grow-based allocator in the adaptation code. Specifically, the memory.grow happens (as far as I can infer), and then TinyGo says "How big is the heap? I'll take all that for my own!". Then it immediately stomps on the adapter's State struct. Heck, if you squint, you might even conclude our adaptation code is making an invalid assumption.

The fix for new code should be something like having TinyGo export a cabi_realloc (which it does if you target WASIp2). That'll hit existing happy paths in the adaptation code and shouldn't require changes from us. OTOH, one way to look at it is that TinyGo already does export a cabi_realloc, except with the wrong name and type signature. This patch detects that and wraps it to work.

Of course, it would be perfectly possible to pre-process our TinyGo modules outside wit-component: we could inject the same manufactured cabi_realloc right into the customer module and then go down the usual adaptation paths. The advantages to doing it in wit-component are…

  1. It saves complexity (passes, branches, etc.) for Fastly and other similar folks.

  2. It doesn't modify the customer module, which feels like a moral good to me.

  3. It solves this pretty hairy crash transparently for others who need to run arbitrary third-party code as components.

    Admittedly, it's an unsatisfying breach of elegance, but the alternative is for everyone to independently discover, diagnose, and search out the solution to this. I did try to structure it in the most generic way possible, with the name of the malloc-like routine a variable and the TinyGo detection off by itself, because I can forsee us discovering the same troublesome assumption in other languages. The name tiny_go did persist in some field names because I thought it most clearly answered "why the heck is this here?", but we could genericize those and exile mention of TinyGo to comments if that makes it more palatable.

Alternative solutions are most welcome! But others we could gin up were ickier, and hopefully WASIp2 will relegate all this to an historical footnote.

@alexcrichton
Copy link
Member

alexcrichton commented Jul 12, 2025

I agree this is probably the best place to solve this, but to put this into context as a reviewer:

  • The title/description of this PR don't meaningfully explain what's going on in the commit. While the description points to Empty TinyGo project crashes under component adapter fastly/Viceroy#491 that's an issue on a separate project that also isn't a clear "this is why this is being done this way in wasm-tools". Basically as a reviewer coming to read this there's no direction of where this is going, so I'm left to my own devices to reverse-engineer that all from the code.
  • The code itself documents what it's doing but it doesn't meanigfully explain why it's doing so. That's fine as such documentation is more relevant for a PR/commit messages but I'm still left wondering why this is being done.
  • There are no tests in this PR so there's no meaningful way to explore what modification is being done beyond I'm relatively confident that it's not changing anything pre-existing.

It unfortunately also doesn't help that the encode function is a beast of a function that's already very difficult to understand what's happening with realloc. That's not your fault of course but this is going to unfortuantely make matters worse as the realloc logic is getting more complicated.

This can then also all be coupled with the fact that while we all want components to work generally there's still a needle to thread in what's supported where. While I don't believe this PR is doing this, at one extreme it's not reasonable to saddle an open source project with all the legacy burdens and baggage of a specific embedding. The other extreme of blissfully ignoring reality is also not appropriate for an open source project as well, and inevitably the balance is going to be somewhere in the middle.

At the end of the day if no one knows how or is willing to update the TinyGo toolchain that's an extremely burdensome requirement to carry. Support for a new platform like components is almost always best done with a give-and-take style approach where embedders, toolchains, languages, etc, are all in a balance of what concerns are handled where. If a toolchain cannot budge from a single position then it, in my opinion, needs to be an extremely high value target to justify saddling everyone else with that burden.

Basically this is my rationale for "I think this is best done with an opt-in flag". I don't want the baggage here to proliferate to other toolchains and other languages by default. We already have a means of solving the problems you're encountering for other languages and for a variety of reasons it sounds like TinyGo and/or the surrounding support isn't going to implement those solutions. Given that, I at least personally believe that from an open source project perspective the best way to support this is to land this here but behind an off-by-default flags the explicitly requires users to activate. That's where documentation can be updated etc.


On the slightly more technical front, I'm more-or-less taking you at face value that this is the best place to solve this. I do not personally have the energy to boot up on everything TinyGo-related and see if I have a different way forward for this. From my current understanding I don't know, for example, if this is a temporary hack for preexisting modules or a permanent feature intended for all future productions of TinyGo modules. If the former, just for preexisting modules, that feels understandable to me. If the latter, all future modules, I don't really have any intuition for why that is the case.

@erikrose
Copy link
Contributor Author

Tests are on the way! I had hoped to have them laid in before anyone got around to reviewing, but they proved trickier than expected, and my attention was divided. I apologize for the delay. Please feel free to look away while I finish them. I will turn this back into a Draft until then.

For this PR's motivation and problem statement, beyond what's in the initial commit message, I'd reference this comment block in fallback_realloc_scheme(). That explains why we trigger for TinyGo specifically. The comments on ReallocScheme leave out mention of TinyGo, since the schemes are meant to be potentially generic. Instead, they give an overview of what each scheme does, Malloc being the new one.

I share your concern that encode() is a beast! I preserved existing behavior and improved the commenting on existing code where it was difficult to reverse-engineer. But there are still spots where the logic escapes me—that's partly why I didn't attempt a larger, clarifying refactor, beyond such effort being outside the scope of this PR. It certainly could use such a refactor, though; there's too much state blowing around in there. One possibility I experimented with was encapsulating the state involved with tracking functions added by the adapter: map.funcs, func_names, num_func_imports. At first glance, these all have to change in parallel, and there's an opportunity to enforce that. However, my first few attempts weren't a clear win for comprehension, and there were exceptions, which is another reason I put it aside. Something like that, if it can be made a clear win, would be a good future PR.

From my current understanding I don't know, for example, if this is a temporary hack for preexisting modules or a permanent feature intended for all future productions of TinyGo modules. If the former, just for preexisting modules, that feels understandable to me.

It's just for preexisting modules. New builds should target WASIp2, e.g. GOOS=wasip2 GOARCH=wasm tinygo build -o main.wasm main.go. That provides a cabi_realloc(), which leads the adaptation code down a happier path, cued by ReallocScheme::Realloc, which uses the provided cabi_realloc() to communicate the adapter's storage use (for its State struct) to Go's GC.

if no one knows how or is willing to update the TinyGo toolchain

The TinyGo wasm toolchain is well maintained, having p1 and p2 support as well as a dedicated maintainer on Fastly's staff. I think any ambiguity about the allocation of responsiblity among tools is an accident of timing. As I understand it, providing cabi_realloc() as part of the adapted module is a WASIp2 affordance; toolchains that know about only p1 generally don't provide it. There is blurring around this due to some needs of Fermyon before p2 came out, but conversations with @sunfishcode have persuaded me that p2 is the historically intended cutover point.

Basically this is my rationale for "I think this is best done with an opt-in flag". I don't want the baggage here to proliferate to other toolchains and other languages by default.

Me neither. That's why the baggage is so narrowly scoped. It activates only (1) if TinyGo is in the producers section and (2) no cabi_realloc() is provided (so we're talking about a p1 module here with no awareness of p2) and (3) a malloc() is exported and (4) malloc() has the expected signature. There is thus a vanishingly small chance of it kicking in spuriously. And it will naturally veto itself as people move to p2 builds and beyond. Thus, I would like to have this on by default, rather than making every person encountering an opaque panic bounce off Stack Overflow to find the flag.

Let me know if I can fill in more details about anything, and I'm eager to hear your feedback either way. (Take your time, as I'll be out Thursday and Friday.) Thanks!

@erikrose erikrose marked this pull request as draft July 17, 2025 00:19
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.

Empty TinyGo project crashes under component adapter
2 participants