Skip to content

Steam Non-Steam Shortcuts

Technical reference for how decky-romm-sync creates, manages, and launches non-Steam shortcuts. This covers the SteamClient.Apps.AddShortcut API, VDF format details, and app ID handling.

AddShortcut API Behavior

Signature

SteamClient.Apps.AddShortcut(name: string, exe: string, startDir: string, launchOptions: string): Promise<number>

Returns the new shortcut's appId (a number), or 0/null on failure.

What it actually does

Despite accepting four parameters, AddShortcut ignores startDir and launchOptions. This was confirmed by the MoonDeck plugin developers. Only name and exe are used during creation.

To set all shortcut properties reliably, wait for Steam to register the new app's overview before the Set* calls:

const appId = await SteamClient.Apps.AddShortcut(name, exe, "", "");
// Poll appStore.GetAppOverviewByAppID(appId) (~100ms cadence) until the overview
// exists, with a 1000ms fallback; on timeout, proceed anyway.
await waitForAppOverview(appId, 1000);

SteamClient.Apps.SetShortcutName(appId, name);
SteamClient.Apps.SetShortcutExe(appId, exe);
SteamClient.Apps.SetShortcutStartDir(appId, startDir);
// An empty launch_options (uninstalled placeholder) needs no write or confirm —
// a fresh shortcut's launch options are already empty, so skip both. A non-empty
// command takes the confirmed write (setLaunchOptionsConfirmed).
if (launchOptions !== "") await setLaunchOptionsConfirmed(appId, launchOptions);

Steam must finish registering the new app internally before the Set* calls land, or they silently fail. Rather than a fixed worst-case wait, the plugin polls appStore for the new overview (readiness) — the common case proceeds in ~100ms instead of a blind 500ms, and the 1000ms ceiling keeps the old wait's safety net when the overview is slow. Skipping the launch-options write for the (majority) uninstalled case also avoids setLaunchOptionsConfirmed's RegisterForAppDetails poll, which forces Steam to load and cache a fat AppDetails object per call.

Exe quoting

Do NOT pass quoted exe paths to AddShortcut or SetShortcutExe. The API handles quoting internally. Passing "\"path/to/exe\"" (pre-quoted) results in double-quoting, which causes launches to fail with "file not found."

Pass the raw path:

SteamClient.Apps.SetShortcutExe(appId, "/home/deck/homebrew/plugins/decky-romm-sync/bin/rom-launcher");

Updating existing shortcuts

Steam assigns a shortcut's appId when AddShortcut creates it, and that appId is stable for the shortcut's lifetime. The plugin never computes it: it records Steam's assigned id in roms.shortcut_app_id and detects ownership by the exe path (…/bin/rom-launcher), not by re-deriving the id. (The historical "appId is CRC32(exe + appName)" formula does not hold on current Steam — see App IDs and Artwork.) Two consequences follow:

  • launchOptions and startDir are appId-safe. Changing either on an existing shortcut keeps the same appId, so the shortcut's identity, artwork, collection membership, and roms.shortcut_app_id binding all survive. SetAppLaunchOptions on an existing shortcut is reliable — confirmed on hardware in #827 across in-session writes, a Steam restart, and removal-churn re-syncs. The plugin uses it directly to bake the launch command in at download-complete and to re-resolve paths after a RetroDECK-home migration.
  • exe and the display name are applied by delete + recreate. A launch-config change that touches exe or the name is handled by removing the shortcut and re-syncing it, which yields a new appId (a fresh shortcut); a launchOptions-only change is not. This delete + recreate behavior is unchanged; the original rationale — that changing exe/name re-hashes to a different appId — rests on the CRC derivation above and is no longer verified.

Because SetAppLaunchOptions returns void with no success signal, the plugin fires the set then polls RegisterForAppDetails until the read-back strLaunchOptions matches (setLaunchOptionsConfirmed). Setting "" — the placeholder an uninstalled ROM carries until it is downloaded — is valid and confirms against an empty read-back.

The real hazard is not the set: heavy removal-churn can corrupt Steam's in-memory shortcut state. A Steam restart clears it. The sync engine processes removals before additions to minimise churn.

