Skip to content

Commit 3077a53

Browse files
Fix Firestore GetQueryNamed runtime drift (#112)
1 parent 3338a5a commit 3077a53

8 files changed

Lines changed: 351 additions & 51 deletions

File tree

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
# Firebase Runtime Failure Backlog
2+
3+
This backlog tracks binding drifts that are concrete candidates for the runtime-failure remediation loop.
4+
5+
## Ready
6+
7+
| Case id | Target/member | Audit finding reference | Expected runtime failure mechanism | Proof status | Fix PR | Merged commit |
8+
| --- | --- | --- | --- | --- | --- | --- |
9+
| `cloudfirestore-getquerynamed` | `Firebase.CloudFirestore.Firestore.GetQueryNamed` | `output/firebase-binding-audit/report.json` -> `CloudFirestore` -> `GetQueryNamed` (`signature-drift`) | The binding currently exports `getQueryNamed:completion:` as `NSInputStream` instead of `NSString`, so native Firestore receives `__NSCFInputStream` and throws an Objective-C exception when it treats the argument like a string. | Proved locally on the unfixed binding. Evidence: `ObjCRuntime.ObjCException`, `NSInvalidArgumentException`, selector `-[__NSCFInputStream length]`, artifact `tests/E2E/Firebase.Foundation/artifacts/firebase-foundation-result-cloudfirestore-getquerynamed-failure.json`, log `tests/E2E/Firebase.Foundation/artifacts/firebase-foundation-sim-cloudfirestore-getquerynamed-failure.log`. | Pending | - |
10+
11+
## Investigating
12+
13+
No additional runtime-failure candidates are currently promoted.
14+
15+
## Done
16+
17+
None yet.

source/Firebase/CloudFirestore/ApiDefinition.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -448,7 +448,7 @@ interface Firestore
448448

449449
// - (void)getQueryNamed:(NSString *)name completion:(void (^)(FIRQuery *_Nullable query))completion
450450
[Export ("getQueryNamed:completion:")]
451-
void GetQueryNamed (NSInputStream bundleStream, Action<Query> completion);
451+
void GetQueryNamed (string name, Action<Query> completion);
452452
}
453453

454454
// @interface FIRTimestamp : NSObject <NSCopying>

tests/E2E/Firebase.Foundation/FirebaseFoundationE2E/FirebaseFoundationE2E.csproj

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,13 @@
1515
<IsPackable>false</IsPackable>
1616
<ProvisioningType>manual</ProvisioningType>
1717
<EnableNullabilityValidation>false</EnableNullabilityValidation>
18+
<RuntimeDriftCase></RuntimeDriftCase>
19+
<RuntimeDriftCaseMethod></RuntimeDriftCaseMethod>
20+
<RuntimeDriftCasePropsPath></RuntimeDriftCasePropsPath>
1821
</PropertyGroup>
1922

23+
<Import Project="$(RuntimeDriftCasePropsPath)" Condition="'$(RuntimeDriftCasePropsPath)' != '' and Exists('$(RuntimeDriftCasePropsPath)')" />
24+
2025
<PropertyGroup Condition="'$(EnableNullabilityValidation)' == 'true'">
2126
<DefineConstants>$(DefineConstants);ENABLE_NULLABILITY_VALIDATION</DefineConstants>
2227
</PropertyGroup>
@@ -42,6 +47,17 @@
4247
<BundleResource Include="GoogleService-Info.plist" Condition="Exists('GoogleService-Info.plist')" />
4348
</ItemGroup>
4449

