Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions pkgs/jnigen/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
- Add docs about debugging.
- Add support for Kotlin interfaces with suspend functions. These can now be
implemented using Dart functions that return a `Future`.
- Fixed a bug where Kotlin's `DefaultConstructorMarker` was exposed in the
generated bindings.
- Namespace primitive types to avoid collisions with generated API names, eg
`bool`.
- Kotlin suspend functions with no result (a return type of `Unit`) now return
Expand Down
25 changes: 23 additions & 2 deletions pkgs/jnigen/lib/src/bindings/dart_generator.dart
Original file line number Diff line number Diff line change
Expand Up @@ -864,6 +864,10 @@ class _TypeGenerator extends TypeVisitor<String> {
},
);

if (!node.classDecl.isRenamed) {
// The class is not generated, fall back to `JObject`.
return super.visitDeclaredType(node);
}
final typeParams = allTypeParams.join(', ').encloseIfNotEmpty('<', '>');
final prefix = resolver?.resolvePrefix(node.classDecl) ?? '';
return '$prefix${node.classDecl.finalName}$typeParams$nullable';
Expand Down Expand Up @@ -1393,7 +1397,9 @@ ${modifier}final _$name = $_protectedExtension
} else {
s.writeAll(node.modifiers.map((m) => '$m '));
s.write('${node.returnType} ${node.name}(');
s.writeAll(node.params.map((p) => '${p.type} ${p.name}'), ', ');
// Filter out Kotlin synthetic params from documentation.
final docParams = node.params.where((p) => !p.isKotlinSynthetic);
s.writeAll(docParams.map((p) => '${p.type} ${p.name}'), ', ');
s.writeln(')`');
}
if (node.returnType is! PrimitiveType || node.isConstructor) {
Expand All @@ -1402,7 +1408,9 @@ ${modifier}final _$name = $_protectedExtension
node.javadoc?.accept(_DocGenerator(s, depth: 1));

// Used for inferring the type parameter from the given parameters.
// Exclude Kotlin synthetic params since they're not part of the Dart API.
final typeLocators = node.params
.where((p) => !p.isKotlinSynthetic)
.accept(_ParamTypeLocator(resolver: resolver))
.fold(<String, List<String>>{}, _mergeMapValues).map(
(key, value) =>
Expand All @@ -1424,15 +1432,22 @@ ${modifier}final _$name = $_protectedExtension
.join(_newLine(depth: 2));
// This is needed to keep the references alive in the scope while waiting
// for the FFI call.
// Filter out Kotlin synthetic params from local references since they're
// not part of the Dart API.
final localReferences = node.params
.where((p) => !p.isKotlinSynthetic)
.accept(const _ParamReference())
.where((ref) => ref.isNotEmpty)
.toList();
if (node.isConstructor) {
final className = node.classDecl.finalName;
final name = node.finalName;
final ctorName = name == 'new\$' ? className : '$className.$name';
final paramsDef = node.params.accept(_ParamDef(resolver)).delimited(', ');
// Filter out Kotlin synthetic params from the Dart API signature.
final dartApiParams =
node.params.where((p) => !p.isKotlinSynthetic).toList();
final paramsDef =
dartApiParams.accept(_ParamDef(resolver)).delimited(', ');
final typeParamsCall = node.classDecl.allTypeParams
.map((typeParam) => '$_typeParamPrefix${typeParam.name}')
.join(', ')
Expand Down Expand Up @@ -1705,6 +1720,12 @@ class _ParamCall extends Visitor<Param, String> {

@override
String visit(Param node) {
// Kotlin synthetic parameters (e.g., DefaultConstructorMarker) should
// always receive jNullReference in JNI calls.
if (node.isKotlinSynthetic) {
return '$_jni.jNullReference.pointer';
}

final nativeSuffix = node.type.accept(const _ToNativeSuffix());
final nonPrimitive = node.type is PrimitiveType ? '' : r'_$';
final paramCall = '$nonPrimitive${node.finalName}$nativeSuffix';
Expand Down
15 changes: 13 additions & 2 deletions pkgs/jnigen/lib/src/bindings/excluder.dart
Original file line number Diff line number Diff line change
Expand Up @@ -67,8 +67,19 @@ class _ClassExcluder extends Visitor<ClassDecl, void> {
final isPrivate = method.isPrivate;
final isAbstractCtor = method.isConstructor && node.isAbstract;
final isBridgeMethod = method.isSynthetic && method.isBridge;
final excluded =
isPrivate || isAbstractCtor || isBridgeMethod || isExcluded;

// Exclude synthetic Kotlin constructors with DefaultConstructorMarker.
// These are compiler-generated overloads for default parameters and
// should not be exposed in the Dart API.
final isSyntheticDefaultCtorMarker = method.isConstructor &&
method.isSynthetic &&
method.params.any((param) => param.isKotlinSynthetic);

final excluded = isPrivate ||
isAbstractCtor ||
isBridgeMethod ||
isSyntheticDefaultCtorMarker ||
isExcluded;
if (excluded) {
log.fine('Excluded method ${node.binaryName}#${method.name}');
}
Expand Down
22 changes: 22 additions & 0 deletions pkgs/jnigen/lib/src/bindings/kotlin_processor.dart
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,17 @@ class _KotlinClassProcessor extends Visitor<ClassDecl, void> {
method.accept(_KotlinGetterProcessor(getter));
} else if (setters[signature] case final setter?) {
method.accept(_KotlinSetterProcessor(setter));
} else {
if (method.isConstructor && method.isSynthetic) {
for (final param in method.params) {
if (param.type case final DeclaredType type) {
if (type.binaryName ==
'kotlin.jvm.internal.DefaultConstructorMarker') {
param.isKotlinSynthetic = true;
}
}
}
}
}
}
}
Expand Down Expand Up @@ -179,6 +190,17 @@ class _KotlinConstructorProcessor extends Visitor<Method, void> {
@override
void visit(Method node) {
_processParams(node.params, constructor.valueParameters);

// Mark DefaultConstructorMarker parameters as Kotlin synthetic.
// These are compiler-generated parameters for constructors with default
// values and should not be exposed in the Dart API.
for (final param in node.params) {
if (param.type case final DeclaredType type) {
if (type.binaryName == 'kotlin.jvm.internal.DefaultConstructorMarker') {
param.isKotlinSynthetic = true;
}
}
}
}
}

Expand Down
7 changes: 6 additions & 1 deletion pkgs/jnigen/lib/src/bindings/renamer.dart
Original file line number Diff line number Diff line change
Expand Up @@ -165,11 +165,16 @@ class _ClassRenamer implements Visitor<ClassDecl, void> {

_ClassRenamer(
this.config,
) : renamed = {...config.importedClasses.values};
) : renamed = {...config.importedClasses.values} {
for (final node in renamed) {
node.isRenamed = true;
}
}

@override
void visit(ClassDecl node) {
if (renamed.contains(node)) return;
node.isRenamed = true;
log.finest('Renaming ${node.binaryName}.');
renamed.add(node);

Expand Down
19 changes: 17 additions & 2 deletions pkgs/jnigen/lib/src/elements/elements.dart
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,9 @@ enum GenerationStage {
// `../generate_bindings.dart`.
unprocessed,
userVisitors,
excluder,
kotlinProcessor,
linker,
excluder,
renamer,
dartGenerator;

Expand Down Expand Up @@ -141,10 +141,12 @@ class ClassDecl with ClassMember, Annotated implements Element<ClassDecl> {
/// Final name of this class.
///
/// Populated by [Renamer].
@JsonKey(includeFromJson: false)
@override
late String finalName;

@JsonKey(includeFromJson: false)
bool isRenamed = false;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This seems like a bit of a hack, to work around the late initialization error. Usually late initialization errors are due to a bug, such as making assumptions about the initialization order that turn out to be false. Could you explain what was causing the late initialization error, and why this is necessary to fix it?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

This wasn’t a hack but a fix for an initialization ordering bug.

finalName was declared late and only assigned inside the renaming path. For elements that didn’t need renaming, that path never ran, so finalName stayed uninitialized and later access caused a LateInitializationError.

The original logic assumed the renamer always ran before finalName was used, which wasn’t true for all elements. That made the design rely on an implicit ordering invariant that wasn’t actually guaranteed.

The isRenamed flag fixes this by making the state explicit. Instead of guessing whether renaming happened based on a late field, we track it directly and only read finalName when it’s valid. This removes the ordering dependency and makes the generator’s behavior deterministic and easier to reason about.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The original logic assumed the renamer always ran before finalName was used, which wasn’t true for all elements. That made the design rely on an implicit ordering invariant that wasn’t actually guaranteed.

This doesn't really explain why the bug is happening, it's just restating the symptoms of the bug. Why are there elements that are being code-genned, but aren't being renamed? You should figure out why that's happening and fix that. This isRenamed flag simply hides that bug.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

The LateInitializationError was triggered in _TypeGenerator.visitDeclaredType when it accessed classDecl.finalName for a class that had been removed by the Excluder before the Renamer ran. Since finalName is only set during renaming, it was never initialized for that class.

The underlying issue was stage ordering. The old pipeline ran Excluder before KotlinProcessor, so synthetic constructors involving DefaultConstructorMarker weren’t marked early enough. They survived exclusion and reached codegen with a ClassDecl that never went through the renaming phase.

The fix addresses both sides: reorder the stages so synthetic members are filtered correctly, and guard _TypeGenerator so any non-renamed class safely falls back to JObject instead of crashing.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

So excluded elements are not being renamed, but are being code genned? That seems like the true bug here. Why are these excluded elements still being referenced during code generation? Shouldn't they be fully excluded?

@sagar-h007 sagar-h007 Feb 26, 2026

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

@liamappelbe You’re right ,the root issue is that exclusion currently works at the declaration level, not the type-reference level.

The Excluder removes constructors that reference DefaultConstructorMarker, but it doesn’t remove or rewrite the corresponding type references in the resolved AST. So DeclaredType nodes for classes that were never in Classes.decls can still show up in signatures the generator visits. Those classes were never seen by the Renamer, so finalName was never initialized, which caused the crash.

So it’s not that excluded elements are being generated,it’s that references to non-generated classes can still exist in the type graph, and the generator wasn’t handling them as external types.

The isRenamed check is just a safety guard so codegen behaves deterministically. The real fix should happen earlier in the pipeline, either by normalizing such types to a fallback (like JObject) during resolution, or by having the exclusion pass clean up dangling type references. I’m planning a follow-up change to address that properly.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Ok, so after the follow up change, are you planning to remove isRenamed?

In the meantime, the flag should probably be called isExcluded or something. The real thing you want to represent is this edge case where a node is excluded but not removed from the AST.


/// Name of the type class.
@JsonKey(includeFromJson: false)
String get typeClassName => '\$$finalName\$Type\$';
Expand Down Expand Up @@ -830,6 +832,16 @@ class Param with Annotated implements Element<Param> {
@JsonKey(includeFromJson: false)
late String finalName;

/// Whether this parameter is a Kotlin synthetic parameter
/// (e.g., DefaultConstructorMarker).
///
/// These parameters should be hidden from the generated Dart API but still
/// passed to the JNI constructor (as jNullReference).
///
/// Populated by [KotlinProcessor].
@JsonKey(includeFromJson: false)
bool isKotlinSynthetic = false;

factory Param.fromJson(Map<String, dynamic> json) => _$ParamFromJson(json);

Param clone({GenerationStage until = GenerationStage.userVisitors}) {
Expand All @@ -842,6 +854,9 @@ class Param with Annotated implements Element<Param> {
if (GenerationStage.linker <= until) {
cloned.method = method;
}
if (GenerationStage.kotlinProcessor <= until) {
cloned.isKotlinSynthetic = isKotlinSynthetic;
}
if (GenerationStage.renamer <= until) {
cloned.finalName = finalName;
}
Expand Down
2 changes: 1 addition & 1 deletion pkgs/jnigen/lib/src/generate_bindings.dart
Original file line number Diff line number Diff line change
Expand Up @@ -51,9 +51,9 @@ Future<void> generateJniBindings(Config config) async {
return classes.accept(visitor);
}

runStage(Excluder(config));
runStage(KotlinProcessor());
await runStage(Linker(config));
runStage(Excluder(config));
runStage(Renamer(config));
// classes.accept(const Printer());

Expand Down
Loading
Loading