Skip to content

@extern is an unchecked disaster waiting to happen #1

Description

@ApparentlyPlus

What it does today

@extern tells Gata that a C function exists somewhere, so you can call it:

@extern int func lib_probe(int n);

realm kernel { entry func Main() { let int r = lib_probe(3); } }

The declaration goes into Gata's symbol table, is checked against the arguments you pass whenever you call the function, and the type checker ensures that the resulting value (in the above case, int) follows normal semantic analysis.

The problem with all of the above is that it relies on hopes and dreams, not reality, because whether or not an extern's definition is correct is not something appa can tangibly check. It lives right at the boundary where Gata stops and C begins, and appa can only see one side of that boundary. It knows what you said the function looks like. It has no way of knowing what the function actually looks like in C.

Two things fall out of that, and they behave very differently

  1. Nothing tells C about your extern definition:

    The declaration is Gata-side only, and appa does not emit a prototype for it purely from the extern definition, because that goes against what the assumption of an extern is in the first place — that the function exists somewhere, is in scope, and is callable. This means the emitted unit calls a name that it never declared and assumes that someone else did.

    That's an implicit declaration, which C99 deprecated, C23 removed, and gcc 14+ rejects outright under every -std. The reason this hasn't been a daily annoyance is pure luck of layout. libgata's externs are all _env_*, and env.GatOS.g defines every one of them in a native { } block that lands in the same translation unit, so a declaration was always sitting there anyway.

  2. If the declaration is wrong, nobody finds out:

    Write @extern int func f(int) when the real symbol is long f(double), and appa will happily type-check every call site against your version, emit the call, link it (because gcc compiles that across translation units), and hand you a program that computes nonsense. There's no pass that catches this, and there's nothing for a pass to catch it with. The actual function isn't in the compiler's world at all.

The first one at least fires at build time. The second is silent, and quite frankly, unpatchable given the scope of the project.

The fixes that look obvious are all wrong

  1. Emit the prototype ourselves

    I tried this and reverted it.

    shared.h is included by the preamble, so a generated prototype arrives before any native { } definition of the same name, and static int f(int) following extern int f(int) is a C error rather than a harmless duplicate, which is precisely the shape every environment's floor uses.

    On top of that, Gata has no const, so a generated prototype for @extern int func puts(char* s) conflicts with the real one in <stdio.h>:

    @extern int func puts(char* s);
    
    realm userspace {
        entry func Main() {
            let char* p = null;
            let int r = puts(p);
        }
    }

    The emitted program.c:

    #include <stdio.h>          /* line 16, from the environment preamble */
    ...
    #include "shared.h"         /* line 61 */
    
    void gata_kernelspace_main(void)
    {
        char* p = NULL;
        int32_t r = puts(p);
    }

    Add the prototype the emitter would write into shared.h:

    extern int32_t puts(char*);

    and the translation unit stops compiling:

    In file included from program.c:61:
    shared.h:28:16: error: conflicting types for ‘puts’; have ‘int32_t(char *)’ {aka ‘int(char *)’}
       28 | extern int32_t puts(char*);
          |                ^~~~
    In file included from program.c:13:
    /usr/include/stdio.h:718:12: note: previous declaration of ‘puts’ with type ‘int(const char *)’
      718 | extern int puts (const char *__s);
          |            ^~~~
    

    See the issue? puts expects const char * in <stdio.h> and our emitted prototype uses char * because Gata has no way to express const. Even if it did, that wouldn't solve the issue.

    If you try to fix this by simply emitting a prototype into shared.h, you get a C compiler error because Gata lacks const, as you saw. To fix that specific error, you'd have to teach Gata about const. But then you'd hit the next issue (e.g., restrict, volatile, or platform-specific size_t).

    You can't patch it "a little bit" because the patch itself relies on accurately replicating C's semantic rules. Every edge case you patch forces you to build another piece of the C compiler. You are essentially playing whack-a-mole with the C standard.

    So then why not introduce a guard for each problem? Because both guards would be trying to answer the same question: does a C declaration for this name already exist in this translation unit?

    Neither could actually see that, so one would grep the native {} text for the identifier and the other would bail out on any signature with a pointer in it. Two guesses wearing a trench coat. This would be glorified patchwork.

    The pointer guard is the one that stings the most. When a header is in the translation unit, emitting our prototype makes the C compiler line the two declarations up next to each other — which is the only moment anywhere in the toolchain where a wrong @extern can be caught. That conflicting types for 'puts' error wasn't the fix misfiring, it was the fix working: the declaration genuinely doesn't describe the function, and Gata has no way to write one that does. So the guard I had added switched off the check in exactly the case where it had something to say.

  2. Point at the header instead

    Something like @header("<stdio.h>"). This amounts to telling the emitter "don't emit", which is the same as not declaring the extern and using a native { } block instead, which you can already do today. You'd be adding syntax that documents intent and verifies nothing.

  3. Let @extern spell C types directly

    Now the declaration is right, but appa still can't check a call against it without mapping C types back to Gata types. Write both signatures and neither validates the other; write only the C one and call sites go unchecked. Either way you've arrived at a native { } block with extra steps.

  4. Read the actual header

    @cImport, bindgen, Swift's clang importer style. This one genuinely works, and it is a C declaration parser with typedef and macro expansion and a type-mapping policy behind it. Everyone who took C interop seriously ended up here, for the same reason we keep circling: a restated signature can't be checked, so you parse the source of truth instead of the restatement. This would be another project on its own right, because we'd have to properly parse and analyze C itself, maintain a C-to-Gata type mapping, and do this, and do that, and ..., and ..., and ....

    This is also the only correct patch, and one I am willing to forego on the account that this is a student project.

So where does that leave it

Every option above moves the assertion somewhere else. The compiler guesses, or the author asserts, or the author asserts twice, and not one of them checks it. Checking means appa's view of the function has to be derived from C rather than stated next to it, and deriving it means
writing the parser. That's the whole reason there's no cheap fix here, and why the ostrich algorithm is the right way to address this.

By the way, none of which makes @extern useless, as long as you know what it's actually buying you. It checks your calls against your declaration, so arity mistakes, typos and Gata-side type confusion still get caught — all things a bare native { } block would swallow without a word. It gives you internal consistency. It just can't give you external correctness, but it never claimed to.

In the meantime, the documented workaround is to write both halves and keep them in sync yourself:

native { void* lookup_handle(const char* name); } // the declaration C needs
@extern Process func lookup_handle(char* name); // the declaration Gata needs

TL;DR: @extern is effectively a "trust me bro" annotation.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingwontfixThis will not be worked on

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions