From affd5566abc3a8084c549d147cac3a8b0eb183d7 Mon Sep 17 00:00:00 2001 From: Torlando <281092095+torlando-agent[bot]@users.noreply.github.com> Date: Sat, 29 Aug 2026 17:54:27 +0000 Subject: [PATCH] fix: coordinate CLI and flasher map installs with a shared marker Greptile round 7 remediation: The CLI's advisory flock and the web flasher's Web Locks name live in incompatible lock namespaces, so a CLI install and a browser install on the same card could each hold "their" lock, both pass preflight on identical slot state, and clobber each other's records (orphaning a pack, or writing the same generation into two slots). Both producers now share two symmetric layers: 1. An on-disk install marker (.pyxis-installing in pyxis-map/, token "PYXI 1 ") claimed at install start via an atomic exclusive create, with ownership verified by read-back. A fresh foreign marker refuses the install before any mutation; a stale one (30-minute TTL; a future epoch counts as fresh under clock skew) is reclaimed. Release only deletes the releasee's own token. 2. Commit-time revalidation: activate_map_set / activateMapSet snapshot the raw slot and style bytes at derivation, re-read them immediately before the record writes, and abort if the activation state moved or a fresh foreign marker is present. The published pack is kept (device-harmless: the firmware only reads packs named by an active selection) and the existing retry/resume path converges it. Regression tests on both producers: marker refusal before any publication, stale-marker reclamation and release, and commit-time-race abort with retry convergence. The in-memory flasher FS now models getFileHandle({exclusive:true}) -> FileExistsError. CLI: 85 passed. Flasher: 24 passed. --- docs/flasher/js/map-installer.js | 159 ++++++++++++++--- tests/tools/test_build_map_pack.py | 151 ++++++++++++++++ tests/web/test_map_installer.mjs | 117 +++++++++++++ tools/maps/build_map_pack.py | 272 ++++++++++++++++++++++++----- 4 files changed, 634 insertions(+), 65 deletions(-) diff --git a/docs/flasher/js/map-installer.js b/docs/flasher/js/map-installer.js index 361cfe60..9f80f720 100644 --- a/docs/flasher/js/map-installer.js +++ b/docs/flasher/js/map-installer.js @@ -58,6 +58,106 @@ async function withInstallLock(rootDirectory, operation) { export class MapInstallerError extends Error {} function fail(message) { throw new MapInstallerError(message); } +// Cross-producer install marker. The CLI builder (tools/maps/build_map_pack.py) +// claims the same well-known file with the same token format before it +// publishes or activates. The browser holds a Web Locks name, which the CLI's +// flock cannot see, and the CLI holds a flock, which this tab cannot see: +// neither lock coordinates across producers, so the on-disk marker is the +// one shared coordination primitive. A marker contains: PYXI 1 +// . A marker written within INSTALL_MARKER_TTL_MS is a live +// installer; older ones are abandoned (crashed builder, closed tab) and are +// reclaimed. A future epoch (the other writer's clock ahead of ours) counts +// as fresh: refusing is the safe direction. +const INSTALL_MARKER_NAME = '.pyxis-installing'; +const INSTALL_MARKER_TTL_MS = 15 * 60 * 1000; + +function parseInstallMarker(text) { + const parts = String(text).trim().split(' '); + if (parts.length !== 4 || parts[0] !== 'PYXI' || parts[1] !== '1') return null; + if (!parts[2] || parts[2].length > 64) return null; + if (!/^\d+$/.test(parts[3])) return null; + return {owner: parts[2], epochMs: Number(parts[3])}; +} + +function installMarkerIsFresh(epochMs, nowMs = Date.now()) { + if (epochMs > nowMs) return true; + return nowMs - epochMs <= INSTALL_MARKER_TTL_MS; +} + +async function readInstallMarker(pyxis) { + let handle; try { handle = await pyxis.getFileHandle(INSTALL_MARKER_NAME); } + catch (error) { if (error?.name === 'NotFoundError') return null; throw error; } + return new TextDecoder('utf-8').decode(new Uint8Array(await (await handle.getFile()).arrayBuffer())); +} + +async function acquireInstallMarker(pyxis, owner) { + const token = `PYXI 1 ${owner} ${Date.now()}`; + const existing = await readInstallMarker(pyxis); + if (existing !== null) { + const parsed = parseInstallMarker(existing); + if (parsed && installMarkerIsFresh(parsed.epochMs)) { + fail('Another map installer is already running on this card; wait for it to finish and retry'); + } + // Stale or malformed: reclaim, then claim fresh below. + try { await pyxis.removeEntry(INSTALL_MARKER_NAME); } catch {} + } + let handle; + try { handle = await pyxis.getFileHandle(INSTALL_MARKER_NAME, {create: true, exclusive: true}); } + catch (error) { + if (error && error.name === 'FileExistsError') { + fail('Another map installer is already running on this card; wait for it to finish and retry'); + } + throw error; + } + const writable = await handle.createWritable({keepExistingData: false}); + try { await writable.write(textEncoder.encode(token)); await writable.close(); } + catch (error) { try { await writable.abort(); } catch {} throw error; } + // Read-back: a foreign content means we lost a simultaneous claim race. + // Never delete a foreign marker -- our claim is void. + const actual = await readInstallMarker(pyxis); + if (actual !== token) fail('The map-install marker was contested during acquisition; wait and retry'); + return token; +} + +async function releaseInstallMarker(pyxis, token) { + const actual = await readInstallMarker(pyxis); + if (actual === token) { try { await pyxis.removeEntry(INSTALL_MARKER_NAME); } catch {} } +} + +// Commit-time revalidation: the slot/style records were derived by +// prepareActiveMapSet; a cross-producer installer may have committed new +// activation state since. Re-reading the raw bytes immediately before the +// record writes and aborting on any change guarantees this publication never +// overwrites a newer record with one derived from stale state. The +// already-published pack is kept; a retry re-derives from the new state and +// converges (a pack no record names is device-harmless: the firmware only +// reads packs enumerated in the active selection). +async function verifyActivationState(pyxis, mapSets, styleName, markerToken, state) { + const marker = await readInstallMarker(pyxis); + if (marker !== null) { + const parsed = parseInstallMarker(marker); + if (parsed && installMarkerIsFresh(parsed.epochMs) && marker !== markerToken) { + fail('Another map installer is running on this card; wait for it to finish and retry'); + } + } + const sameRecord = (actual, expected) => + (actual === null && expected === null) || + (actual !== null && expected !== null && equalBytes(actual, expected)); + for (let index = 0; index < 2; index += 1) { + const name = `active-pack.${index}`; + let actual; try { actual = new Uint8Array(await (await (await pyxis.getFileHandle(name)).getFile()).arrayBuffer()); } + catch (error) { if (error?.name === 'NotFoundError') actual = null; else throw error; } + if (!sameRecord(actual, state.slotBytes[index])) { + fail('Active map-set records changed during installation; wait for the other installer to finish and retry'); + } + } + let styleActual; try { styleActual = new Uint8Array(await (await (await mapSets.getFileHandle(styleName)).getFile()).arrayBuffer()); } + catch (error) { if (error?.name === 'NotFoundError') styleActual = null; else throw error; } + if (!sameRecord(styleActual, state.styleBytes)) { + fail('Installed style record changed during installation; wait for the other installer to finish and retry'); + } +} + const MUI_STYLE_PROFILES = Object.freeze({ 'osm-bright': Object.freeze({label:'OSM Bright',attribution:'(c) OpenMapTiles (c) OpenStreetMap contributors',license:'OSM ODbL; style CC-BY-4.0/BSD-3-Clause'}), 'dark-matter': Object.freeze({label:'Dark Matter',attribution:'(c) OpenMapTiles (c) OpenStreetMap contributors; style (c) CARTO',license:'OSM ODbL; style CC-BY-4.0/BSD-3-Clause (CARTO CC-BY-3.0)'}), @@ -661,11 +761,15 @@ async function removeOwnedPack(packs,packId,pack,receiptName,receipt,entries){ async function prepareActiveMapSet(pyxis,metadata){ const slots=await Promise.all([readSelection(pyxis,'active-pack.0'),readSelection(pyxis,'active-pack.1')]); if(slots[0]&&slots[1]&&slots[0].generation===slots[1].generation&&JSON.stringify(slots[0])!==JSON.stringify(slots[1]))fail('Conflicting active map-set records'); + // Raw slot bytes at derivation time: the commit-time revalidation in + // activateMapSet aborts if they change before the record writes. + const slotBytes=[];for(let index=0;index<2;index+=1){let raw;try{raw=new Uint8Array(await (await (await pyxis.getFileHandle(`active-pack.${index}`)).getFile()).arrayBuffer());}catch(error){if(error?.name==='NotFoundError')raw=null;else throw error;}slotBytes.push(raw);} const valid=slots.filter(Boolean);const highest=Math.max(0,...valid.map(slot=>slot.generation));if(highest===0xffffffff)fail('Active selection generation is exhausted'); const current=valid.sort((left,right)=>right.generation-left.generation)[0]; const mapSets=await getDirectory(pyxis,'map-sets');const styleName=`${metadata.mapSetId}.pmas`; const installed=await readStyleSelection(mapSets,styleName); if(installed&&(![2,3].includes(installed.version)||installed.mapSetId!==metadata.mapSetId||installed.attribution!==metadata.attribution))fail('Installed style record does not match selected map set'); + let styleBytes=null;try{styleBytes=new Uint8Array(await (await (await mapSets.getFileHandle(styleName)).getFile()).arrayBuffer());}catch(error){if(error?.name!=='NotFoundError')throw error;} const composition=installed||([2,3].includes(current?.version)&¤t.mapSetId===metadata.mapSetId?current:null); let packs=[]; if(composition){ @@ -675,11 +779,12 @@ async function prepareActiveMapSet(pyxis,metadata){ packs.unshift({packId:metadata.packId}); const target=!slots[0]?'active-pack.0':!slots[1]?'active-pack.1':slots[0].generation<=slots[1].generation?'active-pack.0':'active-pack.1'; const record=encodeActiveMapSet({generation:highest+1,mapSetId:metadata.mapSetId,attribution:metadata.attribution,packs}); - return {target,record,packs,mapSets,styleName}; + return {target,record,packs,mapSets,styleName,slotBytes,styleBytes}; } -async function activateMapSet(pyxis,metadata){ - const {target,record,mapSets,styleName}=await prepareActiveMapSet(pyxis,metadata); +async function activateMapSet(pyxis,metadata,markerToken=null){ + const {target,record,mapSets,styleName,slotBytes,styleBytes}=await prepareActiveMapSet(pyxis,metadata); + await verifyActivationState(pyxis,mapSets,styleName,markerToken,{slotBytes,styleBytes}); await writeVerified(mapSets,styleName,record);const installed=decodeActiveSelection(await fileBytes(mapSets,styleName)); if(installed.version!==3||installed.mapSetId!==metadata.mapSetId||installed.packs[0].packId!==metadata.packId)fail('Style map-set verification failed'); await writeVerified(pyxis,target,record);const selected=decodeActiveSelection(await fileBytes(pyxis,target)); @@ -713,26 +818,38 @@ async function installMuiZipLocked({archive,rootDirectory,metadata,onProgress=() const report=await inspectMuiZip(archive,{onProgress}); if(report.styleId&&report.styleId!==metadata.mapSetId)fail('MUI ZIP style does not match the selected map set'); const manifest=serializeIndexlessManifest(metadata,report.minZoom,report.maxZoom,report.tileCount);const pyxis=await getDirectory(rootDirectory,'pyxis-map'); - await prepareActiveMapSet(pyxis,metadata);const packs=await getDirectory(pyxis,'packs'); - let existing=null;try{existing=await packs.getDirectoryHandle(metadata.packId);}catch(error){if(error?.name!=='NotFoundError')throw error;} - if(existing){ - let empty=true;for await(const _entry of existing.entries()){empty=false;break;} - if(!empty){await verifyExistingPack(existing,archive,report,manifest);try{const active=await activateMapSet(pyxis,metadata);return{...report,manifestBytes:manifest.length,resumed:true,...active};}catch(error){throw new Error(`Map pack is installed and verified, but activation failed: ${error.message}`);}} - } - const pack=existing||await getDirectory(packs,metadata.packId);await verifyNamedDirectory(packs,metadata.packId,pack,[]);let published=false;const receipt=new Uint8Array(16);crypto.getRandomValues(receipt);const receiptName=`.pyxis-install-owner-${[...receipt].map(byte=>byte.toString(16).padStart(2,'0')).join('')}`; + // Claim the cross-producer install marker (shared on-disk token with the + // CLI builder; the Web Locks name above cannot coordinate with it) before + // any mutation of the card: a refusal here must not create packs/ or + // map-sets/. The marker is held until every activation record write + // completes; the commit-time revalidation in activateMapSet is the safety + // net if it is bypassed or outlived. + const markerToken=await acquireInstallMarker(pyxis,`web-${Date.now().toString(16)}-${Math.random().toString(16).slice(2)}`); try{ - await writeVerified(pack,receiptName,receipt);await verifyNamedDirectory(packs,metadata.packId,pack,[receiptName]); - const tiles=await getDirectory(pack,'tiles'); - for(let index=0;indexbyte.toString(16).padStart(2,'0')).join('')}`; + try{ + await writeVerified(pack,receiptName,receipt);await verifyNamedDirectory(packs,metadata.packId,pack,[receiptName]); + const tiles=await getDirectory(pack,'tiles'); + for(let index=0;index None: + # A live cross-producer installer (the web flasher holds a Web Locks + # name, which this process's flock cannot see) is announced by a fresh + # on-disk marker in pyxis-map/. The builder must refuse up front, + # before staging tiles or publishing. + tool = load_tool() + source = tmp_path / "xyz" + put_tile(source, 1, 0, 0) + policy = tool.STYLE_POLICIES["osm-bright"] + sd = tmp_path / "sd" + (sd / "pyxis-map").mkdir(parents=True) + (sd / "pyxis-map/.pyxis-installing").write_text( + "PYXI 1 web-deadbeef " + str(int(__import__("time").time() * 1000))) + with pytest.raises(tool.PackError, match="already running"): + tool.build_map_pack(source, sd, pack_id="pack-a", name="A", + attribution=policy["attribution"], source=policy["source"], + license=policy["license"], style="osm-bright", activate=True) + # Nothing was staged, published, or activated: no pack, no + # records. The empty packs/ directory is created before the + # marker check and is harmless (the firmware reads only fixed + # named paths). The foreign marker is left in place: the other + # installer is live. + assert not (sd / "pyxis-map/packs/pack-a").exists() + assert not (sd / "pyxis-map/active-pack.0").exists() + assert not (sd / "pyxis-map/map-sets").exists() + # The foreign marker was not touched: the other installer is live. + assert (sd / "pyxis-map/.pyxis-installing").is_file() + + +def test_stale_install_marker_is_reclaimed_and_released(tmp_path: Path) -> None: + # An abandoned installer (crashed builder, closed tab) leaves a stale + # marker. The builder reclaims it, installs, and releases it on the + # happy path. + tool = load_tool() + source = tmp_path / "xyz" + put_tile(source, 1, 0, 0) + policy = tool.STYLE_POLICIES["osm-bright"] + sd = tmp_path / "sd" + (sd / "pyxis-map").mkdir(parents=True) + stale = int(__import__("time").time() * 1000) - 24 * 3600 * 1000 + (sd / "pyxis-map/.pyxis-installing").write_text(f"PYXI 1 cli-{stale:x} {stale}") + tool.build_map_pack(source, sd, pack_id="pack-a", name="A", + attribution=policy["attribution"], source=policy["source"], + license=policy["license"], style="osm-bright", activate=True) + assert (sd / "pyxis-map/packs/pack-a/manifest.pmp").is_file() + assert (sd / "pyxis-map/active-pack.0").is_file() + # The builder released its own marker; nothing is left behind. + assert not (sd / "pyxis-map/.pyxis-installing").exists() + + +def test_release_never_deletes_a_foreign_marker(tmp_path: Path) -> None: + # If a later installer reclaimed the marker after ours outlasted the + # TTL, release must leave the foreign claim alone. + tool = load_tool() + sd = tmp_path / "sd" + (sd / "pyxis-map").mkdir(parents=True) + pyxis_fd = os.open(str(sd / "pyxis-map"), os.O_RDONLY) + try: + token = tool._acquire_install_marker(pyxis_fd) + # A later installer overwrote our claim. + foreign = "PYXI 1 web-cafebabe " + str(int(__import__("time").time() * 1000)) + (sd / "pyxis-map/.pyxis-installing").write_text(foreign) + tool._release_install_marker(pyxis_fd, token) + assert (sd / "pyxis-map/.pyxis-installing").read_text() == foreign + finally: + os.close(pyxis_fd) + + +def test_commit_time_revalidation_aborts_and_retry_converges(tmp_path: Path) -> None: + # Commit-time revalidation: if a cross-producer installer commits new + # activation state after our records were derived, the publication is + # aborted before any record write. The published pack is kept (device- + # harmless while no record names it) and a retry converges the records. + tool = load_tool() + source = tmp_path / "xyz" + put_tile(source, 1, 0, 0) + policy = tool.STYLE_POLICIES["osm-bright"] + sd = tmp_path / "sd" + real_activate = tool.activate_map_set + real_verify = tool._verify_activation_state_at + + def racing_activate(pyxis_fd, **kwargs): + # One-shot: fires only on the first (aborted) run. + if raced["fired"]: + real_activate(pyxis_fd, **kwargs) + return + raced["fired"] = True + # Derivation happens inside the real activate_map_set (raw slot and + # style bytes are read there); run it once so the snapshot is + # taken, then simulate a concurrent cross-producer commit landing + # between that read and the commit-time revalidation. A real + # installer commits both records (style first, slot second), so the + # simulated competitor does the same. + real_activate(pyxis_fd, **kwargs) + assert tool._read_selection_at(pyxis_fd, "active-pack.1") is None + record2 = tool.encode_active_map_set( + generation=2, map_set_id="osm-bright", + attribution=policy["attribution"], pack_ids=["other-pack", "raced-pack"]) + map_sets_fd = tool._mkdir_open(pyxis_fd, "map-sets")[0] + try: + tool._write_atomic_at(map_sets_fd, "osm-bright.pmas", record2) + tool._write_atomic_at(pyxis_fd, "active-pack.0", record2) + finally: + os.close(map_sets_fd) + # Now run the commit-time revalidation for real: it must see the + # changed slot bytes (our derivation snapshot had both slots empty) + # and abort before any record write. + map_sets_fd = tool._mkdir_open(pyxis_fd, "map-sets")[0] + token = tool._read_selection_at(pyxis_fd, tool._INSTALL_MARKER_NAME).decode("ascii") + try: + real_verify(pyxis_fd, map_sets_fd, + slot_raw=[None, None], style_raw=None, + style_name="osm-bright.pmas", marker_token=token) + raise AssertionError("revalidation accepted a changed slot") + finally: + os.close(map_sets_fd) + + raced = {"fired": False} + + tool.activate_map_set = racing_activate + try: + with pytest.raises(tool.PackError, match="changed during installation"): + tool.build_map_pack(source, sd, pack_id="pack-a", name="A", + attribution=policy["attribution"], source=policy["source"], + license=policy["license"], style="osm-bright", activate=True) + finally: + tool.activate_map_set = real_activate + # The pack was published but no record was written by this run: the + # card holds exactly the raced installer's consistent gen-2 records + # (style first, slot second -- no split state from this aborted run). + assert (sd / "pyxis-map/packs/pack-a/manifest.pmp").is_file() + raced_record = tool.encode_active_map_set( + generation=2, map_set_id="osm-bright", + attribution=policy["attribution"], pack_ids=["other-pack", "raced-pack"]) + assert (sd / "pyxis-map/active-pack.0").read_bytes() == raced_record + assert (sd / "pyxis-map/map-sets/osm-bright.pmas").read_bytes() == raced_record + assert not (sd / "pyxis-map/active-pack.1").exists() + # No marker is left behind. + assert not (sd / "pyxis-map/.pyxis-installing").exists() + # Retry: the pack verifies, records converge, and the new selection + # includes both packs with pack-a at the front. + result = tool.build_map_pack(source, sd, pack_id="pack-a", name="A", + attribution=policy["attribution"], source=policy["source"], + license=policy["license"], style="osm-bright", activate=True) + record3 = tool.decode_active_selection((sd / "pyxis-map/active-pack.1").read_bytes()) + assert record3["generation"] == 3 + assert record3["packs"] == ["pack-a", "other-pack", "raced-pack"] + assert Path(result) == sd / "pyxis-map" / "packs" / "pack-a" diff --git a/tests/web/test_map_installer.mjs b/tests/web/test_map_installer.mjs index 0d108556..d8dda90e 100644 --- a/tests/web/test_map_installer.mjs +++ b/tests/web/test_map_installer.mjs @@ -13,6 +13,7 @@ import { parseSparseManifest, resolveMuiStyleProfile, suggestMapIdentity, + MapInstallerError, } from '../../docs/flasher/js/map-installer.js'; const PNG = Buffer.from( @@ -143,6 +144,13 @@ class MemoryDirectoryHandle { const current = this.children.get(name); if (current) { if (current.kind !== 'file') throw new DOMException('type', 'TypeMismatchError'); + // File System Access API: exclusive creation of an existing entry + // fails (FileExistsError). The install-marker protocol relies on it. + if (options.exclusive) { + const error = new Error('The file already exists.'); + error.name = 'FileExistsError'; + throw error; + } return current; } if (!options.create) throw new DOMException('missing', 'NotFoundError'); @@ -713,3 +721,112 @@ test('map-set capacity is checked before a ninth pack is published', async () => await assert.rejects(installMuiZip({archive,rootDirectory:root,metadata:{...metadata,packId:'pack-nine',name:'Pack Nine'}}), /active map set/i); assert.equal(pyxis.children.has('packs'), false); }); + + +// --- Cross-producer install marker + commit-time revalidation (Greptile +// round-7 P1). The CLI builder claims the same on-disk marker token; the +// Web Locks name alone cannot coordinate with it. --- + +const MARKER = '.pyxis-installing'; + +function freshMarker(owner = 'cli-0') { + return new TextEncoder().encode(`PYXI 1 ${owner} ${Date.now()}`); +} + +test('a fresh foreign install marker is refused before any record is written', async () => { + const archive = storedZip([['2/1/1.png', PNG]]); + const root = new MemoryDirectoryHandle('sd'); + const pyxis = await root.getDirectoryHandle('pyxis-map', {create: true}); + const originalMarker = freshMarker('cli-0'); + const marker = new MemoryFileHandle(MARKER, root.log); + marker.bytes = originalMarker; + pyxis.children.set(MARKER, marker); + + await assert.rejects( + installMuiZip({archive, rootDirectory: root, metadata}), + /another map installer is already running/i, + ); + // Nothing was published or activated; the foreign marker is untouched. + assert.equal(pyxis.children.has(metadata.packId), false); + assert.equal(pyxis.children.has('map-sets'), false); + assert.equal(pyxis.children.has('active-pack.0'), false); + assert.deepEqual([...marker.bytes], [...originalMarker]); +}); + +test('a stale install marker is reclaimed and the marker is released on success', async () => { + const archive = storedZip([['2/1/1.png', PNG]]); + const root = new MemoryDirectoryHandle('sd'); + const pyxis = await root.getDirectoryHandle('pyxis-map', {create: true}); + const marker = new MemoryFileHandle(MARKER, root.log); + marker.bytes = new TextEncoder().encode(`PYXI 1 cli-0 ${Date.now() - 24 * 60 * 60 * 1000}`); + pyxis.children.set(MARKER, marker); + + const result = await installMuiZip({archive, rootDirectory: root, metadata}); + assert.equal(result.tileCount, 1); + assert.equal(pyxis.children.has(MARKER), false); +}); + +test('activation aborts when a cross-producer commit lands before the record writes, then retry converges', async () => { + const archive = storedZip([['2/1/1.png', PNG]]); + const root = new MemoryDirectoryHandle('sd'); + const pyxis = await root.getDirectoryHandle('pyxis-map', {create: true}); + const mapSets = await pyxis.getDirectoryHandle('map-sets', {create: true}); + const seed = (directory, name, bytes) => { + const file = new MemoryFileHandle(name, root.log); + file.bytes = bytes; + directory.children.set(name, file); + return file; + }; + const gen1 = encodeActiveMapSet({generation: 1, mapSetId: metadata.mapSetId, attribution: metadata.attribution, packs: [{packId: 'old'}]}); + seed(mapSets, `${metadata.mapSetId}.pmas`, gen1); + const slot1 = seed(pyxis, 'active-pack.0', gen1); + + // Deterministic seam: active-pack.0 is read plainly (no create) twice per + // prepare pass -- once by readSelection, once by the raw slot snapshot -- + // and the two prepare passes (installMapPack, then activateMapSet) are + // followed by the commit-time verifyActivationState re-read. That is the + // fifth plain read overall; inject the racer's commit right before it, + // i.e. between the snapshot derive and its revalidation. + let plainSlotReads = 0; + const realGetFileHandle = pyxis.getFileHandle.bind(pyxis); + pyxis.getFileHandle = async (name, options) => { + if (name === 'active-pack.0' && !(options && options.create)) plainSlotReads += 1; + if (name === 'active-pack.0' && !(options && options.create) && plainSlotReads === 5) { + const gen2 = encodeActiveMapSet({generation: 2, mapSetId: metadata.mapSetId, attribution: metadata.attribution, packs: [{packId: 'other'}, {packId: 'old'}]}); + slot1.bytes = gen2; + seed(mapSets, `${metadata.mapSetId}.pmas`, gen2); + } + return realGetFileHandle(name, options); + }; + try { + await assert.rejects( + installMuiZip({archive, rootDirectory: root, metadata}), + /changed during installation/i, + ); + // The raced state is intact (no record was written over it) and our + // pack stays published under packs/ -- device-harmless until a record + // names it. + const packsDir = pyxis.children.get('packs'); + assert.ok(packsDir, 'packs directory exists'); + assert.equal(packsDir.children.has(metadata.packId), true); + const raced = await decodeActiveSelection(pyxis.children.get('active-pack.0').bytes); + assert.equal(raced.generation, 2); + assert.deepEqual(raced.packs.map(pack => pack.packId), ['other', 'old']); + const racedStyle = await decodeActiveSelection(mapSets.children.get(`${metadata.mapSetId}.pmas`).bytes); + assert.equal(racedStyle.generation, 2); + assert.equal(pyxis.children.has(MARKER), false, 'marker released even on abort'); + } finally { + pyxis.getFileHandle = realGetFileHandle; + } + // The retry re-derives from the gen-2 state and converges to generation + // 3 in the free slot (the racer's gen-2 record keeps active-pack.0). + const retried = await installMuiZip({archive, rootDirectory: root, metadata}); + assert.equal(retried.tileCount, 1); + const finalSlots = ['active-pack.0', 'active-pack.1'] + .map(name => pyxis.children.get(name)) + .filter(Boolean) + .map(handle => decodeActiveSelection(handle.bytes)); + const newest = finalSlots.sort((left, right) => right.generation - left.generation)[0]; + assert.equal(newest.generation, 3); + assert.deepEqual(newest.packs.map(pack => pack.packId), [metadata.packId, 'other', 'old']); +}); diff --git a/tools/maps/build_map_pack.py b/tools/maps/build_map_pack.py index cce0aa38..77c7528d 100644 --- a/tools/maps/build_map_pack.py +++ b/tools/maps/build_map_pack.py @@ -32,6 +32,7 @@ import re import stat import struct import sys +import time from typing import Any, cast, Iterable import zlib @@ -1120,7 +1121,8 @@ STYLE_POLICIES: dict[str, dict[str, str]] = { } -def activate_map_set(pyxis_fd: int, *, pack_id: str, map_set_id: str, attribution: str) -> tuple[str, list[str]]: +def activate_map_set(pyxis_fd: int, *, pack_id: str, map_set_id: str, attribution: str, + marker_token: str | None = None) -> tuple[str, list[str]]: """Publish the v3 active map-set record, mirroring the web flasher. pyxis_fd is the open pyxis-map/ directory. Slot and style-record @@ -1141,12 +1143,25 @@ def activate_map_set(pyxis_fd: int, *, pack_id: str, map_set_id: str, attributio The final pack list is validated against the firmware's pack limit before any byte is written, so a rejected activation cannot orphan a published pack. + + marker_token is the caller's cross-producer install-marker token (see + _acquire_install_marker). When given, the raw slot and style-record + bytes are captured when they are first read and re-read immediately + before the record writes: if another installer (the web flasher holds + a Web Locks name, which this process's flock cannot see) committed new + activation state -- or holds a fresh install marker that is not ours -- + in the meantime, the publication is aborted before any byte is written. + The already-published pack is kept; a retry re-derives from the new + state and converges, and a pack that no record names is device-harmless + (MapTilePack only reads packs enumerated in the active selection). Returns (slot_name, enabled_packs). """ slot_names = ("active-pack.0", "active-pack.1") slots: list[dict[str, object] | None] = [None, None] + slot_raw: list[bytes | None] = [None, None] for index, slot_name in enumerate(slot_names): raw = _read_selection_at(pyxis_fd, slot_name) + slot_raw[index] = raw if raw is not None: slots[index] = decode_active_selection(raw) if slots[0] is not None and slots[1] is not None \ @@ -1191,6 +1206,16 @@ def activate_map_set(pyxis_fd: int, *, pack_id: str, map_set_id: str, attributio else: target = slot_names[0] if cast(int, slots[0]["generation"]) <= \ cast(int, slots[1]["generation"]) else slot_names[1] + # Commit-time revalidation: the slot/style records were derived + # above; a concurrent cross-producer installer (the web flasher's + # Web Locks name is invisible to our flock) may have committed new + # activation state in the meantime. Re-reading the raw bytes here + # and aborting on any change guarantees this publication never + # overwrites a newer record with one derived from stale state. + # installed_raw is the style record's bytes as first read above. + _verify_activation_state_at(pyxis_fd, map_sets_fd, slot_raw=slot_raw, + style_raw=installed_raw, style_name=style_name, + marker_token=marker_token) # Style record first, active slot second (the web flasher's order). # The firmware's device-side style activation # (MapStyleCatalog::activate) reads the style record as the source @@ -1318,6 +1343,149 @@ def _install_lock_at(pyxis_fd: int): os.close(descriptor) +# Cross-producer install marker. +# +# The web flasher holds a Web Locks name and this CLI holds an advisory +# flock: two unrelated locking namespaces, so each lock is invisible to +# the other producer. The on-disk marker below is the one coordination +# primitive both producers share -- the flasher writes the same file with +# the same token format (docs/flasher/js/map-installer.js). A marker is a +# small file containing: PYXI 1 . + +_INSTALL_MARKER_NAME = ".pyxis-installing" +# A marker written within this window is a live installer. Installations +# longer than this are treated as abandoned (crashed builder, closed tab) +# and reclaimed. A future epoch (writer clock ahead of ours) counts as +# fresh: refusing is the safe direction. +_INSTALL_MARKER_TTL_SECONDS = 900 + + +def _marker_token(owner: str) -> str: + return f"PYXI 1 {owner} {int(time.time() * 1000)}" + + +def _parse_marker(raw: bytes) -> tuple[str, int] | None: + """Parse a marker token into (owner, epoch_ms); None if malformed.""" + try: + text = raw.decode("ascii") + except UnicodeDecodeError: + return None + parts = text.strip().split(" ") + if len(parts) != 4 or parts[0] != "PYXI" or parts[1] != "1": + return None + if not parts[2] or len(parts[2]) > 64 or not parts[3].isdigit(): + return None + return parts[2], int(parts[3]) + + +def _marker_is_fresh(epoch_ms: int) -> bool: + now_ms = int(time.time() * 1000) + if epoch_ms > now_ms: + return True + return (now_ms - epoch_ms) / 1000.0 <= _INSTALL_MARKER_TTL_SECONDS + + +def _acquire_install_marker(pyxis_fd: int) -> str: + """Claim the cross-producer install marker; return our token. + + Protocol (shared with the web flasher): + * no marker -> create it; + * fresh foreign marker -> refuse: another installer is live; + * stale or malformed -> reclaim (unlink, then create). + After creation the file is read back and compared to our token: a + foreign content means we lost a simultaneous acquire race and we + abort. The marker is best-effort coordination -- the safety net is + the commit-time revalidation in activate_map_set, which refuses to + publish a record over activation state that moved since it was + derived. + """ + owner = f"cli-{os.getpid():x}" + token = _marker_token(owner) + raw = _read_selection_at(pyxis_fd, _INSTALL_MARKER_NAME) + if raw is not None: + parsed = _parse_marker(raw) + if parsed is not None and _marker_is_fresh(parsed[1]): + raise PackError("another map installer is already running on " + "this card; wait for it to finish and retry") + # Stale or malformed: reclaim. A malformed marker may be a live + # writer mid-write, but the read-back below fails loudly if so, + # and commit-time revalidation covers the rest. + _unlink_quietly_at(pyxis_fd, _INSTALL_MARKER_NAME) + try: + descriptor = os.open(_INSTALL_MARKER_NAME, + os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_CLOEXEC, + 0o644, dir_fd=pyxis_fd) + except FileExistsError: + raise PackError("another map installer is already running on " + "this card; wait for it to finish and retry") from None + except OSError as exc: + raise PackError(f"cannot write the map-install marker: {exc}") from exc + try: + _write_all(descriptor, token.encode("ascii"), _INSTALL_MARKER_NAME) + os.fsync(descriptor) + finally: + os.close(descriptor) + actual = _read_selection_at(pyxis_fd, _INSTALL_MARKER_NAME) + if actual != token.encode("ascii"): + # Someone overwrote (or removed) our marker while we acquired it. + # Never delete a foreign marker: our claim is void, abort. + raise PackError("the map-install marker was contested during " + "acquisition; wait and retry") + _fsync_directory_fd(pyxis_fd) + return token + + +def _release_install_marker(pyxis_fd: int, token: str) -> None: + """Remove our install marker -- and only ours. + + If the marker content is not our own token (a later installer + reclaimed it after our install outlasted the TTL, or a crashed run + left a different claim), deleting it would drop that installer's + live claim. Leave it; the TTL reclaims it. + """ + raw = _read_selection_at(pyxis_fd, _INSTALL_MARKER_NAME) + if raw == token.encode("ascii"): + try: + os.unlink(_INSTALL_MARKER_NAME, dir_fd=pyxis_fd) + except OSError: + pass + + +def _verify_activation_state_at(pyxis_fd: int, map_sets_fd: int, *, + slot_raw: list[bytes | None], + style_raw: bytes | None, style_name: str, + marker_token: str | None) -> None: + """Commit-time revalidation against a concurrent installer. + + Re-reads the install marker and the raw activation records + immediately before the record writes. If a cross-producer installer + (web flasher or another builder) committed since the records were + derived -- or holds a fresh install marker that is not ours -- the + activation is aborted before any byte is written. The published pack + is kept; a retry re-derives from the new state and converges (the + pack is device-harmless while it is not named by any record: the + firmware only reads packs enumerated in the active selection). + """ + marker_raw = _read_selection_at(pyxis_fd, _INSTALL_MARKER_NAME) + if marker_raw is not None: + own = marker_token.encode("ascii") if marker_token is not None else None + parsed = _parse_marker(marker_raw) + if parsed is not None and _marker_is_fresh(parsed[1]) \ + and marker_raw != own: + raise PackError("another map installer is running on this card; " + "wait for it to finish and retry") + for index, slot_name in enumerate(("active-pack.0", "active-pack.1")): + actual = _read_selection_at(pyxis_fd, slot_name) + if actual != slot_raw[index]: + raise PackError("active map-set records changed during " + "installation; wait for the other installer " + "to finish and retry") + actual_style = _read_selection_at(map_sets_fd, style_name) + if actual_style != style_raw: + raise PackError("installed style record changed during installation; " + "wait for the other installer to finish and retry") + + def _remove_tree_at(parent_fd: int, name: str) -> None: try: descriptor = os.open(name, _DIRECTORY_FLAGS, dir_fd=parent_fd) @@ -1512,52 +1680,68 @@ def build_map_pack(source_directory: Path, output_root: Path, *, pack_id: str, n assert style is not None # Hold the install lock from the preflight through activation: # two concurrent builders could otherwise both pass the - # preflight and each clobber the other's records. + # preflight and each clobber the other's records. The cross- + # producer install marker (shared with the web flasher, which + # holds a Web Locks name this flock cannot see) is claimed for + # the same span and released on every exit path. with _install_lock_at(pyxis_fd): - _preflight_activation(pyxis_fd, pack_id=pack_id, map_set_id=style, - attribution=attribution) - if not resumed: - temporary, stage_fd = _temporary_directory(packs_fd, pack_id) - copied = 0 - for tile in tiles: - copy_tile(source_fd, tile, stage_fd, max_bytes - copied) - copied += tile.size - manifest_fd = os.open("manifest.pmp", os.O_WRONLY | os.O_CREAT | os.O_EXCL - | os.O_CLOEXEC, 0o644, dir_fd=stage_fd) - try: - _write_all(manifest_fd, manifest, "manifest.pmp") - os.fsync(manifest_fd) - finally: - os.close(manifest_fd) - _fsync_directory_fd(stage_fd) - _validate_pack_fd(stage_fd, target, max_bytes) - _verify_output_chain(output_root, output_fd, pyxis_fd, pyxis_identity, - packs_identity) - _rename_noreplace(packs_fd, temporary, pack_id, target) - committed = True - try: - _verify_output_chain(output_root, output_fd, pyxis_fd, - pyxis_identity, packs_identity) - except BaseException as exc: + # Claim the cross-producer install marker FIRST, before any + # staging or publication: a live web-flasher install (which + # holds a Web Locks name this flock cannot see) is announced + # by its marker, and refusing up front avoids the wasted + # tile copies and -- more importantly -- avoids publishing a + # pack that activation would then have to abandon. Stale or + # malformed markers (crashed builder, closed tab) are + # reclaimed here; ours is released on every exit path. + marker_token = _acquire_install_marker(pyxis_fd) + try: + _preflight_activation(pyxis_fd, pack_id=pack_id, map_set_id=style, + attribution=attribution) + if not resumed: + temporary, stage_fd = _temporary_directory(packs_fd, pack_id) + copied = 0 + for tile in tiles: + copy_tile(source_fd, tile, stage_fd, max_bytes - copied) + copied += tile.size + manifest_fd = os.open("manifest.pmp", os.O_WRONLY | os.O_CREAT | os.O_EXCL + | os.O_CLOEXEC, 0o644, dir_fd=stage_fd) try: - _remove_tree_at(packs_fd, pack_id) - _fsync_directory_fd(packs_fd) - committed = False - except BaseException as cleanup_error: - raise PublishedDurabilityError(target, OSError( - f"publication location changed and rollback failed: " - f"{cleanup_error}")) from exc - raise PackError("output directory changed during publication; " - "rolled back") from exc - try: - os.close(stage_fd) - except OSError as exc: + _write_all(manifest_fd, manifest, "manifest.pmp") + os.fsync(manifest_fd) + finally: + os.close(manifest_fd) + _fsync_directory_fd(stage_fd) + _validate_pack_fd(stage_fd, target, max_bytes) + _verify_output_chain(output_root, output_fd, pyxis_fd, pyxis_identity, + packs_identity) + _rename_noreplace(packs_fd, temporary, pack_id, target) + committed = True + try: + _verify_output_chain(output_root, output_fd, pyxis_fd, + pyxis_identity, packs_identity) + except BaseException as exc: + try: + _remove_tree_at(packs_fd, pack_id) + _fsync_directory_fd(packs_fd) + committed = False + except BaseException as cleanup_error: + raise PublishedDurabilityError(target, OSError( + f"publication location changed and rollback failed: " + f"{cleanup_error}")) from exc + raise PackError("output directory changed during publication; " + "rolled back") from exc + try: + os.close(stage_fd) + except OSError as exc: + stage_fd = -1 + raise PublishedDurabilityError(target, exc) from exc stage_fd = -1 - raise PublishedDurabilityError(target, exc) from exc - stage_fd = -1 - slot_name, _enabled = activate_map_set(pyxis_fd, pack_id=pack_id, - map_set_id=style, - attribution=attribution) + slot_name, _enabled = activate_map_set(pyxis_fd, pack_id=pack_id, + map_set_id=style, + attribution=attribution, + marker_token=marker_token) + finally: + _release_install_marker(pyxis_fd, marker_token) else: temporary, stage_fd = _temporary_directory(packs_fd, pack_id) copied = 0