Skip to content
Merged
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
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@
- Provide a Linux x86_64 AppImage for Ubuntu 22.04 and newer, with AppImageUpdate-compatible GitHub release deltas.

### Fixes
- Keep recording splitting and background processing available when the UDP telemetry port is already in use.
- Report inconclusive historical lap classification as limited without requesting attention, and correct earlier results.
- Center Le Mans Ultimate track outlines within catalogue cards.
- Select and deselect child laps with the Sessions table header checkbox, preserving selections on other pages and ownership tabs.
- Stop recording curb maps and using detected curbs to recalibrate track boundaries; preserve telemetry-based curb detection.
Expand Down Expand Up @@ -79,6 +81,7 @@
- Skip LMU outline requests when stored track names do not identify one layout.

### Internal
- Ignore terminal color codes when waiting for compiler startup in the Paraglide lifecycle CI test.
- Generate Paraglide declarations with the installed native TypeScript compiler and stop abandoned translation watchers from consuming CPU after development exits.
- Use Ubuntu instead of macOS for CI runners and cap Mars memory requests at 6 GB.
- Invalidate cached frontend builds when source changes in dependent workspaces.
Expand Down
7 changes: 5 additions & 2 deletions apps/backend/src/runtime/boot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -155,7 +155,11 @@ export async function bootServer(options: BootOptions = {}): Promise<RunningServ
);

const udpPort = options.udpPort ?? (Number(process.env.RACEIQ_DEV_UDP_PORT) || settings.udpPort || Number(process.env.UDP_PORT) || 5301);
await udpListener.start(udpPort);
try {
await udpListener.start(udpPort);
} catch (error) {
console.error(`[UDP] Unable to listen on port ${udpPort}; recording processing remains available:`, error);
}


nativeSources = startNativeSourceSupervisor(recordingGameId);
Expand All @@ -175,7 +179,6 @@ export async function bootServer(options: BootOptions = {}): Promise<RunningServ
startMaintenanceJobs();

console.log("[Server] RaceIQ Server is ready!");
console.log(`[Server] Listening for UDP on port ${udpPort}`);

return { httpServer, httpPort: boundHttpPort, udpPort };
}
45 changes: 33 additions & 12 deletions apps/backend/test/e2e/classification-migration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -220,9 +220,10 @@ test("an obsolete terminal failure requeues a newer source revision before repor
});
await Bun.write(join(barrierDir, "duringDecodeBatch.release"), "");
const final = await settled(running.address);
expect(final.failed).toBe(1);
expect(final.failed).toBe(0);
expect(final.limited).toBe(1);
expect(database.prepare("SELECT state,source_revision,attempt,reason FROM classification_migration_recordings").get()).toMatchObject({
state: "failed", source_revision: 2, attempt: 1, reason: "ambiguous_match",
state: "limited", source_revision: 2, attempt: 1, reason: "ambiguous_match",
});
expect(preservedLaps(database)).toEqual(original);
} finally {
Expand Down Expand Up @@ -287,7 +288,7 @@ test("normal boot classifies historical recordings in place and skips completed
}
}, 240_000);