50+
<ItemGroup Condition="'$(RuntimeDriftCase)' != ''">
51+
<AssemblyAttribute Include="System.Reflection.AssemblyMetadataAttribute">
52+
<_Parameter1>RuntimeDriftCase</_Parameter1>
53+
<_Parameter2>$(RuntimeDriftCase)</_Parameter2>
54+
</AssemblyAttribute>
55+
<AssemblyAttribute Include="System.Reflection.AssemblyMetadataAttribute" Condition="'$(RuntimeDriftCaseMethod)' != ''">
56+
<_Parameter1>RuntimeDriftCaseMethod</_Parameter1>
57+
<_Parameter2>$(RuntimeDriftCaseMethod)</_Parameter2>
58+
</AssemblyAttribute>
59+
</ItemGroup>
60+
4561
<ItemGroup>
4662
<PackageReference Include="AdamE.Firebase.iOS.Analytics" Version="12.6.0" />
4763
<PackageReference Include="AdamE.Firebase.iOS.Core" Version="12.6.0" />
Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
using System.Reflection;
2+
3+
#if ENABLE_RUNTIME_DRIFT_CASE_CLOUDFIRESTORE_GETQUERYNAMED
4+
using Firebase.CloudFirestore;
5+
using Foundation;
6+
using ObjCRuntime;
7+
#endif
8+
9+
namespace FirebaseFoundationE2E;
10+
11+
static class FirebaseRuntimeDriftCases
12+
{
13+
static readonly TimeSpan AsyncTimeout = TimeSpan.FromSeconds(5);
14+
15+
public static string? GetConfiguredCaseId()
16+
{
17+
return GetAssemblyMetadataValue("RuntimeDriftCase");
18+
}
19+
20+
public static async Task<string> ExecuteConfiguredCaseAsync()
21+
{
22+
var caseId = GetConfiguredCaseId();
23+
if (string.IsNullOrWhiteSpace(caseId))
24+
{
25+
throw new InvalidOperationException("Runtime drift mode was requested without a RuntimeDriftCase value.");
26+
}
27+
28+
var methodName = GetAssemblyMetadataValue("RuntimeDriftCaseMethod");
29+
if (string.IsNullOrWhiteSpace(methodName))
30+
{
31+
throw new InvalidOperationException($"Runtime drift case '{caseId}' is missing RuntimeDriftCaseMethod metadata.");
32+
}
33+
34+
var method = typeof(FirebaseRuntimeDriftCases).GetMethod(methodName, BindingFlags.Static | BindingFlags.NonPublic);
35+
if (method is null)
36+
{
37+
throw new InvalidOperationException($"Runtime drift case '{caseId}' points at missing method '{methodName}'.");
38+
}
39+
40+
if (method.Invoke(null, null) is not Task<string> task)
41+
{
42+
throw new InvalidOperationException($"Runtime drift case '{caseId}' method '{methodName}' did not return Task<string>.");
43+
}
44+
45+
return await task;
46+
}
47+
48+
static string? GetAssemblyMetadataValue(string key)
49+
{
50+
return typeof(FirebaseRuntimeDriftCases)
51+
.Assembly
52+
.GetCustomAttributes<AssemblyMetadataAttribute>()
53+
.FirstOrDefault(attribute => string.Equals(attribute.Key, key, StringComparison.Ordinal))
54+
?.Value;
55+
}
56+
57+
#if ENABLE_RUNTIME_DRIFT_CASE_CLOUDFIRESTORE_GETQUERYNAMED
58+
static async Task<string> VerifyCloudFirestoreGetQueryNamedAsync()
59+
{
60+
const string selector = "getQueryNamed:completion:";
61+
62+
var firestore = Firestore.SharedInstance;
63+
if (firestore is null)
64+
{
65+
throw new InvalidOperationException("Firebase.CloudFirestore.Firestore.SharedInstance returned null after App.Configure().");
66+
}
67+
68+
var queryName = "codex-firestore-missing-query";
69+
var callbackInvoked = false;
70+
var returnedQueryWasNull = false;
71+
NSException? marshaledException = null;
72+
MarshalObjectiveCExceptionMode? marshaledExceptionMode = null;
73+
var completionSource = new TaskCompletionSource<Query?>(TaskCreationOptions.RunContinuationsAsynchronously);
74+
75+
void OnMarshalObjectiveCException(object? sender, MarshalObjectiveCExceptionEventArgs args)
76+
{
77+
marshaledException ??= args.Exception;
78+
marshaledExceptionMode ??= args.ExceptionMode;
79+
}
80+
81+
Runtime.MarshalObjectiveCException += OnMarshalObjectiveCException;
82+
try
83+
{
84+
try
85+
{
86+
firestore.GetQueryNamed(queryName, query =>
87+
{
88+
callbackInvoked = true;
89+
returnedQueryWasNull = query is null;
90+
completionSource.TrySetResult(query);
91+
});
92+
}
93+
catch (ObjCException ex)
94+
{
95+
throw new InvalidOperationException(
96+
$"Selector '{selector}' should not throw after the binding fix, but observed {ex.GetType().FullName}. " +
97+
$"Runtime argument type: {queryName.GetType().FullName}. " +
98+
$"NSException.Name: {FormatDetail(marshaledException?.Name?.ToString())}. " +
99+
$"NSException.Reason: {FormatDetail(marshaledException?.Reason)}. " +
100+
$"Marshal mode: {FormatDetail(marshaledExceptionMode?.ToString())}.",
101+
ex);
102+
}
103+
104+
var completedTask = await Task.WhenAny(completionSource.Task, Task.Delay(AsyncTimeout));
105+
if (completedTask != completionSource.Task)
106+
{
107+
throw new TimeoutException(
108+
$"Selector '{selector}' did not invoke its completion callback within {AsyncTimeout.TotalSeconds} seconds after the binding fix.");
109+
}
110+
111+
var returnedQuery = await completionSource.Task;
112+
if (!callbackInvoked)
113+
{
114+
throw new InvalidOperationException(
115+
$"Selector '{selector}' completed without throwing, but the completion callback was never marked as invoked.");
116+
}
117+
118+
if (marshaledException is not null)
119+
{
120+
throw new InvalidOperationException(
121+
$"Selector '{selector}' completed, but Runtime.MarshalObjectiveCException captured unexpected NSException.Name '{marshaledException.Name}'. " +
122+
$"Reason: {FormatDetail(marshaledException.Reason)}. Marshal mode: {FormatDetail(marshaledExceptionMode?.ToString())}.");
123+
}
124+
125+
return
126+
$"Selector '{selector}' completed without ObjC exception after the binding fix. " +
127+
$"Runtime argument type: {queryName.GetType().FullName}. " +
128+
$"CallbackInvoked: {callbackInvoked}. " +
129+
$"ReturnedQueryWasNull: {returnedQueryWasNull}. " +
130+
$"ReturnedQueryType: {returnedQuery?.GetType().FullName ?? "<null>"}.";
131+
}
132+
finally
133+
{
134+
Runtime.MarshalObjectiveCException -= OnMarshalObjectiveCException;
135+
}
136+
}
137+
#endif
138+
139+
static string FormatDetail(string? value)
140+
{
141+
return string.IsNullOrWhiteSpace(value) ? "<empty>" : value;
142+
}
143+
}

