Inject ambient context into Go calls

This commit is contained in:
pavel 2026-08-27 22:31:10 +02:00
commit 5085ae51aa
8 changed files with 112 additions and 9 deletions

View file

@ -304,6 +304,21 @@ Use `coroutineContext()` only at Go interop boundaries that require a
`context.Context`; it returns the ambient scope context without exposing it in
the Gotlin function signature.
For conventional Go APIs whose first parameter is `context.Context`, Gotlin
normally injects that ambient context automatically when the argument is
omitted:
```kotlin
fun command(): *exec.Cmd {
return exec.commandContext("date")
}
```
The function becomes contextually effectful and lowers to
`exec.CommandContext(gotlinScope.Context(), "date")`. Passing an explicit
context remains supported and suppresses injection, which is important for HTTP
request contexts and deliberately detached work.
```kotlin
fun load(): Int {
delay(10)

View file

@ -116,6 +116,48 @@ fun main() { runBlocking { useContext() } }`)
}
}
func TestGoContextIsInjectedFromAmbientScope(t *testing.T) {
program, err := Parse(`package demo
import exec os.exec
fun command(): *exec.Cmd { return exec.commandContext("date") }
fun main() { runBlocking { println(command()) } }`)
if err != nil {
t.Fatal(err)
}
output, err := GenerateGo(program)
if err != nil {
t.Fatal(err)
}
for _, expected := range []string{"func command(gotlinScope *GotlinCoroutineScope) *exec.Cmd", `exec.CommandContext(gotlinScope.Context(), "date")`, "command(gotlinScope)"} {
if !strings.Contains(string(output), expected) {
t.Fatalf("missing %q:\n%s", expected, output)
}
}
call := program.Functions[0].Body[0].(ReturnStmt).Value.(CallExpr)
goCall, ok := call.Meta.Semantic.Node.(HIRGoCall)
if !ok || !goCall.InjectContext || !goCall.Variadic {
t.Fatalf("Go call context metadata = %#v", call.Meta.Semantic.Node)
}
}
func TestExplicitGoContextPreventsAmbientInjection(t *testing.T) {
program, err := Parse(`package demo
import context
import exec os.exec
fun command(ctx: context.Context): *exec.Cmd { return exec.commandContext(ctx, "date") }`)
if err != nil {
t.Fatal(err)
}
output, err := GenerateGo(program)
if err != nil {
t.Fatal(err)
}
code := string(output)
if strings.Contains(code, "func command(gotlinScope") || !strings.Contains(code, `exec.CommandContext(ctx, "date")`) {
t.Fatalf("explicit context was not preserved:\n%s", code)
}
}
func TestRunBlockingCancelsAndJoinsChildrenOnPanic(t *testing.T) {
prog, err := Parse(`package demo fun main() { runBlocking { launch { delay(1) }; panic("failed") } }`)
if err != nil {

View file

@ -120,6 +120,9 @@ func collectExpressionEffects(expression Expr, node *effectNode) {
if call, ok := semantic.Node.(HIRGotlinCall); ok && call.Target != nil {
node.callees = append(node.callees, call.Target)
}
if call, ok := semantic.Node.(HIRGoCall); ok && call.InjectContext {
node.direct |= CoroutineEffect
}
}
collectExpressionEffects(value.Callee, node)
for _, argument := range value.Args {

View file

@ -1217,6 +1217,12 @@ func (g *goGenerator) expr(expr Expr, expectedType string) (string, error) {
}
args = append([]string{g.currentCoroutineScope}, args...)
}
if call, ok := resolvedCall.Node.(HIRGoCall); ok && call.InjectContext {
if g.currentCoroutineScope == "" {
return "", fmt.Errorf("Go function requires an ambient coroutine context")
}
args = append([]string{g.currentCoroutineScope + ".Context()"}, args...)
}
}
callee, err := g.expr(e.Callee, "")
if err != nil {

View file

@ -108,13 +108,44 @@ func (semantic *SemanticProgram) goFieldType(receiver Type, name string) Type {
return UnknownType{}
}
func (semantic *SemanticProgram) goCallSignature(call CallExpr) (FunctionType, bool) {
selector, ok := call.Callee.(SelectorExpr)
if !ok {
return FunctionType{}, false
}
if receiver, ok := selector.Receiver.(IdentExpr); ok && semantic.Imports[receiver.Name] && semantic.Packages[receiver.Name] == nil {
function, ok := semantic.goSelectorType(receiver.Name, selector.Name).(FunctionType)
return function, ok
}
receiverType := ResolvedType(selector.Receiver)
function, ok := semantic.goMethodType(receiverType, selector.Name).(FunctionType)
return function, ok
}
func shouldInjectCoroutineContext(call CallExpr, signature FunctionType) bool {
if len(signature.Params) == 0 || signature.Params[0].String() != "context.Context" {
return false
}
if len(call.Args) > 0 && ResolvedType(call.Args[0]).String() == "context.Context" {
return false
}
if signature.Variadic {
minimumWithoutContext := len(signature.Params) - 2
if minimumWithoutContext < 0 {
minimumWithoutContext = 0
}
return len(call.Args) >= minimumWithoutContext
}
return len(call.Args) == len(signature.Params)-1
}
func semanticTypeFromGoSignature(signature *gotypes.Signature) Type {
params := make([]Type, signature.Params().Len())
for index := range params {
params[index] = semanticTypeFromGo(signature.Params().At(index).Type())
}
result := semanticTypeFromGoResults(signature.Results())
return FunctionType{Params: params, Result: result}
return FunctionType{Params: params, Result: result, Variadic: signature.Variadic()}
}
func semanticTypeFromGoResults(results *gotypes.Tuple) Type {

View file

@ -48,6 +48,9 @@ func (HIRReference) hirNode() {}
type HIRGoCall struct {
Callee *HIRExpr
Result Type
Params []Type
Variadic bool
InjectContext bool
}
func (HIRGoCall) hirNode() {}
@ -498,7 +501,8 @@ func (resolver *semanticResolver) hirNode(expr Expr, semantic *HIRExpr) HIRNode
return HIRReference{Target: semantic.Symbol}
case GoCallExpr:
if call, ok := expr.(CallExpr); ok {
return HIRGoCall{Callee: exprMeta(call.Callee), Result: semantic.Type}
signature, _ := resolver.program.goCallSignature(call)
return HIRGoCall{Callee: exprMeta(call.Callee), Result: semantic.Type, Params: signature.Params, Variadic: signature.Variadic, InjectContext: shouldInjectCoroutineContext(call, signature)}
}
case GotlinCallExpr, MethodCallExpr:
return HIRGotlinCall{Target: semantic.Symbol, Result: semantic.Type}

View file

@ -79,7 +79,8 @@ fun accountQuery(): GotlinSQLQuery {
func TestGeneratedQueryCanBePassedToVariadicPGXCall(t *testing.T) {
code := compileSQL(t, accountRowSource+`
import pgxpool "github.com/jackc/pgx/v5/pgxpool"
fun execute(pool: *pgxpool.Pool, ctx: Context, customerId: String) {
import context
fun execute(pool: *pgxpool.Pool, ctx: context.Context, customerId: String) {
val query = sql.from<AccountRow>().where { it.customerId == customerId }.build()
pool.query(ctx, query.sql, *query.args)
}

View file

@ -66,6 +66,7 @@ type FunctionType struct {
Params []Type
Result Type
Effects Effect
Variadic bool
}
func (FunctionType) typeNode() {}
@ -175,7 +176,7 @@ func resolveClassTypes(typ Type, classes map[string]*ClassSymbol) Type {
for i, param := range value.Params {
params[i] = resolveClassTypes(param, classes)
}
return FunctionType{Params: params, Result: resolveClassTypes(value.Result, classes)}
return FunctionType{TypeParams: value.TypeParams, Params: params, Result: resolveClassTypes(value.Result, classes), Effects: value.Effects, Variadic: value.Variadic}
case GenericType:
args := make([]Type, len(value.Args))
for i, arg := range value.Args {
@ -220,7 +221,7 @@ func resolveImportedTypes(typ Type, packages map[string]*PackageSymbol) Type {
for index, param := range value.Params {
params[index] = resolveImportedTypes(param, packages)
}
return FunctionType{TypeParams: value.TypeParams, Params: params, Result: resolveImportedTypes(value.Result, packages), Effects: value.Effects}
return FunctionType{TypeParams: value.TypeParams, Params: params, Result: resolveImportedTypes(value.Result, packages), Effects: value.Effects, Variadic: value.Variadic}
default:
return typ
}
@ -242,7 +243,7 @@ func resolveTypeParameters(typ Type, params map[string]bool) Type {
for index, param := range value.Params {
resolved[index] = resolveTypeParameters(param, params)
}
return FunctionType{TypeParams: value.TypeParams, Params: resolved, Result: resolveTypeParameters(value.Result, params), Effects: value.Effects}
return FunctionType{TypeParams: value.TypeParams, Params: resolved, Result: resolveTypeParameters(value.Result, params), Effects: value.Effects, Variadic: value.Variadic}
case GenericType:
args := make([]Type, len(value.Args))
for index, arg := range value.Args {
@ -303,7 +304,7 @@ func substituteType(typ Type, bindings map[string]Type) Type {
for index, param := range value.Params {
params[index] = substituteType(param, bindings)
}
return FunctionType{TypeParams: value.TypeParams, Params: params, Result: substituteType(value.Result, bindings), Effects: value.Effects}
return FunctionType{TypeParams: value.TypeParams, Params: params, Result: substituteType(value.Result, bindings), Effects: value.Effects, Variadic: value.Variadic}
default:
return typ
}