test("missing source is visible attention without retries or metadata loss; feature Retry durably requeues", async () => {
test("missing source completes with limitations without retries or metadata loss; feature Retry durably requeues", async () => {
const directory = await mkdtemp(join(tmpdir(), "raceiq-classification-missing-"));
const dataDir = join(directory, "data");
let server: UpgradeServer | undefined;
Expand All @@ -306,7 +307,7 @@ test("missing source is visible attention without retries or metadata loss; feat
const running = await openUpgrade(dataDir);
server = running.server;
const first = await settled(running.address);
expect(first.status).toBe("attention");
expect(first.status).toBe("complete");
expect(first.limited).toBeGreaterThan(0);
expect(first.failed).toBe(0);
expect(database.prepare("SELECT classification,is_valid,invalid_reason FROM laps ORDER BY id LIMIT 1").get()).toMatchObject({ classification: "out_lap", is_valid: 0, invalid_reason: "outlap" });
Expand Down Expand Up @@ -411,7 +412,7 @@ for (const barrier of ["beforeTransaction", "insideTransaction", "afterCommitBef
}

for (const defect of ["ambiguous_match", "corrupt_source"] as const) {
test(`${defect} fails the recording atomically without changing saved lap identity`, async () => {
test(`${defect} preserves saved laps and distinguishes limitations from execution failures`, async () => {
const directory = await mkdtemp(join(tmpdir(), "raceiq-classification-failure-"));
const dataDir = join(directory, "data");
let server: UpgradeServer | undefined;
Expand Down Expand Up @@ -446,16 +447,36 @@ for (const defect of ["ambiguous_match", "corrupt_source"] as const) {
server = running.server;
// Corrupt historical input blocks startup and therefore aggregate publication.
const snapshot = await settled(running.address, defect !== "corrupt_source");
expect(snapshot.status).toBe(defect === "corrupt_source" ? "running" : "attention");
expect(snapshot.failed).toBeGreaterThan(0);
expect(snapshot.status).toBe(defect === "corrupt_source" ? "running" : "complete");
expect(snapshot.failed).toBe(defect === "corrupt_source" ? 1 : 0);
expect(snapshot.limited).toBe(defect === "ambiguous_match" ? 1 : 0);
if (defect === "corrupt_source") expect(snapshot.aggregatesPending).toBe(true);
const response = await fetch(`${running.address}/api/sessions/classification-migration-recordings`);
expect(response.status).toBe(200);
const list = await response.json() as { recordings: Array<{ state: string; reason: string }> };
expect(list.recordings.some((recording) => recording.state === "failed" && recording.reason === defect)).toBe(true);
expect(list.recordings).toContainEqual(expect.objectContaining({
state: defect === "ambiguous_match" ? "limited" : "failed", reason: defect,
}));
expect(preservedLaps(database)).toEqual(original);
expect(database.prepare("SELECT id,is_valid,invalid_reason FROM laps ORDER BY id").all()).toEqual(quality);
expect(database.prepare("SELECT id FROM laps WHERE classification IS NOT NULL").all()).toEqual([]);
if (defect === "ambiguous_match") {
const attempts = database.prepare("SELECT recording_id,attempt FROM classification_migration_recordings ORDER BY recording_id").all();
const revision = (await status(running.address)).revision;
await server.stop();
database.exec("UPDATE classification_migration_recordings SET state='failed' WHERE reason='ambiguous_match'");
const resumed = await openUpgrade(dataDir);
server = resumed.server;
const repaired = await settled(resumed.address);
expect(repaired.failed).toBe(0);
expect(repaired.limited).toBe(1);
expect(repaired.status).toBe("complete");
expect(repaired.revision).toBeGreaterThan(revision);
expect(database.prepare("SELECT recording_id,attempt FROM classification_migration_recordings ORDER BY recording_id").all()).toEqual(attempts);
expect(preservedLaps(database)).toEqual(original);
expect(database.prepare("SELECT id,is_valid,invalid_reason FROM laps ORDER BY id").all()).toEqual(quality);
expect(database.prepare("SELECT id FROM laps WHERE classification IS NOT NULL").all()).toEqual([]);
}
} finally {
await server?.stop();
database?.close();
Expand Down Expand Up @@ -591,17 +612,17 @@ test("#222 scalar-matching capture preserves ambiguous historical boundaries wit
const running = await openUpgrade(dataDir);
server = running.server;
const snapshot = await settled(running.address);
expect(snapshot.status).toBe("attention");
expect(snapshot.limited).toBe(1);
expect(snapshot.failed).toBe(1);
expect(snapshot.status).toBe("complete");
expect(snapshot.limited).toBe(2);
expect(snapshot.failed).toBe(0);
expect(database.prepare("SELECT id FROM laps WHERE classification='grid_start'").all()).toEqual([]);
expect(database.prepare("SELECT COUNT(*) AS count FROM laps WHERE classification IS NULL").get()).toMatchObject({ count: 5 });
expect(preservedLaps(database)).toEqual(original);
expect(database.prepare("SELECT id,is_valid,invalid_reason FROM laps ORDER BY id").all()).toEqual(quality);
const response = await fetch(`${running.address}/api/sessions/classification-migration-recordings`);
const list = await response.json() as { recordings: Array<{ state: string; reason: string }> };
// Its first stored range differs from current detection; four later exact matches cannot justify a partial commit.
expect(list.recordings).toContainEqual(expect.objectContaining({ state: "failed", reason: "ambiguous_match" }));
expect(list.recordings).toContainEqual(expect.objectContaining({ state: "limited", reason: "ambiguous_match" }));
expect(list.recordings).toContainEqual(expect.objectContaining({ state: "limited", reason: "missing_source" }));
const firstPageResponse = await fetch(`${running.address}/api/sessions/classification-migration-recordings?limit=1`);
const firstPage = await firstPageResponse.json() as { recordings: Array<{ recordingId: string }>; nextCursor: string | null };
Expand Down
50 changes: 47 additions & 3 deletions apps/backend/test/e2e/database-upgrade.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { expect, test } from "bun:test";
import Database from "libsql";
import { createHash } from "node:crypto";
import dgram from "node:dgram";
import { LAP_DETECTOR_AC_EVO_ID } from "@raceiq/game-ac-evo/lap-detector";
import { CLASSIFICATION_MIGRATION_VERSION } from "@raceiq/backend-core/session-capture/classification-migration";
import { LAP_DETECTOR_ID } from "@raceiq/backend-core/lap-detection/detector";
Expand Down Expand Up @@ -48,7 +49,7 @@ async function command(args: string[], cwd = ROOT, env = process.env) {
return stdout;
}

function startBranch(dataDir: string, pauseStage: "historical" | "lmu-identity" | "race-results" | "dashboard" | null = null): StartupBackend {
function startBranch(dataDir: string, pauseStage: "historical" | "lmu-identity" | "race-results" | "dashboard" | null = null, udpPort = 0): StartupBackend {
const proc = Bun.spawn([process.execPath, "--eval", `
import { captureConsole } from "./server/runtime/logger";
import { bootServer } from "./apps/backend/src/runtime/boot";
Expand Down Expand Up @@ -78,7 +79,7 @@ function startBranch(dataDir: string, pauseStage: "historical" | "lmu-identity"
console.log("[E2E] Stage control:", control.port);
}
captureConsole();
const backend = await bootServer({ httpPort: 0, udpPort: 0, recordingGameId: null, testDependencies });
const backend = await bootServer({ httpPort: 0, udpPort: ${udpPort}, recordingGameId: null, testDependencies });
const authSetup = await fetch("http://127.0.0.1:" + backend.httpServer.port + "/api/auth/setup", {
method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ password: null, exposureConfirmed: true }),
});
Expand Down Expand Up @@ -309,7 +310,7 @@ test("startup preserves ambiguous historical Tourist classification without chan
expect(database.prepare("SELECT * FROM sessions ORDER BY id").all()).toEqual(sessionsBeforeClassification);
expect(database.prepare("SELECT * FROM laps ORDER BY id").all()).toEqual(lapsBeforeClassification);
expect(outcomes.some((row) => row.state === "failed" || row.state === "limited")).toBe(true);
expect(outcomes).toContainEqual({ state: "failed", reason: "ambiguous_match" });
expect(outcomes).toContainEqual({ state: "limited", reason: "ambiguous_match" });
const resumedTimes = database.prepare("SELECT lap_time FROM laps ORDER BY id").all() as Array<{ lap_time: number }>;
expect(resumedTimes.map((lap) => lap.lap_time)).toEqual(lapMetadata.map(() => 123.456));
expect(database.prepare("SELECT id,session_id,notes,is_favorite,profile_id,car_setup,tune_id,experiment_id,experiment_version_id FROM laps ORDER BY id").all()).toEqual(lapMetadata);
Expand Down Expand Up @@ -490,6 +491,49 @@ for (const journalState of ["unfinished", "malformed", "unreadable"] as const) {
}, 60_000);
}

test.each([true, false])("recording splitting completes with UDP conflict %s", async (conflict) => {
const temporary = mkdtempSync(join(tmpdir(), "raceiq-udp-conflict-"));
const dataDir = join(temporary, "data");
const occupied = dgram.createSocket("udp4");
let backend: StartupBackend | undefined;
try {
const listening = Promise.withResolvers<void>();
occupied.once("error", listening.reject);
occupied.bind(0, "0.0.0.0", listening.resolve);
await listening.promise;
const port = conflict ? occupied.address().port : 0;
await command([process.execPath, "run", "db:seed", "--games=fm-2023"], ROOT, environment(dataDir));
backend = startBranch(dataDir, null, port);
if (!conflict) await backend.waitFor("[UDP] Listening on");
const initial = await backend.request("/api/storage/split-migration");
expect(initial.status).toBe(200);
expect(await initial.json()).toMatchObject({
sessionCount: 3, progress: { status: "idle", done: 0, total: 3 },
});
const confirmed = await backend.request("/api/storage/split-migration", {
method: "POST", headers: { "content-type": "application/json" }, body: "{}",
});
expect(confirmed.status).toBe(202);
const deadline = Date.now() + 30_000;
let progress;
do {
const response = await backend.request("/api/storage/split-migration");
expect(response.status).toBe(200);
progress = await response.json() as { sessionCount: number; progress: { status: string; done: number; total: number; migrated: number; failed: number } };
if (progress.progress.status !== "running") break;
// The separate backend process owns the real split-queue timer.
await Bun.sleep(100);
} while (Date.now() < deadline);
expect(progress).toMatchObject({
sessionCount: 0, progress: { status: "success", done: 3, total: 3, migrated: 3, failed: 0 },
});
} finally {
await backend?.stop();
occupied.close();
await rm(temporary, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 });
}
}, 60_000);

for (const captureFailure of ["missing", "corrupt"] as const) {
test(`${captureFailure} historical capture preserves pending repairs while HTTP remains available`, async () => {
const temporary = mkdtempSync(join(tmpdir(), "raceiq-startup-blocked-"));
Expand Down
2 changes: 1 addition & 1 deletion client/messages/de.json
Original file line number Diff line number Diff line change
Expand Up @@ -2483,7 +2483,7 @@
"storage_recording_summary": "{files} Aufnahmedateien · {size}",
"classification_migration_title": "Historische Rundenklassifizierung",
"classification_migration_historical_caveat": "Historische Pace-Ergebnisse können sich bei der Neuverarbeitung ändern.",
"classification_migration_complete": "Historische Aufzeichnungen klassifiziert.",
"classification_migration_complete": "Die Klassifizierung historischer Runden ist abgeschlossen.",
"classification_migration_attention": "Einige Aufzeichnungen benötigen Aufmerksamkeit; abgeschlossene Klassifizierungen bleiben verfügbar.",
"classification_migration_counts": "{completed} klassifiziert · {pending} ausstehend · {limited} eingeschränkt · {failed} fehlgeschlagen",
"classification_migration_refresh": "Aktualisieren",
Expand Down
2 changes: 1 addition & 1 deletion client/messages/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -2484,7 +2484,7 @@
"analyse_motec_unsupported_game": "MoTeC import is not supported for {game} yet. Use a native RaceIQ recording instead.",
"classification_migration_title": "Historical lap classification",
"classification_migration_historical_caveat": "Historical pace results may change while recordings are reprocessed.",
"classification_migration_complete": "Historical recordings classified.",
"classification_migration_complete": "Historical lap classification finished.",
"classification_migration_attention": "Some recordings need attention; completed classification remains available.",
"classification_migration_counts": "{completed} classified · {pending} pending · {limited} limited · {failed} failed",
"classification_migration_refresh": "Refresh",
Expand Down
2 changes: 1 addition & 1 deletion client/messages/es.json
Original file line number Diff line number Diff line change
Expand Up @@ -2467,7 +2467,7 @@
"storage_recording_summary": "{files} archivos de grabación · {size}",
"classification_migration_title": "Clasificación de vueltas históricas",
"classification_migration_historical_caveat": "Los resultados de ritmo históricos pueden cambiar al reprocesar las grabaciones.",
"classification_migration_complete": "Grabaciones históricas clasificadas.",
"classification_migration_complete": "La clasificación de vueltas históricas ha finalizado.",
"classification_migration_attention": "Algunas grabaciones requieren atención; las clasificaciones completadas siguen disponibles.",
"classification_migration_counts": "{completed} clasificadas · {pending} pendientes · {limited} limitadas · {failed} fallidas",
"classification_migration_refresh": "Actualizar",
Expand Down
2 changes: 1 addition & 1 deletion client/messages/fi.json
Original file line number Diff line number Diff line change
Expand Up @@ -2467,7 +2467,7 @@
"storage_recording_summary": "{files} tallennetiedostoa · {size}",
"classification_migration_title": "Historiallisten kierrosten luokittelu",
"classification_migration_historical_caveat": "Historialliset vauhtitulokset voivat muuttua tallenteiden käsittelyn aikana.",
"classification_migration_complete": "Historialliset tallenteet luokiteltu.",
"classification_migration_complete": "Historiallisten kierrosten luokittelu on valmis.",
"classification_migration_attention": "Osa tallenteista vaatii huomiota; valmiit luokitukset säilyvät.",
"classification_migration_counts": "{completed} luokiteltu · {pending} odottaa · {limited} rajoitettu · {failed} epäonnistui",
"classification_migration_refresh": "Päivitä",
Expand Down
2 changes: 1 addition & 1 deletion client/messages/fr.json
Original file line number Diff line number Diff line change
Expand Up @@ -2467,7 +2467,7 @@
"storage_recording_summary": "{files} fichiers d’enregistrement · {size}",
"classification_migration_title": "Classification des tours historiques",
"classification_migration_historical_caveat": "Les résultats de rythme historiques peuvent changer pendant le retraitement.",
"classification_migration_complete": "Enregistrements historiques classés.",
"classification_migration_complete": "La classification des tours historiques est terminée.",
"classification_migration_attention": "Certains enregistrements nécessitent votre attention.",
"classification_migration_counts": "{completed} classés · {pending} en attente · {limited} limités · {failed} en échec",
"classification_migration_refresh": "Actualiser",
Expand Down
2 changes: 1 addition & 1 deletion client/messages/hi.json
Original file line number Diff line number Diff line change
Expand Up @@ -2633,7 +2633,7 @@
"streaming_gap_to_leader": "लीडर से अंतर",
"classification_migration_title": "ऐतिहासिक लैप वर्गीकरण",
"classification_migration_historical_caveat": "रिकॉर्डिंग दोबारा संसाधित होने पर पुराने पेस परिणाम बदल सकते हैं।",
"classification_migration_complete": "ऐतिहासिक रिकॉर्डिंग वर्गीकृत की गईं।",
"classification_migration_complete": "ऐतिहासिक लैप का वर्गीकरण पूरा हुआ।",
"classification_migration_attention": "कुछ रिकॉर्डिंग पर ध्यान देना आवश्यक है; पूरा हुआ वर्गीकरण उपलब्ध है।",
"classification_migration_counts": "{completed} वर्गीकृत · {pending} लंबित · {limited} सीमित · {failed} विफल",
"classification_migration_refresh": "रीफ़्रेश करें",
Expand Down
2 changes: 1 addition & 1 deletion client/messages/it.json
Original file line number Diff line number Diff line change
Expand Up @@ -2467,7 +2467,7 @@
"storage_recording_summary": "{files} file di registrazione · {size}",
"classification_migration_title": "Classificazione giri storici",
"classification_migration_historical_caveat": "I risultati storici di ritmo possono cambiare durante la rielaborazione.",
"classification_migration_complete": "Registrazioni storiche classificate.",
"classification_migration_complete": "La classificazione dei giri storici è terminata.",
"classification_migration_attention": "Alcune registrazioni richiedono attenzione.",
"classification_migration_counts": "{completed} classificate · {pending} in attesa · {limited} limitate · {failed} non riuscite",
"classification_migration_refresh": "Aggiorna",
Expand Down
Loading
Loading