tests/E2E/Firebase.Foundation/FirebaseFoundationE2E/FirebaseSelfTestRunner.cs

Lines changed: 62 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,11 @@ public static async Task RunAsync(StatusViewController statusViewController)
2626
#if ENABLE_NULLABILITY_VALIDATION
2727
await statusViewController.AppendLineAsync("Firebase nullability validation mode enabled.");
2828
#endif
29+
var runtimeDriftCase = FirebaseRuntimeDriftCases.GetConfiguredCaseId();
30+
if (!string.IsNullOrWhiteSpace(runtimeDriftCase))
31+
{
32+
await statusViewController.AppendLineAsync($"Runtime drift case mode enabled: {runtimeDriftCase}");
33+
}
2934

3035
try
3136
{
@@ -44,74 +49,82 @@ await ExecuteCaseAsync(result, statusViewController, "ConfigureApp", async () =>
4449
return $"Configured app '{defaultApp.Name}'.";
4550
});
4651

47-
await ExecuteCaseAsync(result, statusViewController, "CoreSurface", async () =>
52+
if (!string.IsNullOrWhiteSpace(runtimeDriftCase))
4853
{
49-
var defaultApp = App.DefaultInstance ?? throw new InvalidOperationException("Firebase.Core.App.DefaultInstance returned null.");
50-
var firebaseVersion = App.FirebaseVersion;
51-
52-
if (string.IsNullOrWhiteSpace(firebaseVersion))
54+
await ExecuteCaseAsync(result, statusViewController, $"RuntimeDrift:{runtimeDriftCase}", () =>
55+
FirebaseRuntimeDriftCases.ExecuteConfiguredCaseAsync());
56+
}
57+
else
58+
{
59+
await ExecuteCaseAsync(result, statusViewController, "CoreSurface", async () =>
5360
{
54-
throw new InvalidOperationException("Firebase.Core.App.FirebaseVersion returned an empty value.");
55-
}
61+
var defaultApp = App.DefaultInstance ?? throw new InvalidOperationException("Firebase.Core.App.DefaultInstance returned null.");
62+
var firebaseVersion = App.FirebaseVersion;
5663

57-
result.FirebaseVersion = firebaseVersion;
58-
return $"Firebase version: {firebaseVersion}; app name: {defaultApp.Name}.";
59-
});
64+
if (string.IsNullOrWhiteSpace(firebaseVersion))
65+
{
66+
throw new InvalidOperationException("Firebase.Core.App.FirebaseVersion returned an empty value.");
67+
}
68+
69+
result.FirebaseVersion = firebaseVersion;
70+
return $"Firebase version: {firebaseVersion}; app name: {defaultApp.Name}.";
71+
});
6072

