From 0f5a5fe6fe0d9fd70399c523080d40f207d23fe1 Mon Sep 17 00:00:00 2001 From: Resurrected Trader Date: Tue, 18 Aug 2026 21:48:48 +0100 Subject: [PATCH] fix: Recover profiles left with no framework Two ways a profile could end up with an empty `framework`, and no way out of either in basic mode, where no framework control is rendered at all -- the Frameworks nav is filtered out, /frameworks redirects to /profiles, and the profile dropdown is gated on advanced_mode. The launch then fails with "No framework assigned. Assign a framework to this profile.", telling the user to do something the UI does not let them do. The only repair was accidental: opening the editor fires the auto-assign effect, so saving an untouched profile writes the field back. FrameworkBootstrap now adopts framework-less profiles whenever there is nothing to choose -- no frameworks yet, or exactly one -- because the assignment it would make is the only one the user could make by hand. With two or more it still declines, but says so instead of returning silently: that empty value is the deliberate post-delete state and guessing could launch a profile against the wrong game directory. ProfileForm also shows the dropdown, in either mode, when the saved profile has no framework. The other way in is a handoff. The successor signals Adopted at the top of Main and only then runs Migration and FrameworkBootstrap, so the predecessor stays alive and message-driven right through the migration. Every repository save rewrites its whole file from an in-memory list the OLD schema parsed, so a single run counter arriving in that window drops every field the successor just added -- and frameworks.json survives, so the bootstrap never retried. DataWriteGate closes before the successor is spawned: from then on the data directory belongs to it and this process only reads. It reopens only where no successor can still be migrating (it exited without signalling, or never started); one that is alive but silent leaves the gate closed, since read-only beats two writers on one directory. Co-Authored-By: Claude Opus 5 (1M context) --- CLAUDE.md | 5 +-- .../src/features/profiles/ProfileForm.tsx | 19 ++++++---- src/D2BotNG/Data/CharacterRepository.cs | 2 +- src/D2BotNG/Data/DataWriteGate.cs | 34 ++++++++++++++++++ src/D2BotNG/Data/FileRepository.cs | 17 ++++++++- src/D2BotNG/Data/FrameworkBootstrap.cs | 33 +++++++++++++---- src/D2BotNG/Data/FrameworkRepository.cs | 2 +- src/D2BotNG/Data/KeyListRepository.cs | 2 +- src/D2BotNG/Data/PatchRepository.cs | 2 +- src/D2BotNG/Data/ProfileRepository.cs | 13 +++++-- src/D2BotNG/Data/ProxyRepository.cs | 2 +- src/D2BotNG/Data/ScheduleRepository.cs | 2 +- src/D2BotNG/Data/SettingsRepository.cs | 14 ++++++++ src/D2BotNG/Engine/Handoff/HandoffManager.cs | 36 +++++++++++++++++-- src/D2BotNG/Program.cs | 1 + 15 files changed, 158 insertions(+), 26 deletions(-) create mode 100644 src/D2BotNG/Data/DataWriteGate.cs diff --git a/CLAUDE.md b/CLAUDE.md index a2e21ff..33b297b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -120,14 +120,15 @@ Frontend uses a single gRPC server-stream for all real-time state: - **DaclOverwriter** - Changes DACL for elevated process access ### Data Layer -- **FileRepository** - Generic protobuf JSON file-backed repo using `JsonFormatter`/`JsonParser`. Stores data in `data/ng/` as single JSON documents (list-wrapper messages from `storage.proto`). Durable atomic saves via `Utilities/AtomicFile` (write `.tmp`, flush to disk, rename); unparseable files are quarantined to `.corrupt` and the repo starts empty. Reads and writes both take the SemaphoreSlim; use `MutateAllAsync()` for read-modify-write (GetAll → modify → ReplaceAll loses concurrent writes). Supports `ReloadAsync()` for base path changes. +- **FileRepository** - Generic protobuf JSON file-backed repo using `JsonFormatter`/`JsonParser`. Stores data in `data/ng/` as single JSON documents (list-wrapper messages from `storage.proto`). Durable atomic saves via `Utilities/AtomicFile` (write `.tmp`, flush to disk, rename); unparseable files are quarantined to `.corrupt` and the repo starts empty. Reads and writes both take the SemaphoreSlim; use `MutateAllAsync()` for read-modify-write (GetAll → modify → ReplaceAll loses concurrent writes). Supports `ReloadAsync()` for base path changes. Every save is gated on `DataWriteGate`, so a predecessor mid-handoff writes nothing. - **ProfileRepository** - Extends `FileRepository`, writes each framework's d2bs.ini via IniWriter inside `SaveAsync` (under the repo lock, ordering ini writes with profile saves); `RewriteInisAsync()` for framework-side callers - **KeyListRepository** - Extends `FileRepository`, round-robin key selection, in-use/held state tracking (transient, not persisted) - **FrameworkRepository** - Extends `FileRepository`. A framework bundles `game_directory`, `d2bs_path`, `dll_paths`, `game_version`; profiles reference one by name (`Profile.framework`) and supply the launched executable via `Profile.d2_path`. `FrameworkPaths` resolves the DLL/ini/mules paths from a framework. -- **FrameworkBootstrap** - Idempotent migration: ensures a `Default` framework exists and assigns it to any profile with no framework. Seeds the Default from the pre-frameworks config — `game_directory` from the old install-path setting (else the directory most profiles' `d2_path` live in, else the registry) with `d2bs_path` = `/d2bs`, and game version + retention + health thresholds from `SettingsRepository.LegacySettings` (recovered by `SettingsMigrator`, since those keys were dropped from the `Settings` schema). Only a genuine first-run migration (no frameworks yet) adopts framework-less profiles; once frameworks exist, an empty `Profile.framework` (from a framework delete) is left for the user to reassign. Runs at startup and on base-path change. +- **FrameworkBootstrap** - Idempotent migration: ensures a `Default` framework exists and assigns it to any profile with no framework. Seeds the Default from the pre-frameworks config — `game_directory` from the old install-path setting (else the directory most profiles' `d2_path` live in, else the registry) with `d2bs_path` = `/d2bs`, and game version + retention + health thresholds from `SettingsRepository.LegacySettings` (recovered by `SettingsMigrator`, since those keys were dropped from the `Settings` schema). Adopts framework-less profiles whenever there is nothing to choose — no frameworks yet (first-run migration) or exactly one — since the assignment it would make is the only one the user could make by hand. With two or more it declines and logs a warning naming the profiles: an empty `Profile.framework` there is the deliberate post-delete state and guessing could launch against the wrong game directory. The one-framework case is not a nicety: basic mode renders no framework control at all (nav hidden, route redirected, dropdown gated on `advanced_mode`), so an orphaned profile refused to start with no UI to repair it — `ProfileForm` now also forces the dropdown visible when the saved profile has no framework, in either mode. Runs at startup and on base-path change. - **ItemRepository** - In-memory dictionary; aggregates and watches every framework's `kolbot/mules/`. `RefreshAsync()` rebuilds watchers when frameworks change. - **SettingsRepository** - Singleton, protobuf JSON in `d2botng.json` next to the exe. On load, when the file's `schema_version` is behind, recovers pre-frameworks values into `LegacySettings` via `SettingsMigrator` but deliberately does NOT rewrite the file — leaving it at the old version keeps those values recoverable if startup fails before the framework migration completes; the file upgrades on the next save. A corrupt file is quarantined to `.corrupt` and the app boots with defaults. Stamps `schema_version` on every save - **SettingsMigrator** (`Data/SettingsMigrator.cs`) - Versioned migration for `d2botng.json` (`schema_version`, absent = 0), applied on load up to `CurrentVersion`. Each breaking change archives the old settings shape as a **backend-only proto** in `src/D2BotNG/Legacy/Protos/` (kept out of `protos/`, so it's excluded from the frontend's buf generation) and parses the old file into it — typed and field-tolerant, not raw-JSON poking. v0→v1 recovers the removed `game`/`engine` values, exposed as `SettingsRepository.LegacySettings` for the framework migration. Only the settings file is versioned — the `repeated`-wrapper list files have no place for a version, so their one-off migrations stay in bootstraps +- **DataWriteGate** (`Data/DataWriteGate.cs`) - Process-wide switch that stops this instance persisting anything (every `FileRepository` save, the d2bs.ini writes, and `SettingsRepository`). Closed by `HandoffManager` *before* it spawns the successor, because the successor signals Adopted at the top of `Main` and only then runs `Migration`/`FrameworkBootstrap` — the predecessor is alive and message-driven throughout. A save rewrites its whole file from an in-memory list the OLD schema parsed, so one run counter arriving in that window silently drops every field the successor just added (this is how an update to the frameworks release could leave every profile with no `framework`, which then never self-healed because the successor's frameworks.json survived). Reopened only when no successor can still be migrating: it exited without signalling, or was never started. A successor that is alive but silent leaves the gate closed — read-only beats two writers on one directory. - **Paths** (`Data/Paths.cs`) - Reactive path resolver, subscribes to `SettingsChanged` event. Exposes BasePath, DataDirectory, LegacyDataDirectory (d2bs/mules paths are per-framework now, via `FrameworkPaths`) - **ScheduleRepository**, **PatchRepository** - Standard FileRepository implementations - **Migration** (`Legacy/Models/Migration.cs`) - Static one-time migration from legacy JSONL files (`data/`) to modern protobuf JSON (`data/ng/`). Runs on startup and on base path change. Skips IRC profiles. Separate `MigrateLegacyApi` migrates `server.json` → `LegacyApiSettings`. diff --git a/src/D2BotNG.UI/src/features/profiles/ProfileForm.tsx b/src/D2BotNG.UI/src/features/profiles/ProfileForm.tsx index 1028196..cdddfc2 100644 --- a/src/D2BotNG.UI/src/features/profiles/ProfileForm.tsx +++ b/src/D2BotNG.UI/src/features/profiles/ProfileForm.tsx @@ -91,6 +91,13 @@ export function ProfileForm({ const profilesData = useProfiles(); const advancedMode = useSettings()?.advancedMode ?? false; + // Basic mode hides frameworks entirely, but a saved profile can still be left without + // one — deleting a framework clears every reference to it — and such a profile refuses to + // start. Hiding the only control that repairs it turns that into a dead end, so show + // the picker whenever the profile on disk has no framework, in either mode. + const frameworkOrphaned = !!profile && !profile.framework; + const showFramework = advancedMode || frameworkOrphaned; + // Build set of existing profile names for uniqueness validation const existingNames = useMemo(() => { return new Set(profilesData.map((p) => p.profile.name.toLowerCase())); @@ -295,11 +302,11 @@ export function ProfileForm({ touched.d2Path && d2Path.trim() === "" ? "Diablo II path is required" : undefined, - // Framework is only user-selectable in advanced mode; in basic mode it is - // auto-set to "Default" once the frameworks snapshot arrives, so the error is - // only ever visible in advanced mode (where the dropdown exists). + // Framework is only user-selectable where the dropdown is rendered; elsewhere it + // is auto-set to "Default" once the frameworks snapshot arrives, so the error can + // only ever be seen next to the control it refers to. framework: - advancedMode && touched.framework && framework.trim() === "" + showFramework && touched.framework && framework.trim() === "" ? "Framework is required" : undefined, entryScript: @@ -311,7 +318,7 @@ export function ProfileForm({ // Surfaced near the submit button in basic mode, where the framework dropdown // isn't rendered: without this, a blocked save would be a silent no-op. const basicModeFrameworkError = - !advancedMode && framework.trim() === "" && frameworksData.length === 0 + !showFramework && framework.trim() === "" && frameworksData.length === 0 ? "No frameworks are available. Restart D2BotNG to recreate the Default framework, or enable Advanced Mode in Settings to create one." : undefined; @@ -588,7 +595,7 @@ export function ProfileForm({ onChange={(e) => setProxy(e.target.value)} options={proxyOptions} /> - {advancedMode && ( + {showFramework && (