See: src/utils/steamShortcuts.ts

Recovery after a server switch / re-import

Switching the RomM server URL — or re-importing on the same server — reissues rom_ids while the Steam shortcuts are not deleted (their assigned appIds persist) and the roms rows survive (ADR-0007 retention) with the binding in roms.shortcut_app_id, reverse-lookupable via get_app_id_rom_id_map(). Because Steam assigns the appId at creation (the CRC32(exe + name) derivation is disproven — see App IDs and Artwork), the plugin never re-derives an id to "find" the old shortcut. It keeps a game's shortcut alive across the rom_id churn through three lanes:

  • Stable sibling-group keys → the rebind lane. An unchanged game whose sibling_group_key is the same after the re-import (same IGDB/… identity) rides collapse_sibling_groups's rebind lane: the group's only fetched member is the fresh rom_id, the old bound rom_id has vanished, so collapse emits one entry keyed to the vanished sibling — the frontend reuses its existing shortcut by rom_id — carrying bind_rom_id → the fresh representative. The shortcut, its artwork, its collection membership, and its playtime all survive; only the DB binding moves onto the new rom_id, and no duplicate is minted. (The domain behavior is unit-pinned in tests/domain/test_sync_diff.py; on-device confirmation of the end-to-end path is still pending.)
  • Changed / absent group keys → delete + create. If the re-import changes or drops the sibling_group_key (the game rematches to a different metadata entry, or its identity is lost), the group no longer resolves to the old bound sibling, so the fresh rom_id takes the new lane: a fresh AddShortcut with a new appId, and the old shortcut is torn down by the stale path. Artwork and collection membership are re-established for the new shortcut. This is honest degradation — the identity link the rebind lane needs is gone, so the shortcut cannot be preserved.
  • Untracked orphans → adoption at create time. A live RomM-owned shortcut (exe ends /bin/rom-launcher) that carries no DB binding — a crashed run's uncommitted in-flight shortcut, or a zombie left after a DB reset — is invisible to both the rebind lane (no roms row) and the stale path (nothing to unbind), so a naive create would leave a duplicate (#1366). When such a ROM reaches the create path, the frontend adopts the orphan instead: it matches the built entry's display name against a once-per-run pool of live-but-unbound RomM appIds (getLiveRomMShortcutAppIds() minus the run's already-bound appIds, resolved to names via appStore.GetAppOverviewByAppID), reuses the matched appId, and rewrites its identity + launch bake (the same Set* writes as an update). The pool is built lazily on the first create candidate (a pure-update run pays no scan cost); each orphan is adopted at most once per run; a name collision adopts the lowest appId deterministically; a null live scan (store unreadable) disables adoption for the run rather than guess. An adoption counts as "added" in the post-sync toast (a game came under management) but skips the AddShortcut a duplicate would cost. See resolveShortcutAppId in src/utils/syncManager.ts.

Two guards keep a re-import from wiping a freshly-bound shortcut (#1036):

  • One appId, one bound row. SqliteRomRepository.save() unbinds any sibling row holding the appId before the per-rom_id UPSERT, and migration 003's partial unique index on shortcut_app_id enforces it (see Database Design). A re-import never leaves two bound rows sharing one appId.
  • Stale-removal excludes appIds bound this run. The finalize stale pass flags bound rows whose rom_id wasn't synced this run — which includes the old rom_id the rebind lane just superseded. domain/sync_diff.py:select_stale_removals removes any candidate whose appId is in the run's committed_app_ids (every appId bound this run, across both the happy-path and the heartbeat-timeout late-ack commit paths), so the appId the run just re-bound onto the new rom_id is never emitted for removal. The get_by_app_id reverse lookup orders rom_id DESC LIMIT 1 so it resolves the live (newest) binding for any pre-migration edge state.

Sync-start reconcile of Steam-UI-deleted shortcuts

A user can delete a RomM shortcut through Steam's own UI (remove from library), which the plugin never observes. The roms row keeps its now-dead shortcut_app_id, so get_app_id_rom_id_map keeps serving it (playtime writes and launch-options bakes aim at a Steam app that no longer exists) and the incremental skip never recreates it: the skip counts bound roms rows, not live Steam shortcuts, so the platform reports "unchanged" forever. The game stays gone until a server-side change or a Force Full Sync (#1046).

The fix is a frontend-assisted reconcile at sync start, because only the frontend can read Steam's shortcut store. It runs before the sync builds its work queue — so the unbind lands before the incremental-skip decision — on both the skip-preview (start_sync) and preview (sync_preview) paths:

  1. getLiveRomMShortcutAppIds() (src/utils/steamShortcuts.ts) scans Steam's live shortcuts and returns the raw appIds of every RomM-owned shortcut (exe ends with /bin/rom-launcher), regardless of any backend binding. It returns null when the store was unreadable (collectionStore absent) versus [] when the scan ran and found none — a load-bearing distinction.
  2. reconcileStaleShortcuts() (src/utils/syncManager.ts) skips the reconcile on a null scan (reconciling against "couldn't look" would unbind every binding), and otherwise calls the reconcile_shortcuts callable with the live set. It is best-effort: a scan or backend failure is logged and swallowed, never blocking the sync.
  3. ShortcutRemovalService.reconcile_live_shortcuts unbinds every bound roms row whose shortcut_app_id is not in the live set — clearing only the binding (Rom.unbind_shortcut, ADR-0007), never deleting the row or its per-ROM children. An empty live set is the correct "they're all gone" signal and unbinds every binding.

Once a row is unbound, the fetcher's incremental baseline (_read_incremental_baseline, which reconstructs only rows with a non-NULL shortcut_app_id) no longer counts it, so unit.rom_count == registry_count fails and the platform falls through to a full fetch that recreates the shortcut. The unbind is reversible by design — the next sync re-binds.

This is eager (sync-start) reconciliation of the Steam-shortcut binding, distinct from #951's lazy on-access reconciliation of the rom_installs (on-disk install) view: a different aggregate, a different cost driver, and — unlike installs — one the backend physically cannot reconcile lazily, since no per-game backend seam observes Steam's shortcut store.

BIsModOrShortcut

Non-Steam shortcuts return BIsModOrShortcut() = true by default. This is their natural state — Steam uses this flag to determine how to render and launch an app.

An earlier version of the plugin used a "bypass counter" pattern (inspired by MetaDeck) to temporarily return false from BIsModOrShortcut() so that Steam would render metadata sections (description, developer, etc.) on the game detail page. This approach was dropped in Phase 5.6 because it caused launch failures — Steam skips the shortcut launch path when BIsModOrShortcut() returns false.

The current approach owns the entire game detail UI via custom React components (RomMPlaySection, RomMGameInfoPanel, CustomPlayButton) injected through route patching. This avoids fighting Steam's internal rendering logic.

See: src/patches/gameDetailPatch.tsx, src/components/RomMPlaySection.tsx

Overview metadata mutations (readiness-gated)

Beyond the custom UI, the plugin writes three fields directly onto each RomM shortcut's SteamAppOverview so the shortcut presents like a native Steam game: controller_support = 2 (the "Full Controller Support" badge — important so Game Mode doesn't flag the controller-driven RetroDECK launch), metacritic_score (from RomM's average_rating), and m_setStoreCategories (RomM's steam_categories).

Steam rebuilds appStore from scratch on every SharedJSContext mount, so these in-memory mutations are lost on each reload and must re-apply per mount. registerMetadataPatches builds the appId→romId map; applyAllMetadata then applies the mutations with a readiness retry (the same [0, 1s, 3s, 5s] ladder as applyAllPlaytime). Without the retry the pass runs before appStore is populated and silently no-ops on a cold boot, so the badge/rating/categories never appear until a later mount (#1203). The mutations are idempotent, so retries are safe.

The pass also re-runs on sync_complete. A sync adds or re-keys ROMs whose metadata the init-time pass never saw, so onSyncComplete re-fetches the full paged metadata cache + appId map (fetchMetadataCachePages, shared with init), re-registers via registerMetadataPatches with the fresh data, and re-applies — mirroring the playtime re-apply beside it. It runs on every sync_complete, cancelled runs included (a partial run's committed units still carry fresh metadata, and the pass is idempotent), in its own detached block with its own error handling so a re-fetch failure never touches the toast, collections, or playtime paths (#1207). The backend commits metadata per unit during the sync (before the terminal emit), so this re-fetch always sees the new ROMs.

See: src/patches/metadataPatches.ts, src/utils/metadataCache.ts (paged fetch), onSyncComplete in src/index.tsx

VDF Format Notes

Shortcut creation and every field update go through the frontend SteamClient.Apps.AddShortcut() / Set* API — AddShortcut returns the real appId directly, so the plugin never computes app IDs itself and never edits shortcuts.vdf while Steam is running (Steam holds the file in memory and rewrites it from memory, silently clobbering external writes — see shortcuts.vdf is memory-authoritative). The backend SteamConfigAdapter (adapters/steam_config.py) still lays down artwork files in the grid directory, including the icon PNG; its shortcuts.vdf read/write helpers remain in the adapter but are no longer on any live path after the icon write moved to SteamClient.Apps.SetShortcutIcon.

shortcuts.vdf structure

Steam stores non-Steam shortcuts in a binary VDF file at:

~/.local/share/Steam/userdata/<user_id>/config/shortcuts.vdf

Each entry has these key fields:

VDF Field Format Notes
AppName string Display name
Exe string Executable path. AddShortcut-created entries store it unquoted (on-device inspection) — the API handles any quoting internally
StartDir string Start directory. Stored unquoted for AddShortcut-created entries
LaunchOptions string The full launch command the bin/rom-launcher exec wrapper runs, e.g. flatpak run net.retrodeck.retrodeck "/path/to/game.iso" — or "" (placeholder) for an uninstalled ROM. No romm:<id> marker; ownership is detected by the exe path instead
appid signed int32 Assigned by Steam when AddShortcut runs; stored as the signed int32 form (to_signed_app_id)
icon string Icon path or hash
tags object Steam collection tags. The plugin manages collections via collectionStore (machine-scoped names like RomM: N64 (steamdeck)), not by writing this VDF field.

shortcuts.vdf is memory-authoritative

While Steam is running, shortcuts.vdf is authoritative in Steam's memory: Steam rewrites the file from memory mid-session and on exit, so any external write to it while Steam runs is silently clobbered. The plugin therefore creates and mutates shortcuts only through the SteamClient API (AddShortcut / Set* / SetShortcutIcon), never by editing shortcuts.vdf directly. Pass raw, unquoted paths through those APIs — the API adds any quoting internally, and on-device inspection confirms AddShortcut-created entries are stored unquoted; pre-quoting double-quotes the path and breaks launches (see Exe quoting).

See: py_modules/adapters/steam_config.py

Collection management

Steam collections are managed entirely on the frontend via collectionStore, not by writing the shortcut's tags VDF field. The plugin owns machine-scoped collections named RomM: <platform> (<hostname>) for platforms and RomM: [<name>] (<hostname>) for synced RomM collections. The sync_complete event carries platform_app_ids and romm_collection_app_ids maps; onSyncComplete (src/index.tsx) creates/updates the collections for the maps it receives and then runs a stale-collection cleanup that deletes any RomM: … collection for this machine whose platform/collection name is absent from those maps.

The cleanup is gated on a completed (non-cancelled) sync (!data.cancelled). On a cancelled run the maps are partial — they list only the platforms the run reached before the cancel (empty if the cancel fired before the first unit), because the backend builds platform_app_ids from the cross-unit accumulator of reached platforms. Treating a partial map as the authoritative active-set would delete the collections for unreached platforms — an early cancel would wipe the entire library organization. The additive create/update path stays ungated, so the platforms that did complete still get their collections; only the destructive deletion is skipped on cancel. Steam collections are not backed up, so the safe behavior on a partial/cancelled run is to delete nothing.

App IDs and Artwork

SteamClient.Apps.AddShortcut() returns the real appId, so the plugin does not compute shortcut app IDs itself — there is no app-ID generator in the codebase. Steam assigns the appId at creation and it is stable for the shortcut's lifetime, which is why mutating launchOptions or startDir keeps the same appId (see Updating existing shortcuts) while delete + recreate yields a new one.

Errata (2026-07): the appId is not CRC32(exe + appName). Earlier docs described the appId as CRC32(exe + appName). On-device inspection of 68 live plugin-created shortcuts matched none against any CRC32 candidate (exe/name variants, quoted/unquoted, with/without a trailing NUL, top bit set), and the live appids are uniformly spread across [0x80000000, 0xFFFFFFFF] — consistent with random assignment at creation (and with the community observation that delete + re-add yields a different appid). The load-bearing facts are unchanged: the appId is stable for the shortcut's lifetime (so launchOptions / startDir edits are appId-safe), delete + recreate yields a new appId, and the plugin's identity model never computes appIds — it records Steam's assigned id in roms.shortcut_app_id and detects ownership by the exe path. Only the derivation mechanism was wrong.

The frontend stores the returned appId and the backend persists it as shortcut_app_id on the ROM's roms row (the synced-ROM registry; reverse-lookupable by shortcut_app_id). The frontend resolves rom_id ↔ appId through the backend's get_app_id_rom_id_map() callable, which reads that binding.

The signed-int32 helper to_signed_app_id(app_id) remains in py_modules/domain/sgdb_artwork.py (alongside the SGDB endpoint/asset-type maps) for the shortcuts.vdf record format, but no longer has a production caller now that the icon write goes through SteamClient rather than editing the VDF.

Artwork file naming

Grid artwork is stored at userdata/<user_id>/config/grid/, keyed by the shortcut's real appId:

Suffix Artwork Type
<appId>p.png Portrait grid (cover)
<appId>_hero.png Hero banner
<appId>_logo.png Logo overlay
<appId>.png Wide grid / horizontal
<appId>_icon.png Icon

Each form also occurs with a .jpg / .jpeg extension. On shortcut removal the plugin deletes the full suffix × extension set for the removed appId (ArtworkService.remove_artwork_files), so companion art (hero/logo/icon/wide) never outlives its shortcut. Files a removal missed historically are reclaimed by the Danger Zone's Remove Orphaned Grid Images cleanup (cleanup_orphaned_grid_images): candidates are only grid-image-named files whose appId sits in the non-Steam-shortcut range ([0x80000000, 0xFFFFFFFF] — see the errata above; store-game custom art is out of range and never touched) and whose appId belongs to no live shortcut in the frontend's full scan; if any bound roms.shortcut_app_id is missing from that scan the cleanup refuses and deletes nothing.

ArtworkService (cover staging/finalisation, renaming the staged cover to {app_id}p.png) and SteamGridService (SGDB hero/logo/grid/icon) own the artwork flow. The icon is a two-step write: SteamGridService.save_shortcut_icon writes the icon PNG into the grid dir via SteamConfigAdapter.write_shortcut_icon and returns its icon_path; the frontend then points the live shortcut at it with SteamClient.Apps.SetShortcutIcon(appId, icon_path). The backend no longer edits the shortcuts.vdf icon field — Steam is memory-authoritative and clobbered that write, so pointing the shortcut must go through SteamClient (see shortcuts.vdf is memory-authoritative).

Covers are applied per created shortcut through Steam's own artwork API during the apply, so tiles show their real cover in-session with no client restart. Right after a newly created shortcut resolves its appId in the per-item apply loop (applyCoverArtwork in syncManager.ts), the frontend fetches the cover bytes for that ROM (get_artwork_base64(rom_id)) and hands them to SteamClient.Apps.SetCustomArtworkForApp(appId, base64, "png", 0). Steam decodes the image, owns the tile, and writes the file itself as {app_id}p.png in the grid dir — the same path the backend also writes — so the cover appears as the shortcut is created and Steam refreshes the tile in-session.

The key properties:

  • Creates only. A cover is applied only when the item is a fresh create. An updated or rebound shortcut keeps its existing grid file (in-session cover refresh on a version/metadata change is tracked separately, #1386).
  • One cover per item, under the session-budget gate. The cover is fetched and applied inside the existing 50 ms-paced per-item loop — never prefetched or batched. Decoding many covers resident at once is exactly the CEF heap overflow (#797) that crashed SharedJSContext on large libraries; the session-budget gate (see ADR-0024) prices each create at its permanent cost plus the cover's transient peak and pauses the run before the renderer nears its heap cliff.
  • Fail-soft. A cover that can't be fetched (base64: null) or applied (a throwing SetCustomArtworkForApp) is logged and never fails the shortcut — the shortcut is already created, and the backend's commit-time grid write is the durability net.

The backend also writes each {app_id}p.png grid file at commit (SyncReporter._finalize_cover_pathArtworkService.finalize_cover_path). That copy costs no renderer heap and is the durability net: it lands the grid file even if a per-item API call failed, so a residual gray tile resolves the next time the game's page is opened or on the next client restart.

Key Files

File Purpose
src/utils/steamShortcuts.ts addShortcut(), removeShortcut(), getExistingRomMShortcuts(), getLiveRomMShortcutAppIds() (raw live appId scan for the sync-start reconcile) — frontend shortcut CRUD. The existing-shortcut scan emits a sync heartbeat every 10s between batches so a large library can't stall the run past the backend's per-unit heartbeat timeout
src/utils/syncManager.ts Listens for sync events, orchestrates shortcut creation/removal, artwork application, collection management. reconcileStaleShortcuts() runs the sync-start reconcile of Steam-UI-deleted shortcuts. Caches the existing-shortcut scan per run (keyed by the sync_apply_unit run_id) so it scans Steam once per run, not once per unit
py_modules/services/shortcut_removal.py ShortcutRemovalService — resolves shortcut-removal sets, unbinds removed ROMs, and runs reconcile_live_shortcuts (the sync-start reconcile of Steam-UI-deleted bindings)
src/utils/collections.ts Machine-scoped Steam collection management
src/patches/gameDetailPatch.tsx Route patch for /library/app/:appid — injects RomMPlaySection for custom game detail UI
src/patches/metadataPatches.ts Store patches for description, associations, categories, release date display
py_modules/adapters/steam_config.py SteamConfigAdapter — VDF read/write, grid dir, shortcut icon write, Steam Input config
py_modules/services/library/ LibraryService — builds shortcut data, drives per-unit sync apply
py_modules/domain/sgdb_artwork.py to_signed_app_id, SGDB asset-type/endpoint maps
bin/rom-launcher Pure exec "$@" wrapper invoked by Steam — runs the full launch command baked into the shortcut's launch options; owns no state, no path resolution, no emulator knowledge

Common Pitfalls

Quoting exe breaks launches

Pre-quoting the exe path in AddShortcut or SetShortcutExe causes double-quoting. Steam tries to execute ""/path/to/exe"" and fails with "file not found." Always pass raw paths through the SteamClient API.

Empty Set* params after AddShortcut

Calling Set* methods too quickly after AddShortcut (before the new app's overview is registered) results in the properties not being saved. The shortcut appears in the library but with wrong or missing exe/startDir/launchOptions. Launches fail or open the wrong thing. The plugin gates the Set* calls on an overview-readiness poll (waitForAppOverview, 1000ms fallback) rather than a fixed delay.

Removal-churn can corrupt shortcut state

SetAppLaunchOptions on an existing shortcut is reliable (validated in #827; see Updating existing shortcuts) — the historical "property updates may not persist" warning has been narrowed. The remaining hazard is removal-churn: adding and removing many shortcuts in one pass can corrupt Steam's in-memory shortcut state. A Steam restart clears it. Two things keep churn down. The sync engine processes removals before additions, and every launch-options write uses the fire-then-poll setLaunchOptionsConfirmed so a silently dropped write is observable rather than assumed. And mass removals are awaited and chunk-paced through the shared removeShortcutsPaced helper (src/utils/shortcutRemoval.ts, over pacedForEach, #977): every bulk removal path — the DangerZone actions (per-platform, Remove-All-RomM including the live-orphan sweep, and the Remove-Non-Steam bulk action) and the sync-run stale-shortcut cleanup (sync_stale, fired at run finalize) — awaits each removeShortcut in sequence and yields a 50ms breather every 25 removals, so the CEF renderer never blocks and thousands of removals can't stack as fire-and-forget promises. (The sync_stale handler records its "removed" delta for the terminal toast up front, before the first breather, so the paced removal can't leave the count partial when sync_complete interleaves.) Only exe/name changes still go through delete + recreate — a fresh AddShortcut, which yields a new appId.

AddShortcut / RemoveShortcut timing between shortcuts

Bulk shortcut loops corrupt Steam's internal store if driven too fast — added shortcuts may silently fail to register, and removals churn the in-memory state (above). Both cadences live in one place: the shared paced loop pacedForEach in src/utils/pacedOps.ts, which iterates awaiting each item and yields a breather between chunks (no trailing delay). The two callers differ only in chunk size:

  • Add (syncManager.tsprocessUnitShortcuts, processCoverRefreshes) paces one item at a time: a 50ms breather after every addShortcut() / cover apply, plus the per-unit heartbeat + cancel hooks.
  • Remove (removeShortcutsPaced in src/utils/shortcutRemoval.ts, shared by the DangerZone actions and the sync_stale cleanup) paces in 25-item chunks with a 50ms breather between them. A removal is a single cheap call, so chunked yielding keeps a 5000-game teardown at ~seconds of overhead instead of the ~4 minutes strict 50ms/item would cost, while still letting the renderer breathe. No heartbeat hook: the DangerZone actions run outside any sync, and sync_stale fires at run finalize — after every per-unit heartbeat window has already closed and detached from the watchdog (the backend never awaits the frontend's stale removal).

The apply is chunked; a heartbeat timeout must not discard a chunk's delivered bindings

A unit's emitted shortcuts are split into fixed-size chunks (200, ADR-0023); the pipeline emits one sync_apply_unit per chunk (carrying chunk_index / chunk_count / chunk_offset / unit_total, shortcuts = the chunk slice), then waits for the frontend's report_unit_results ack — echoing the chunk_index back — and commits that chunk's roms rows durably before emitting the next. A mid-unit crash, cancel, or timeout forfeits only the in-flight chunk; every chunk committed before it stays committed. See Backend Architecture — per-unit apply for the full loop.

If the frontend stops heartbeating for longer than the per-chunk timeout (_UNIT_HEARTBEAT_TIMEOUT_SEC, 60s — e.g. a chunk slow enough that real heartbeats lag), the wait gives up. But by then the frontend has already created that chunk's Steam shortcuts and will still fire its late report_unit_results. Dropping that ack is data loss: the bindings are never written to roms, so get_app_id_rom_id_map doesn't know about the shortcuts, and the next sync re-creates them as duplicates (an unmapped exe-detected shortcut takes the addShortcut branch).

So a heartbeat timeout is handled differently from a user cancel (#1052 / #1367):

  • User cancel — the in-flight chunk is intentionally discarded. The orchestrator clears the staging and nulls unit_complete_event, so a stray late ack can't commit a cancelled chunk.
  • Heartbeat timeout — the orchestrator moves the abandoned chunk into an abandoned_chunk stash on LibrarySyncStateBox (stash_abandoned_chunk): its run/unit/chunk identity plus this chunk's ROMs (only the abandoned chunk), while keeping the whole-unit staging live for the commit to read and clearing the dispatch identity. The stash lives outside the run-lifecycle state and deliberately survives the run's teardown (finish_run nulls current_sync_id), because in production the late report_unit_results arrives after the run has wound down — the window an earlier design missed, where the active-unit ack check could no longer match and the recovery was unreachable (#1367). The late ack matches the stash by identity (take_abandoned_chunk) and drives commit_unit_results itself over the stashed rows (binding + metadata), never stamping a timed-out platform complete. Bounded lifetime: the next run's try_begin_run clears an unacked stash.

The committed binding self-heals the duplicate hazard: a bound roms row is mapped by getExistingRomMShortcuts next sync, so resolveShortcutAppId takes the update branch. The orchestrator does not add active orphan deletion — a Steam shortcut is the sole record of its tile (the "never delete data that exists nowhere else" invariant).