6173
#if ENABLE_NULLABILITY_VALIDATION
62-
await ExecuteCaseAsync(result, statusViewController, "CoreNullabilitySurface", () =>
63-
FirebaseNullabilityValidation.VerifyCoreNullabilityAsync());
74+
await ExecuteCaseAsync(result, statusViewController, "CoreNullabilitySurface", () =>
75+
FirebaseNullabilityValidation.VerifyCoreNullabilityAsync());
6476

65-
await ExecuteCaseAsync(result, statusViewController, "AnalyticsNullabilitySurface", () =>
66-
FirebaseNullabilityValidation.VerifyAnalyticsNullabilityAsync());
77+
await ExecuteCaseAsync(result, statusViewController, "AnalyticsNullabilitySurface", () =>
78+
FirebaseNullabilityValidation.VerifyAnalyticsNullabilityAsync());
6779

68-
await ExecuteCaseAsync(result, statusViewController, "AppCheckNullabilitySurface", () =>
69-
FirebaseNullabilityValidation.VerifyAppCheckNullabilityAsync());
80+
await ExecuteCaseAsync(result, statusViewController, "AppCheckNullabilitySurface", () =>
81+
FirebaseNullabilityValidation.VerifyAppCheckNullabilityAsync());
7082

71-
await ExecuteCaseAsync(result, statusViewController, "CloudMessagingNullabilitySurface", () =>
72-
FirebaseNullabilityValidation.VerifyCloudMessagingNullabilityAsync());
83+
await ExecuteCaseAsync(result, statusViewController, "CloudMessagingNullabilitySurface", () =>
84+
FirebaseNullabilityValidation.VerifyCloudMessagingNullabilityAsync());
7385

74-
await ExecuteCaseAsync(result, statusViewController, "CloudFirestoreNullabilitySurface", () =>
75-
FirebaseNullabilityValidation.VerifyCloudFirestoreNullabilityAsync());
86+
await ExecuteCaseAsync(result, statusViewController, "CloudFirestoreNullabilitySurface", () =>
87+
FirebaseNullabilityValidation.VerifyCloudFirestoreNullabilityAsync());
7688

77-
await ExecuteCaseAsync(result, statusViewController, "CrashlyticsNullabilitySurface", () =>
78-
FirebaseNullabilityValidation.VerifyCrashlyticsNullabilityAsync());
89+
await ExecuteCaseAsync(result, statusViewController, "CrashlyticsNullabilitySurface", () =>
90+
FirebaseNullabilityValidation.VerifyCrashlyticsNullabilityAsync());
7991

80-
await ExecuteCaseAsync(result, statusViewController, "DatabaseNullabilitySurface", () =>
81-
FirebaseNullabilityValidation.VerifyDatabaseNullabilityAsync());
92+
await ExecuteCaseAsync(result, statusViewController, "DatabaseNullabilitySurface", () =>
93+
FirebaseNullabilityValidation.VerifyDatabaseNullabilityAsync());
8294

83-
await ExecuteCaseAsync(result, statusViewController, "PerformanceNullabilitySurface", () =>
84-
FirebaseNullabilityValidation.VerifyPerformanceNullabilityAsync());
95+
await ExecuteCaseAsync(result, statusViewController, "PerformanceNullabilitySurface", () =>
96+
FirebaseNullabilityValidation.VerifyPerformanceNullabilityAsync());
8597
#endif
8698

