Skip to content

Commit 4ab3c88

Browse files
committed
fix(layout): reject degenerate startup snapshots
1 parent e7be41a commit 4ab3c88

4 files changed

Lines changed: 96 additions & 38 deletions

File tree

path_mode/scripts/path_renderer.gd

Lines changed: 2 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -38,8 +38,6 @@ var _last_applied_background_path: String = "__unset__"
3838
const DOUBLE_CLICK_THRESHOLD := 0.5
3939
const MAX_INITIAL_UI_SETTINGS_RETRIES := 8
4040
const INITIAL_UI_SETTINGS_RETRY_DELAY := 0.15
41-
const BACKGROUND_MAX_DIMENSION := 2048
42-
const BACKGROUND_MAX_BYTES_HINT := 64 * 1024 * 1024
4341

4442
@onready var ui: PathModeUI = $"../UI"
4543

@@ -896,37 +894,8 @@ func _load_hdr_background_safely(path: String) -> Texture2D:
896894
push_warning("[PathRenderer] Failed to load imported HDR background resource: %s" % path)
897895
return null
898896

899-
var image := (imported_tex as Texture2D).get_image()
900-
if image == null:
901-
push_warning("[PathRenderer] Failed to extract HDR image data from imported resource: %s" % path)
902-
return null
903-
904-
var original_size := image.get_size()
905-
if original_size.x <= 0 or original_size.y <= 0:
906-
push_warning("[PathRenderer] Invalid HDR background dimensions for: %s" % path)
907-
return null
908-
909-
if image.get_format() != Image.FORMAT_RGBA8:
910-
image.convert(Image.FORMAT_RGBA8)
911-
912-
var largest_dimension := maxi(original_size.x, original_size.y)
913-
if largest_dimension > BACKGROUND_MAX_DIMENSION:
914-
var scale := float(BACKGROUND_MAX_DIMENSION) / float(largest_dimension)
915-
var resized_width := maxi(1, int(round(original_size.x * scale)))
916-
var resized_height := maxi(1, int(round(original_size.y * scale)))
917-
image.resize(resized_width, resized_height, Image.INTERPOLATE_LANCZOS)
918-
print("[PathRenderer] Downscaled HDR background from %s to %s to avoid large GPU uploads." % [str(original_size), str(image.get_size())])
919-
920-
var estimated_bytes := image.get_width() * image.get_height() * 4
921-
if estimated_bytes > BACKGROUND_MAX_BYTES_HINT:
922-
push_warning("[PathRenderer] HDR background still too large after resize (%s bytes): %s" % [estimated_bytes, path])
923-
return null
924-
925-
var texture := ImageTexture.create_from_image(image)
926-
if texture == null:
927-
push_warning("[PathRenderer] Failed to create ImageTexture from HDR background: %s" % path)
928-
else:
929-
_background_texture_cache[path] = texture
897+
var texture := imported_tex as Texture2D
898+
_background_texture_cache[path] = texture
930899
return texture
931900

932901

src/frontend/app.js

Lines changed: 77 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -457,6 +457,36 @@ async function persistStartupLayoutSnapshotRecord(record) {
457457
db.close();
458458
}
459459

