Skip to content

Commit d3db795

Browse files
davidortinauCopilot
andcommitted
Fix stuck video imports: stale cleanup and retry for stuck imports
- Add CleanupStaleImportsAsync() to VideoImportPipelineService — marks imports stuck in-progress for >10 minutes as Failed - Allow RetryImportAsync to retry stuck imports (not just Failed ones) - Import.razor auto-cleans stale imports on page load - Import.razor shows Retry button for stuck imports (>10 min old) - API retry endpoint accepts stuck imports too (not just Failed) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent bb81ffe commit d3db795

4 files changed

Lines changed: 288 additions & 8 deletions

File tree

Lines changed: 210 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,210 @@
1+
# iOS Release Build Troubleshooting Guide
2+
3+
**Date:** December 2024
4+
**Last Updated:** December 2024
5+
6+
## Overview
7+
8+
This document captures critical issues discovered during iOS Release build and production configuration. These issues primarily affect release builds (AOT compilation), fresh app installations, and authentication workflows. This guide is a reference for future developers to prevent regressions.
9+
10+
---
11+
12+
## Issue 1: Fresh Install Crash — PatchMissingColumnsAsync
13+
14+
### Symptom
15+
App starts but no data loads. Logs show repeated errors: `no such table: LearningResource` and similar table-not-found errors.
16+
17+
### Root Cause
18+
The database initialization sequence is wrong. `PatchMissingColumnsAsync()` runs **before** `MigrateAsync()`. On fresh installs (empty database), it attempts to ALTER TABLE on tables that don't exist yet. The exception is caught silently (around line 203 in SyncService.cs), so `MigrateAsync()` never executes, and the database schema is never created.
19+
20+
### Fix
21+
Added a SQLite `sqlite_master` table existence check before any ALTER TABLE statement. If a table doesn't exist, skip the patch operation — `MigrateAsync()` will create it with all required columns.
22+
23+
**File:** `src/SentenceStudio.Shared/Services/SyncService.cs``PatchMissingColumnsAsync()`
24+
25+
```csharp
26+
// Check if table exists in sqlite_master before attempting ALTER TABLE
27+
using var cmd = connection.CreateCommand();
28+
cmd.CommandText = "SELECT name FROM sqlite_master WHERE type='table' AND name=?";
29+
cmd.Parameters.AddWithValue("@table", tableName);
30+
var exists = cmd.ExecuteScalar() != null;
31+
32+
if (!exists) continue; // Skip patch if table doesn't exist yet
33+
```
34+
35+
---
36+
37+
## Issue 2: EF Core 10 Model Building on iOS Release (AOT)
38+
39+
### Symptom
40+
Build succeeds but database queries fail in Release builds with error: `Model building is not supported when publishing with NativeAOT`. All data operations fail.
41+
42+
### Root Cause
43+
iOS Release builds use Mono Full AOT (Ahead-of-Time compilation). In this mode, `RuntimeFeature.IsDynamicCodeSupported` is `false`. EF Core 10 refuses to dynamically build its data model at runtime. The model is **lazily built on first query**, not during `MigrateAsync()`, so Release builds fail when queries execute.
44+
45+
### Fix (Temporary)
46+
Added `<UseInterpreter>true</UseInterpreter>` to the Release PropertyGroup in the iOS .csproj file. This enables the Mono interpreter alongside AOT, allowing dynamic code execution for EF Core model building.
47+
48+
**File:** `src/SentenceStudio.iOS/SentenceStudio.iOS.csproj`
49+
50+
```xml
51+
<PropertyGroup Condition="'$(Configuration)' == 'Release'">
52+
<UseInterpreter>true</UseInterpreter>
53+
</PropertyGroup>
54+
```
55+
56+
### Fix (Proper - Recommended)
57+
Generate a compiled data model using:
58+
```bash
59+
dotnet ef dbcontext optimize --project src/SentenceStudio.Shared
60+
```
61+
Then configure EF Core to use the compiled model. This eliminates runtime model building entirely. See [Microsoft.EntityFrameworkCore.Tasks](https://learn.microsoft.com/en-us/ef/core/performance/advanced-performance-topics#using-compiled-models) for details.
62+
63+
---
64+
65+
## Issue 3: Logout Black Screen
66+
67+
### Symptom
68+
Tapping Logout button navigates to a black screen or does nothing. App remains in authenticated state.
69+
70+
### Root Cause
71+
`Logout()` method in NavMenu was synchronous but required async operations:
72+
- Never called `MauiAuthenticationStateProvider.LogOutAsync()` to clear auth state
73+
- Navigated to `/auth` (profile picker) instead of `/auth/login` (actual login page)
74+
- No async handling prevented proper state cleanup
75+
76+
### Fix
77+
1. Made `Logout()` async
78+
2. Injected `AuthenticationStateProvider`
79+
3. Called `MauiAuthenticationStateProvider.LogOutAsync()` to clear auth state
80+
4. Changed navigation target from `/auth` to `/auth/login`
81+
82+
**File:** `src/SentenceStudio.UI/Layout/NavMenu.razor`
83+
84+
```csharp
85+
private async Task Logout()
86+
{
87+
if (AuthStateProvider is MauiAuthenticationStateProvider provider)
88+
{
89+
await provider.LogOutAsync();
90+
}
91+
Navigation.NavigateTo("/auth/login", forceLoad: true);
92+
}
93+
```
94+
95+
---
96+
97+
## Issue 4: No Data After Login
98+
99+
### Symptom
100+
Login succeeds and user is authenticated, but dashboard shows "No vocabulary data yet" instead of synced content.
101+
102+
### Root Cause
103+
CoreSync initial sync fires at app startup (before login) and receives HTTP 401 Unauthorized. After successful login, no re-sync is triggered, so local data remains empty.
104+
105+
### Fix
106+
Added post-login sync trigger in `Index.razor`. When the user is authenticated but local data is empty, manually call `SyncService.TriggerSyncAsync()` to pull data immediately after login.
107+
108+
**File:** `src/SentenceStudio.UI/Pages/Index.razor`
109+
110+
```csharp
111+
protected override async Task OnInitializedAsync()
112+
{
113+
if (IsAuthenticated && !HasLocalData)
114+
{
115+
await SyncService.TriggerSyncAsync();
116+
}
117+
}
118+
```
119+
120+
---
121+
122+
## Issue 5: Production Config Not Loading
123+
124+
### Symptom
125+
Release builds connect to localhost instead of Azure. Production endpoints are ignored.
126+
127+
### Root Cause
128+
`ConfigurationExtensions.cs` only loaded the base `appsettings.json` file. Environment-specific configuration files (`appsettings.Production.json`) were never loaded as embedded resources.
129+
130+
### Fix
131+
Added environment detection and conditional loading:
132+
- Debug builds load `appsettings.Development.json`
133+
- Release builds load `appsettings.Production.json`
134+
135+
Both are added as embedded resources in the project file and overlaid on the base configuration.
136+
137+
**Files:**
138+
- `src/SentenceStudio.AppLib/Setup/ConfigurationExtensions.cs` — Updated to detect environment and load appropriate config
139+
- `src/SentenceStudio.AppLib/appsettings.Production.json` — Contains Azure endpoints and production settings
140+
141+
```csharp
142+
var environmentSpecificFile =
143+
#if DEBUG
144+
"appsettings.Development.json";
145+
#else
146+
"appsettings.Production.json";
147+
#endif
148+
149+
config.AddJsonFile($"SentenceStudio.AppLib.{environmentSpecificFile}",
150+
optional: false, reloadOnChange: false);
151+
```
152+
153+
---
154+
155+
## Issue 6: EnableILStrip Error on iOS Release
156+
157+
### Symptom
158+
Build fails with PE file or ILStrip error when building iOS Release on .NET 10.
159+
160+
### Root Cause
161+
iOS Release builds attempt IL stripping by default in .NET 10. Some libraries or configurations conflict with this process.
162+
163+
### Fix
164+
Disable IL stripping by adding `-p:EnableILStrip=false` to the build command.
165+
166+
**Build Command:**
167+
```bash
168+
dotnet build -f net10.0-ios -c Release -p:RuntimeIdentifier=ios-arm64 -p:EnableILStrip=false
169+
```
170+
171+
---
172+
173+
## Issue 7: Install on Physical Device
174+
175+
### Symptom
176+
Deploying to physical iOS devices via `dotnet build -t:Run` is slow over WiFi.
177+
178+
### Solution
179+
Use Xcode's device control CLI for faster WiFi deployment:
180+
181+
```bash
182+
xcrun devicectl device install app --device {UDID} path/to/SentenceStudio.iOS.app/
183+
```
184+
185+
Retrieve device UDID:
186+
```bash
187+
xcrun devicectl list devices
188+
```
189+
190+
This method is significantly faster than `dotnet build -t:Run` for iterative device testing.
191+
192+
---
193+
194+
## Prevention Checklist
195+
196+
- [ ] **Database Init:** Verify `PatchMissingColumnsAsync()` checks `sqlite_master` before ALTER TABLE
197+
- [ ] **AOT Compatibility:** For EF Core, either use compiled models or enable `UseInterpreter` in Release builds
198+
- [ ] **Authentication:** After logout, always navigate to login page with `forceLoad: true`
199+
- [ ] **Post-Login Sync:** Trigger CoreSync after successful login if local data is empty
200+
- [ ] **Environment Config:** Ensure `appsettings.{Environment}.json` is loaded as embedded resource
201+
- [ ] **Build Flags:** Include `-p:EnableILStrip=false` in iOS Release builds
202+
- [ ] **Device Deployment:** Use `xcrun devicectl` for faster physical device iteration
203+
204+
---
205+
206+
## References
207+
208+
- [EF Core Compiled Models](https://learn.microsoft.com/en-us/ef/core/performance/advanced-performance-topics#using-compiled-models)
209+
- [.NET MAUI iOS Deployment](https://learn.microsoft.com/en-us/dotnet/maui/ios/deployment/)
210+
- [Xcrun Device Control CLI](https://developer.apple.com/documentation/devicectl)

src/SentenceStudio.Api/ImportEndpoints.cs

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -111,9 +111,13 @@ private static async Task<IResult> RetryImport(
111111
if (import.UserProfileId != userProfileId)
112112
return Results.Forbid();
113113

114-
// Only retry failed imports
115-
if (import.Status != VideoImportStatus.Failed)
116-
return Results.BadRequest("Only failed imports can be retried");
114+
// Only retry failed or stuck imports
115+
var isStuck = import.Status != VideoImportStatus.Failed
116+
&& import.Status != VideoImportStatus.Completed
117+
&& import.CreatedAt < DateTime.UtcNow.AddMinutes(-10);
118+
119+
if (import.Status != VideoImportStatus.Failed && !isStuck)
120+
return Results.BadRequest("Only failed or stuck imports can be retried. In-progress imports must be older than 10 minutes.");
117121

118122
// Reset status and retry
119123
import.Status = VideoImportStatus.Pending;

src/SentenceStudio.Shared/Services/VideoImportPipelineService.cs

Lines changed: 46 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -64,15 +64,25 @@ public async Task<List<VideoImport>> GetImportHistoryAsync(string userProfileId,
6464
}
6565

6666
/// <summary>
67-
/// Retry a failed import by resetting its status and re-running the pipeline.
67+
/// Retry a failed or stuck import by resetting its status and re-running the pipeline.
68+
/// Accepts Failed imports and any in-progress import older than the stale threshold.
6869
/// </summary>
6970
public async Task RetryImportAsync(string importId)
7071
{
7172
var import = await GetImportByIdAsync(importId);
7273
if (import == null)
7374
throw new InvalidOperationException($"Import {importId} not found");
74-
if (import.Status != VideoImportStatus.Failed)
75-
throw new InvalidOperationException($"Import {importId} is not in Failed state");
75+
76+
if (import.Status == VideoImportStatus.Completed)
77+
throw new InvalidOperationException($"Import {importId} is already completed");
78+
79+
// Allow retry for Failed, or any in-progress import older than 10 minutes (stuck)
80+
var isStuck = import.Status != VideoImportStatus.Failed
81+
&& import.Status != VideoImportStatus.Completed
82+
&& import.CreatedAt < DateTime.UtcNow.AddMinutes(-10);
83+
84+
if (import.Status != VideoImportStatus.Failed && !isStuck)
85+
throw new InvalidOperationException($"Import {importId} is still in progress. Wait or retry after 10 minutes.");
7686

7787
import.Status = VideoImportStatus.Pending;
7888
import.ErrorMessage = null;
@@ -82,6 +92,39 @@ public async Task RetryImportAsync(string importId)
8292
await RunPipelineAsync(import);
8393
}
8494

95+
/// <summary>
96+
/// Marks in-progress imports older than the threshold as Failed.
97+
/// Call on page load or startup to recover from orphaned pipeline tasks.
98+
/// </summary>
99+
public async Task<int> CleanupStaleImportsAsync(TimeSpan? staleThreshold = null)
100+
{
101+
var threshold = staleThreshold ?? TimeSpan.FromMinutes(10);
102+
var cutoff = DateTime.UtcNow - threshold;
103+
104+
using var scope = _serviceProvider.CreateScope();
105+
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
106+
107+
var staleImports = await db.VideoImports
108+
.Where(vi => vi.Status != VideoImportStatus.Completed
109+
&& vi.Status != VideoImportStatus.Failed
110+
&& vi.CreatedAt < cutoff)
111+
.ToListAsync();
112+
113+
if (staleImports.Count == 0) return 0;
114+
115+
foreach (var import in staleImports)
116+
{
117+
import.Status = VideoImportStatus.Failed;
118+
import.ErrorMessage = $"Import timed out — stuck in {import.Status} for over {threshold.TotalMinutes:0} minutes.";
119+
import.CompletedAt = DateTime.UtcNow;
120+
_logger.LogWarning("Marking stale import {Id} ({Title}) as failed — was {Status} since {CreatedAt}",
121+
import.Id, import.VideoTitle, import.Status, import.CreatedAt);
122+
}
123+
124+
await db.SaveChangesAsync();
125+
return staleImports.Count;
126+
}
127+
85128
/// <summary>
86129
/// Gets the most recent failed import for a video, if any.
87130
/// </summary>

src/SentenceStudio.UI/Pages/Import.razor

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -228,16 +228,20 @@
228228
{
229229
var isClickable = import.Status == VideoImportStatus.Completed && !string.IsNullOrEmpty(import.LearningResourceId);
230230
var isFailed = import.Status == VideoImportStatus.Failed;
231+
var isStuck = !isFailed
232+
&& import.Status != VideoImportStatus.Completed
233+
&& import.CreatedAt < DateTime.UtcNow.AddMinutes(-10);
234+
var canRetry = isFailed || isStuck;
231235
<div class="list-group-item d-flex align-items-center gap-3 py-2">
232236
@RenderStatusBadge(import.Status)
233237
<span class="fw-semibold flex-grow-1 text-truncate @(isClickable ? "cursor-pointer" : "")"
234238
@onclick="() => OnImportRowClick(import)"
235-
title="@(isFailed && !string.IsNullOrEmpty(import.ErrorMessage) ? import.ErrorMessage : "")">
239+
title="@(isFailed && !string.IsNullOrEmpty(import.ErrorMessage) ? import.ErrorMessage : isStuck ? "Stuck click Retry to restart" : "")">
236240
@(import.VideoTitle ?? "Untitled")
237241
</span>
238242
<span class="text-secondary-ss text-truncate d-none d-md-inline" style="max-width:160px;">@GetChannelName(import.MonitoredChannelId)</span>
239243
<small class="text-secondary-ss text-nowrap">@import.CreatedAt.ToString("d")</small>
240-
@if (isFailed)
244+
@if (canRetry)
241245
{
242246
<button class="btn btn-sm btn-outline-warning flex-shrink-0" @onclick="() => RetryImport(import)"
243247
@onclick:stopPropagation="true" disabled="@(retryingImportId == import.Id)">
@@ -331,6 +335,24 @@
331335

332336
// Load channels and imports
333337
await Task.WhenAll(LoadChannels(), LoadImports());
338+
339+
// Cleanup stale imports (stuck in-progress for >10 min) so they show retry buttons
340+
try
341+
{
342+
if (VideoImportPipelineSvc != null)
343+
{
344+
var cleaned = await VideoImportPipelineSvc.CleanupStaleImportsAsync();
345+
if (cleaned > 0)
346+
{
347+
Logger.LogInformation("Cleaned up {Count} stale imports on page load", cleaned);
348+
await LoadImports(); // Refresh to show updated statuses
349+
}
350+
}
351+
}
352+
catch (Exception ex)
353+
{
354+
Logger.LogWarning(ex, "Stale import cleanup failed");
355+
}
334356

335357
// Start polling for active imports
336358
StartPolling();
@@ -472,6 +494,7 @@
472494
{
473495
try
474496
{
497+
// RetryImportAsync now handles both Failed and stuck imports
475498
await VideoImportPipelineSvc.RetryImportAsync(import.Id);
476499
}
477500
catch (Exception ex)

0 commit comments

Comments
 (0)