87-
await ExecuteCaseAsync(result, statusViewController, "InstallationsSurface", async () =>
88-
{
89-
var installations = Installations.DefaultInstance ?? throw new InvalidOperationException("Firebase.Installations.Installations.DefaultInstance returned null.");
90-
var installationId = await GetInstallationIdAsync(installations);
91-
result.InstallationsIdPreview = installationId.Length > 16
92-
? installationId[..8] + "..." + installationId[^8..]
93-
: installationId;
94-
return $"Installation id: {result.InstallationsIdPreview}.";
95-
});
96-
97-
await ExecuteCaseAsync(result, statusViewController, "AnalyticsSurface", async () =>
98-
{
99-
Analytics.SetAnalyticsCollectionEnabled(true);
100-
Analytics.SetConsent(new Dictionary<ConsentType, ConsentStatus>
99+
await ExecuteCaseAsync(result, statusViewController, "InstallationsSurface", async () =>
101100
{
102-
[ConsentType.AnalyticsStorage] = ConsentStatus.Granted,
103-
[ConsentType.AdStorage] = ConsentStatus.Denied,
101+
var installations = Installations.DefaultInstance ?? throw new InvalidOperationException("Firebase.Installations.Installations.DefaultInstance returned null.");
102+
var installationId = await GetInstallationIdAsync(installations);
103+
result.InstallationsIdPreview = installationId.Length > 16
104+
? installationId[..8] + "..." + installationId[^8..]
105+
: installationId;
106+
return $"Installation id: {result.InstallationsIdPreview}.";
104107
});
105108

106-
Analytics.LogEvent(EventNamesConstants.ScreenView.ToString(), new Dictionary<object, object>
109+
await ExecuteCaseAsync(result, statusViewController, "AnalyticsSurface", async () =>
107110
{
108-
[ParameterNamesConstants.ScreenName] = new NSString("firebase_nuget_e2e"),
109-
[ParameterNamesConstants.ScreenClass] = new NSString(nameof(FirebaseSelfTestRunner)),
110-
[ParameterNamesConstants.Success] = NSNumber.FromBoolean(true),
111+
Analytics.SetAnalyticsCollectionEnabled(true);
112+
Analytics.SetConsent(new Dictionary<ConsentType, ConsentStatus>
113+
{
114+
[ConsentType.AnalyticsStorage] = ConsentStatus.Granted,
115+
[ConsentType.AdStorage] = ConsentStatus.Denied,
116+
});
117+
118+
Analytics.LogEvent(EventNamesConstants.ScreenView.ToString(), new Dictionary<object, object>
119+
{
120+
[ParameterNamesConstants.ScreenName] = new NSString("firebase_nuget_e2e"),
121+
[ParameterNamesConstants.ScreenClass] = new NSString(nameof(FirebaseSelfTestRunner)),
122+
[ParameterNamesConstants.Success] = NSNumber.FromBoolean(true),
123+
});
124+
125+
return "Analytics collection and event APIs completed without throwing.";
111126
});
112-
113-
return "Analytics collection and event APIs completed without throwing.";
114-
});
127+
}
115128
}
116129
catch (Exception ex)
117130
{

tests/E2E/Firebase.Foundation/README.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,3 +80,15 @@ dotnet tool run dotnet-cake -- --target=nuget --names="Firebase.Analytics,Fireba
8080
```sh
8181
tools/e2e/run-firebase-foundation.sh --package-dir output --configuration Debug --enable-nullability-validation
8282
```
83+
84+
## Targeted runtime drift mode
85+
86+
The harness also supports a targeted runtime-drift lane for one binding drift at a time. This mode runs only `ConfigureApp` plus the selected drift case, so each remediation PR can prove the unfixed runtime failure locally, then keep only the success-oriented regression test in the final diff.
87+
88+
The checked-in case manifest lives at [`runtime-drift-cases.json`](./runtime-drift-cases.json), and the backlog/queue is tracked in [`docs/firebase-runtime-failure-backlog.md`](../../docs/firebase-runtime-failure-backlog.md).
89+
90+
Run a specific drift case with:
91+
92+
```sh
93+
tools/e2e/run-firebase-foundation.sh --package-dir output --configuration Debug --runtime-drift-case cloudfirestore-getquerynamed
94+
```
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
{
2+
"cases": [
3+
{
4+
"id": "cloudfirestore-getquerynamed",
5+
"method": "VerifyCloudFirestoreGetQueryNamedAsync",
6+
"bindingPackage": "AdamE.Firebase.iOS.CloudFirestore",
7+
"packages": [
8+
{
9+
"id": "AdamE.Firebase.iOS.CloudFirestore",
10+
"version": "12.6.0"
11+
}
12+
]
13+
}
14+
]
15+
}

0 commit comments

Comments
 (0)