460+
async function deleteStartupLayoutSnapshotRecord(fingerprint) {
461+
if (!fingerprint) {
462+
return;
463+
}
464+
465+
if (typeof localStorage !== 'undefined') {
466+
try {
467+
localStorage.removeItem(`${STARTUP_LAYOUT_SNAPSHOT_LS_PREFIX}${fingerprint}`);
468+
} catch (_err) {
469+
// Ignore localStorage cleanup failures.
470+
}
471+
}
472+
473+
const db = await openStartupLayoutSnapshotDb();
474+
if (!db) {
475+
return;
476+
}
477+
478+
await new Promise((resolve, reject) => {
479+
const tx = db.transaction(STARTUP_LAYOUT_SNAPSHOT_STORE_NAME, 'readwrite');
480+
const store = tx.objectStore(STARTUP_LAYOUT_SNAPSHOT_STORE_NAME);
481+
store.delete(fingerprint);
482+
tx.oncomplete = () => resolve();
483+
tx.onerror = () => reject(tx.error || new Error('indexedDB delete failed'));
484+
tx.onabort = () => reject(tx.error || new Error('indexedDB delete aborted'));
485+
});
486+
487+
db.close();
488+
}
489+
460490
function collectStartupLayoutSnapshotRecord(reason = '') {
461491
if (!startupLayoutSnapshotState.fingerprint || !Array.isArray(nodes) || nodes.length === 0) {
462492
return null;
@@ -515,6 +545,44 @@ function validateStartupLayoutSnapshotRecord(record) {
515545
return { ok: false, reason: 'position-coverage-low', coverage: Number(coverage.toFixed(4)) };
516546
}
517547

548+
const finitePositions = record.positions.filter((item) => (
549+
item &&
550+
Number.isFinite(Number(item.x)) &&
551+
Number.isFinite(Number(item.y))
552+
));
553+
if (expectedNodeCount >= 10 && finitePositions.length > 0) {
554+
let minX = Infinity;
555+
let maxX = -Infinity;
556+
let minY = Infinity;
557+
let maxY = -Infinity;
558+
const uniqueBuckets = new Set();
559+
560+
for (let index = 0; index < finitePositions.length; index += 1) {
561+
const item = finitePositions[index];
562+
const x = Number(item.x);
563+
const y = Number(item.y);
564+
if (x < minX) minX = x;
565+
if (x > maxX) maxX = x;
566+
if (y < minY) minY = y;
567+
if (y > maxY) maxY = y;
568+
uniqueBuckets.add(`${Math.round(x)}:${Math.round(y)}`);
569+
}
570+
571+
const spanX = maxX - minX;
572+
const spanY = maxY - minY;
573+
const uniqueRatio = uniqueBuckets.size / Math.max(1, finitePositions.length);
574+
if ((spanX < 48 && spanY < 48) || uniqueRatio < 0.12) {
575+
return {
576+
ok: false,
577+
reason: 'degenerate-layout',
578+
purge: true,
579+
spanX: Number(spanX.toFixed(2)),
580+
spanY: Number(spanY.toFixed(2)),
581+
uniqueRatio: Number(uniqueRatio.toFixed(4)),
582+
};
583+
}
584+
}
585+
518586
return {
519587
ok: true,
520588
coverage: Number(coverage.toFixed(4)),
@@ -568,8 +636,16 @@ function maybeApplyStartupWarmSnapshot(trigger = '') {
568636
recordFingerprint: record.fingerprint || null,
569637
recordNodeCount: record.nodeCount || 0,
570638
recordEdgeCount: record.edgeCount || 0,
571-
positionCount: Array.isArray(record.positions) ? record.positions.length : 0
639+
positionCount: Array.isArray(record.positions) ? record.positions.length : 0,
640+
spanX: validation.spanX,
641+
spanY: validation.spanY,
642+
uniqueRatio: validation.uniqueRatio,
572643
});
644+
if (validation.purge === true && record.fingerprint) {
645+
deleteStartupLayoutSnapshotRecord(record.fingerprint).catch((error) => {
646+
console.warn('[Startup Warm Snapshot] Failed to purge invalid snapshot record:', error && error.message ? error.message : String(error));
647+
});
648+
}
573649
startupLayoutSnapshotState.pendingRecord = null;
574650
return false;
575651
}

src/pathmode.background.contract.test.ts

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,14 +14,12 @@ describe('pathmode background safety contract', () => {
1414

1515
test('loads HDR backgrounds through the guarded resize-and-convert path', () => {
1616
const source = fs.readFileSync(pathRendererPath, 'utf8');
17-
expect(source).toContain('const BACKGROUND_MAX_DIMENSION := 2048');
1817
expect(source).toContain('var _background_texture_cache: Dictionary = {}');
1918
expect(source).toContain('var _last_applied_background_path: String = "__unset__"');
2019
expect(source).toContain('func _load_hdr_background_safely(path: String) -> Texture2D:');
2120
expect(source).toContain('var imported_tex = ResourceLoader.load(path)');
22-
expect(source).toContain('(imported_tex as Texture2D).get_image()');
21+
expect(source).toContain('var texture := imported_tex as Texture2D');
22+
expect(source).not.toContain('(imported_tex as Texture2D).get_image()');
2323
expect(source).not.toContain('var image_error := image.load(path)');
24-
expect(source).toContain('image.convert(Image.FORMAT_RGBA8)');
25-
expect(source).toContain('image.resize(resized_width, resized_height, Image.INTERPOLATE_LANCZOS)');
2624
});
2725
});
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import * as fs from 'fs';
2+
import * as path from 'path';
3+
4+
describe('startup layout snapshot contract', () => {
5+
const repoRoot = path.resolve(__dirname, '..');
6+
const appPath = path.join(repoRoot, 'src', 'frontend', 'app.js');
7+
8+
test('rejects and purges degenerate persisted layout snapshots', () => {
9+
const source = fs.readFileSync(appPath, 'utf8');
10+
expect(source).toContain('async function deleteStartupLayoutSnapshotRecord(fingerprint)');
11+
expect(source).toContain("reason: 'degenerate-layout'");
12+
expect(source).toContain('if ((spanX < 48 && spanY < 48) || uniqueRatio < 0.12)');
13+
expect(source).toContain('if (validation.purge === true && record.fingerprint)');
14+
});
15+
});

0 commit comments

Comments
 (0)