reloadThemeImages() replaced gradient_tex_ in each of its three branches
(image loaded, load failed, no background) without freeing the old
texture, so every skin activation, including the one at startup, leaked
one texture.
The logo and coin textures were freed immediately instead, although the
current frame's draw lists can still reference them: the window backdrop
records its images at the start of the frame and skin switches happen
mid-frame (Settings, Ctrl+arrow keys), and the screenshot sweep switches
skins after RenderDrawData() but before the secondary viewports are
rendered. That hands the renderer a freed texture.
Textures replaced mid-frame are now retired to a queue (retireTexture()
also zeroes the member) that the next preFrame() frees first, after the
previous frame's draw data has been rendered. The old gradient is
retired at the top of reloadThemeImages(), and the logo and coin where
they used to be freed. ensureLogoTexture() still frees directly, since
it runs at the top of render(), before this frame draws a logo.
Shutdown does not drain the queue.
Measured with a temporary live-texture counter over a screenshot sweep
of all 10 skins: before, 6, 7, ... 15 live textures (one more per skin);
now a constant 5. The first screenshot after each skin switch is
pixel-identical to before on all 10 skins, and the full sweep matches
within noise.
Not verifiable here: DX11 at runtime.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Turning blur off (blur 0), switching to low-spec mode or enabling
reduced transparency after the acrylic targets existed left the capture
FBO and both blur ping-pong targets allocated for the rest of the
session, although nothing draws with them while the effect is skipped.
They are now released at a frame boundary and rebuilt on demand:
- ImGuiAcrylic::ReleaseIdleBuffers() runs right after app.preFrame(),
before NewFrame(), when the previous frame's draw data has been
rendered, and once in the resize-watcher frame (GL and DX11). While
the effect is skipped it frees the targets; when the effect is wanted
again it reallocates them once, with no per-frame retry if that fails;
- resize() while skipped only records that a reallocation is needed;
- the release checks the GL names (and each DX11 texture) directly, so a
half-allocated ping-pong pair is freed too; DX11 also resets the
ping-pong index;
- the capture paths and applyBlur() return early without a capture
buffer, which would otherwise blit the default framebuffer onto
itself;
- each release logs "released targets (effect skipped)".
With blur on, the path and the output are unchanged (screenshot sweeps
within noise). After re-enabling, the first frame draws the tinted
fallback until a fresh capture exists.
Measured with software GL, toggling every 30 frames for 1200 frames: the
targets were released exactly one frame after each switch-off (22 of 22)
and rebuilt on each switch-on (21 of 21), with no GL or framebuffer
errors. While off at 1280x720, with a fixed malloc mmap threshold, the
process holds 4.62 MB less (the 1280x720 RGBA8 capture plus two 320x180
RGBA16F blur targets) and RssAnon is about 3.1-3.5 MB lower. With
glibc's default threshold the freed memory stays in the heap and RssAnon
does not visibly drop.
Known issue: every off/on pair now frees and reallocates those targets.
Under software GL (llvmpipe) with the default glibc allocator the freed
texture memory is not reused: toggling every 2 frames grew RssAnon from
49 to 906 MB over 240 pairs, every 8 frames from 56 to 179 MB, and every
30 frames it levels off near 120 MB. It is not a GL object leak (with a
fixed mmap threshold the mapped bytes stay flat), and on a hardware
driver (WSLg d3d12) RssAnon stayed between 67.5 and 74.9 MB under the
2-frame churn (94.3 MB without the release). Users reach fast toggling
by holding Ctrl+Shift+Down (key repeat, about 20 Hz) or by dragging the
blur slider back and forth across 0. A short delay before releasing
would avoid it if software-rendered users matter.
Not verifiable here: the DX11 release and reallocation at runtime, and
macOS.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The GL window asked for a 24-bit depth and 8-bit stencil buffer, and
every acrylic Framebuffer attached a DEPTH24_STENCIL8 renderbuffer the
size of its color texture. Nothing uses them: no code enables depth or
stencil tests (the ImGui GL3 backend disables both), every blit and
readback is color-only, and only Framebuffer::clear() also cleared the
depth buffer, which nothing read. The window and every FBO, including
the full-size capture FBO, carried that memory, and the FBO
renderbuffers were reallocated on every resize.
Now:
- the window requests depth 0 and stencil 0 explicitly (SDL3 requests a
depth buffer by default when the attributes are simply removed);
alpha 8 stays for the transparent window;
- Framebuffer has no renderbuffer, and clear() clears color only;
- a permanent startup log line,
"GL default framebuffer: depth=N stencil=N", reports what the driver
provided; "(no attachment)" marks a failed query, which is how some
drivers report a buffer that does not exist.
The log shows depth=0 stencil=0 both offscreen and on WSLg X11, where
"Window transparency enabled by compositor" is still logged, with no
framebuffer-incomplete errors. Screenshot sweeps at 1280x720 and
2560x1440, and a glass run with ui_opacity 0.6 and blur on (so panels go
through the FBO blur), match the previous build within noise. The
driver-side memory saving itself was not measured. DX11 is unaffected:
its swap chain has no depth buffer.
Not verifiable here: macOS (an NSOpenGL 3.2 core context with a
transparent window).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Skin handling did more work than it needed:
- the bundled-skin scan fully parsed each of the 9 bundled skin files to
read name, author, dark and images from their first 327-345 bytes. The
build copies the rest of ui.toml into every skin, so each file is
51-54 KB;
- setActiveSkin() reloaded ui.toml right after main() had loaded it, a
second full parse with nothing merged in between;
- in the Windows single-file exe, every getBundledSkinsDirectory() call
(skin refresh, the dragonx entry, each image-dir lookup) re-extracted
all embedded themes with a blind overwrite: more than once per launch,
and again on skin changes.
Now:
- bundled skins are parsed only up to the first table header after
[theme] other than [theme.images]. Anything unusual (no [theme],
images defined later, a parse error in the slice) falls back to the
full parse, and user skins are still parsed in full;
- UISchema tracks whether its state is exactly the base (the last change
was a base load and no hot-reload is pending), and setActiveSkin()
skips reloadBase() in that case;
- the bundled-skins directory is resolved once per process (re-checked
with exists()), and theme extraction rewrites a file only when its
bytes differ, through the new resources::writeFileIfDifferent(). It
compares bytes, not sizes, so a same-length palette edit still
updates.
Measured on the Lite build (median of 5 runs):
- bundled-skin scan: 15 -> 1 ms;
- from "Initializing ObsidianDragon" to the activated skin: 29 -> 12 ms;
- ui.toml is loaded once per launch instead of twice, and the light skin
merges its overlay exactly once.
A Python mirror of the header-slice parse agrees with a full parse on
name, author, dark and images for all 18 source and build-expanded skin
files, and full screenshot sweeps over all 10 skins match the previous
build within noise. New unit tests cover writeFileIfDifferent(): missing
file, identical bytes (mtime untouched), same-length change, and a
longer file truncated.
Behaviour changes:
- a bundled skin with a TOML error after its header is now listed
instead of skipped; the error surfaces only when it is selected;
- deleting a single file inside the extracted bundled-themes dir
mid-session is repaired only at the next launch;
- the "Extracted N embedded bundled themes" count includes files that
were already current.
Not verifiable here: Linux has no embedded themes, so the new
writeFileIfDifferent() ran only in the unit tests and the extraction
path was only compiled, in the Windows cross-build. That a second
Windows launch leaves the theme files' mtimes untouched is still to be
checked on Windows.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
main() built the font atlas before App::init() loaded ui.toml, so the
first build used the hard-coded fallback sizes (H1 24 px, Body2 12 px).
App::init() then rebuilt for a saved font scale above 1.0, still before
the schema load and so still at the fallback sizes. Only the color-emoji
rebuild requested for the first frame reached the ui.toml sizes, so
color-emoji users built the atlas twice at font scale 1.0 and three
times at 1.5.
Users with chat_emoji_color=false never got that last rebuild. They
built the atlas once or twice and ran the whole session at the fallback
sizes, e.g. Body2 at 12 px instead of 15 and H1 at 24 px instead of 20.
Now:
- App::loadBaseUiSchema(), the ui.toml load moved out of App::init()
unchanged, runs in main() before both InitImGui paths (DX11 and GL),
together with the saved font scale and emoji style from the early
settings read. It is idempotent, so the call left in App::init() is a
no-op;
- the two rebuild triggers in App::init() are gone;
- Typography records what the atlas was built from (the 15 schema sizes,
the schema's font scale, the effective emoji style and the schema
generation), and preFrame() rebuilds once when they differ. While the
schema generation and the emoji style are unchanged this costs two
compares per frame.
Measured on the Lite build (color and mono emoji, font scale 1.0 and
1.5, English and Japanese): every run builds the atlas exactly once, at
the ui.toml sizes times the scale (H1 20 px and Body2 15 px at 1.0,
30 px and 22 px at 1.5), and ui.toml is loaded before the window and GL
context are created. The packaged Lite zip and AppImage log one atlas
build instead of two. Color-emoji screenshot sweeps match the previous
build within noise.
Visible changes:
- users with chat_emoji_color=false had been rendering the whole UI at
the fallback font sizes; they now get the designed ui.toml sizes, so
every mono-emoji screen differs from before and matches the
color-emoji layout;
- a user skin whose [fonts] differ from ui.toml now rebuilds the atlas
once when it is activated or left; on a runtime switch the atlas used
to keep the old sizes until a restart. With a test skin that sets
Body2 to 18 px, startup builds twice and ends at 18 px; switching to a
bundled skin rebuilds once, switching among the 9 bundled skins
rebuilds nothing (they carry an identical copy of [fonts]), and
switching back rebuilds once;
- editing ui.toml while running still gives exactly one rebuild.
Not verifiable here: the Windows DX11 path with the schema loaded before
its first FreeType build, macOS, and the interactive font-scale slider,
Alt+scroll and emoji toggle, which were checked by reading the code.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Typography copied every font blob into a fresh heap buffer for each face
it added (IM_ALLOC + memcpy, FontDataOwnedByAtlas = true): the Ubuntu
weight, the CJK subset merge, the emoji merge, and the icon and pickaxe
fonts. The blobs are INCBIN symbols in read-only memory that live for
the whole process, so the copies (16 faces, each with its own CJK copy
and, for 8 of them, an emoji copy) only cost memory. Styles with the
same weight and size also built separate, identical ImFonts.
Now:
- every font is added straight from the embedded data with
FontDataOwnedByAtlas = false. ImGui 1.92 frees only data it owns, so
the blobs survive atlas clears and DPI or skin reloads;
- load() walks a style table and shares the ImFont between styles with
the same (weight, scaled size, emoji) key. Emoji is part of the key,
so no style gains or loses the emoji fallback. With the current
ui.toml sizes H3/H4, H6/ButtonLg and Body1/Overline share a face: 13
ImFonts instead of 16 (12 text faces plus Mono), CJK merged 13 times
instead of 16 and emoji 7 times instead of 8.
Measured on the Lite build, RssAnon 15 s after start, median of 3 runs:
- color emoji: 108.6 -> 78.8 MiB (-29.8 MiB);
- monochrome emoji: 102.8 -> 78.6 MiB (-24.2 MiB).
RssFile is also 2.4-5.1 MiB lower, because the blobs are no longer read
end to end for copying. Full screenshot sweeps (610 screens per run; color
and mono emoji at 1280x720 in English and Japanese, color at 2560x1440)
match the previous build within run-to-run noise. The only content
differences are live values such as the mining tab's RAM readout (233 MB
before, 201 MB after).
Also fixed: when AddFontFromMemoryTTF failed, ImGui had already freed
the owned copy and the caller freed it again, a latent double free;
those IM_FREE calls are gone. The emoji-merge failure log now prints the
blob size instead of passing the float font size to %u.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
CMake turned every res/lang/*.json into an xxd header that i18n.cpp
compiled in as a fallback, on every platform. Only the Windows
single-file exe needs it: the Linux zip and AppImage and the macOS .app
always ship res/lang, and loadLanguage() reads the loose file first. On
Linux the embedded copy added about 1.5 MB to the binary, and every
translation edit recompiled i18n.cpp.
A new option, DRAGONX_EMBED_LANG, defaults to ON for WIN32 and OFF
elsewhere:
- ON without xxd is now a configure error (it used to warn, then fail to
compile i18n.cpp on the missing headers);
- i18n.cpp compiles the embedded includes and the fallback only when ON;
- outside Windows, loadLanguage() also tries
<exe>/../share/<app>/res/lang (the cmake --install layout and the
full-node AppImage) and <exe>/../Resources/res/lang (the macOS .app
binary run directly), so those layouts find the loose files whatever
the working directory; before, only the embedded copy covered that.
Measured:
- full-node Linux binary: .data 1,699,696 -> 168,592 bytes; stripped
19,983,136 -> 18,456,128 bytes (-1.53 MB);
- with the ImGui demo stubs, the Linux zips and AppImages are
0.50-0.55 MB smaller, with identical file lists including all 8
language files;
- a Lite build started from an unrelated working directory loads the
ja.json next to the binary (2422 strings), and the packaged Lite zip
and AppImage load ja.json with no new not-found or failed lines;
- Windows keeps the embedded copy: the 8 language headers are generated
and the ja.json bytes are in the exe.
A new test loads "de" from the loose files and checks a translated
string; with bin/res/lang moved away exactly its two assertions fail.
Behaviour changes on Linux and macOS: a missing or corrupt res/lang now
falls back to English instead of the embedded copy, and a translation
edit needs only the copy_langs step instead of a recompile.
Not verifiable here: the macOS .app in a non-English language, and the
Windows single-file exe at runtime.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The Windows release embeds its resources into the exe through a
generated INCBIN header. The Lite build got the same param and asmap
entries as the full node, so ObsidianDragonLite.exe carried
sapling-spend.params, sapling-output.params and asmap.dat (about 53 MB)
that it never reads: the SDXL backend embeds its own copy of the params,
and Lite already skips extracting full-node-only resources.
build_release_win now writes the param and asmap INCBINs only for the
full node (should_bundle_full_node_assets). Lite gets null/zero stubs
for those three symbols and still embeds images, themes and xmrig. Two
fixes make that safe:
- getEmbeddedResource() stopped at the first table entry with null data,
so with the params stubbed out the Lite lookup ended before xmrig:
mining would report itself available but never find the embedded
binary. It now walks to the end of the table and skips null payloads.
This also fixes a full-node build without asmap, whose lookup ended at
the null asmap entry and hid the daemon and xmrig entries.
- full and Lite builds share build/windows. When the full-node build
cannot find the params, the generated header is now deleted, so a Lite
header left behind by an earlier Lite build is not compiled into it
(the node would then never start, because the startup job would take
the embedded-extraction path and never copy the loose params).
Measured on release builds:
- ObsidianDragonLite.exe: 159,788,544 -> 106,120,192 bytes (-53.7 MB, of
which about 0.28 MB is the ImGui demo stubs);
- the Lite header has no param or asmap INCBINs and the resource object
holds only the xmrig payload; a sample of sapling-spend.params occurs
once in the exe instead of twice (the remaining copy is the
backend's);
- the full-node header is byte-identical to before.
Not verifiable here (Windows runtime): the Lite exe starting without
embedded params, and mining through the embedded xmrig. Lite installs
from older versions may still hold about 53 MB of extracted params and
asmap in %APPDATA%\ObsidianDragonLite\dragonx; nothing deletes them.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
While the node-status banner shows, its detail line was fitted every
frame by popping one character at a time and re-measuring the whole
"· " + detail + "…" string: O(L^2) per frame in the detail length L,
which adds up on the ~1.5 KB Windows crash report. The pop also left a
dangling UTF-8 lead byte before the "…", so a cut in non-ASCII text drew
replacement characters in place of the ellipsis.
- util::textfit::ellipsizeTail builds the same string in O(L + m log m),
m being the length of the line the cut lands in: ImGui measures
multi-line text as its widest line, so the old loop could only stop
inside the first line that overflows on its own. Byte-identical for
ASCII; the cut now lands on a code-point boundary.
- EllipsisFitCache keeps the result until the detail, budget, font size,
font (by FontId) or rasterizer density changes, so an unchanged frame
costs one string compare.
- When ImGui would draw a line wider than the per-line split predicts
(a malformed byte joined with the following newline, as in CP-1252
output from the Windows daemon), the old whole-string loop is used for
that text.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
TruncateToWidth, the middle "front...back" ellipsis for addresses, pool
URLs, paths, labels and the status bar, cut at byte offsets: a
multi-byte character at either cut was split into invalid UTF-8 and
drawn as replacement characters (e.g. a translated Cyrillic or CJK
status). It also tried front lengths from n/2 down until one fit,
building and measuring a new string for each: up to O(n^2) per
truncated label per frame.
util::textfit::middleEllipsis keeps the same contract and is
byte-identical for ASCII, but snaps the front cut down and the back cut
up to code-point boundaries, and finds the largest fitting front length
by bisection into one reused buffer: O(log n) measures for single-line
text. Text containing a newline keeps the linear scan, because its
measured width (the widest line) does not grow steadily with the cut.
text_fit.h also gains largestFittingPrefix, a bisection over code-point
boundaries; its first caller is the node-status banner in a following
change.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Every Typography text, textWrapped and textColored call rebuilt all 15
type specs to read one uppercase flag: 28 ui.toml element lookups, 13
of them copying the element with its string map. With M such labels on
screen, that was 28M lookups and 13M copies per frame.
The specs are now built once per UI-schema generation, which every
ui.toml load, hot reload and skin or theme switch bumps. DPI, font
scale and language are not inputs, so nothing else invalidates them.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
With any console filter on, each batch of new output rescanned all N
lines to rebuild the visible set. A text filter lowercase-copied the
needle and every line to test it: O(total characters) and 2N
allocations per batch.
When the filter is unchanged and the model only appended or evicted
lines since the last pass, the visible indices drop the evicted lines
and shift down, and only the new lines are tested. The result equals
a full rescan, since line text and channel never change and the
filtered view has no folds. A filter change, Clear, or a model window
that doesn't follow on from the last one still rescans.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Each batch of new output re-wrapped all N visible lines glyph by glyph,
allocating a fresh segment vector per line; a line evicted at the
10,000-line cap shifted every index, so nothing could be kept. And on
every frame, idle or not, seeding the scanline shading summed the wrap
rows of every line above the view.
- The model numbers its lines and the view keeps one wrap per line,
aligned by that number, so an eviction drops a prefix instead of
forcing a rebuild. New output wraps only the new lines; the layout is
then a prefix sum over the N visible lines, with no measuring and no
per-line allocation.
- The wraps are kept while the wrap width, line height, font size, font
(by ImFont::FontId) and rasterizer density are exactly unchanged. A
line-spacing change only re-sums heights; filters and fold toggles
reuse the cached wraps. A resize drag still re-wraps every visible
line per frame, as before, but reuses their storage.
- The scanline seed is read from the prefix sum: O(1) per frame.
Heights, offsets and segments are bit-identical to the old rebuild. A
font reload at an unchanged size (the colour-emoji toggle) now re-wraps
the console at once; before, it waited for the next line of output.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The Overview address list built all N rows on every frame (icons,
labels, balances, buttons, an InvisibleButton and a context-menu
check per address) and allocated a vector of N row positions, however
few rows fit on screen; the edge mask then walked all their vertices.
An ImGuiListClipper now submits only the rows that intersect the
list's clip rect, plus:
- one guard row on each side, so dividers and anti-aliased edges at
the clip boundary are drawn as before;
- the row being dragged and the row whose context menu is open, so
drag-to-reorder and the menu keep working when they scroll away;
- the rows keyboard navigation targets (kept by the clipper itself).
Before each row the cursor is put back exactly where the unclipped
loop left it, so the density-scaled spacing, which depends on the
cursor position, and every row's geometry are unchanged. Row tops are
computed from the index, so the per-frame vector is gone, and the
scroll extent is the same. The row model (sort, filter) is still
rebuilt over all addresses each frame.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
ApplyScrollEdgeMask, which fades a scroll area's content at its top
and bottom edges, walked every vertex the area emitted on every frame
and tested both edges per vertex, even when the content fit without
scrolling and no vertex could ever be written. It now returns before
the walk when neither edge fades.
The fade maths moved unchanged into scroll_edge_fade.h and is tested
against the old inline expression, so rendering is identical. All six
callers benefit without an edit: the Overview address list, Mining's
recent blocks, Console, History, Explorer and Peers.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The Mining tab's memory bar read used system RAM and the wallet's own
memory on the UI thread every frame: synchronous OS queries, on Linux
an open and parse of /proc/meminfo and /proc/self/status per frame.
Both are now sampled at most once a second; the first frame after
more than a second idle resamples at once. Total RAM was already
cached and daemon RAM already comes from a background sampler.
The used-RAM and wallet-memory readings in the bar and its tooltip now
refresh at most once a second; what they show is unchanged.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The Mining tab's earnings card walked all N wallet transactions on
every frame to total Today / Yesterday / All time and pick the 4 most
recent rows: four string compares per transaction plus a memo search
on receives. It also ran localtime and mktime every frame to find
local midnight.
The pass is now a pure function behind a one-slot cache keyed on
WalletState::tx_revision, the transaction count, the filter pill, the
Solo/Pool mode, solo support and local midnight, with a 2 s maximum
age as a safety net. It reruns only when one of those changes or the
2 s age runs out, instead of every frame. The totals are bit-identical
(same loop, same summation order), and crossing local midnight still
moves Today into Yesterday at once.
Local midnight is recomputed only when the clock second changes,
through the shared util::localTime; a failed localtime now yields a
zeroed struct instead of an uninitialised one. The "x ago" labels and
Est. Daily are still computed per frame.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The copy button of a text selection in a chat bubble copied
body.substr(start, len) with byte offsets kept from earlier frames.
Those offsets can outlive a thread change (an older message inserted
above shifts the message indices, so the selection can land on a
shorter body), and a start past the end made substr throw
std::out_of_range out of the chat render; the main loop catches it,
but the rest of that frame's UI is dropped.
The copy now runs only when the start is not past the end of the body.
substr already clamps the length, so the copied text is the same in
every case that did not throw before.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Every frame the open chat thread handled all N messages: grouping flags
from two day keys each, a measuring layout of every body, then the
draw, which laid every body out a second time and ran the bubble, hover
and popup code, however few rows were on screen.
The thread now keeps a layout cache: grouping flags, each body's
wrapped size (keyed by message, so only new or re-wrapped bodies are
measured) and each row's height, with prefix offsets. A frame binary-
searches the visible range and submits those rows plus any failed send
(Retry stays reachable with Tab), the row being drag-selected and the
row whose context menu is open; one spacer stands in for each skipped
run, and scrolling to the newest message works as before.
- Rows are rebuilt when the chat store changes or when the wrap width,
fonts, font size, DPI scale, rasterizer density or compact mode
change. Fonts are keyed by FontId, so a font reload at the same size
(the colour-emoji toggle) re-measures. A rebuild is O(N), about
0.5-1 ms at 3000 messages.
- Grouping flags are recomputed on a data change and once a minute,
together with util::refreshTimezone(), so a system timezone change
reaches chat timestamps within a minute.
Rows above the visible range are now one spacer of their summed
heights, so the visible rows no longer move by a pixel each time a row
above them crosses the top of the app window. Debug builds log once if
a cached wrap width or row height ever disagrees with the live layout.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The open chat thread called std::localtime at least twice per message
per frame (two day keys for grouping), and more for day separators and
time labels. On glibc every call re-checks the timezone with a stat of
/etc/localtime. The chat time helpers now use util::localTime() from
the new util/local_time.h: localtime_r (localtime_s on Windows), with
no per-call timezone check and no shared static result buffer.
glibc's localtime_r reads the timezone only once, so local_time.h also
adds util::refreshTimezone() (tzset) for caches to call when they
rebuild. Nothing calls it yet; its first caller is the chat thread's
grouping-flags cache in the next commit.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Every frame the History tab:
- hashed the txid, amount, confirmations, time, type and address of
all N transactions (about 0.6-5 ms at 5k-20k) to decide whether its
totals and its merged, sorted row list were stale;
- copied every chat txid into a fresh set;
- ran the type and search filter over all N rows, with up to three
case-insensitive substring searches each.
The totals and the row list now key on tx_revision (the row list also
on the sort mode), the Message badge and the Chat filter use the chat
store's txid index, and the filtered rows are rebuilt only when the row
list, the type filter, the search text or, under the Chat filter, the
chat store changes. A frame is now a few compares plus one txid lookup
per row of the current page. An unchanged full-node refresh no longer
rebuilds the totals and the row list either: the old fingerprint folded
in the refresh time.
On lite, a change to only a memo or a sender address now shows at once.
The old fingerprint left those fields out and relied on a refresh time
that lite never sets, so such a row stayed stale until something else
in the list changed, such as the next block.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The History tab copies every chat txid into a fresh set each frame to
badge and filter chat transactions: M string allocations for M chat
messages. ChatStore gains hasChatTxid(), an O(1) lookup in a txid index
that is rebuilt only after the store changed (append, delivery update,
erase, clear) and never built for an empty store. clear() also drops
the index, so no chat txids linger after a lock. The History tab moves
to it in the next commit.
The chat note-buffer pump, which runs every frame while chat is idle,
built the whole conversation-id list just to see whether the store was
empty; it now asks store().empty(), O(1) instead of O(M).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
WalletState had no change signal for its transaction list, so a UI
cache had to fingerprint all N transactions every frame to notice an
update. It now carries tx_revision, a counter that only increases;
clear() on disconnect bumps it instead of resetting it, so a cache
built before a reconnect or wallet switch can never match again.
- Every writer of the list bumps it: the full-node refresh, loading the
cached history, adding, completing and removing a pending send, a
mining-address relabel, the lite refresh and the screenshot demo
rows.
- The full-node refresh and the lite refresh (every ~2 s) swap in the
new list only when it differs field by field, the amount compared
bit-exactly because -0.0 and +0.0 print differently. An unchanged
refresh keeps the list and the revision; the compare costs O(N) per
refresh, not per frame.
Nothing reads the counter yet.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The image picker decoded up to 6 full-size images per frame on the UI
thread to make its thumbnails (1.5-2.7 s per frame with 12 MP photos,
until the visible grid filled) and read every .gif/.webp a second time
just to see whether it animates. Thumbnails were freed only on navigate
or close, so a large folder and every hovered animation (up to ~20 MB
each) stayed resident.
Thumbnails now decode on util::StartImageDecode workers (at most 2 at
once, so the top of the visible grid fills first) and the UI thread
uploads up to 8 small textures per frame. The animation badge comes from
the same decode, with no second read. Hover previews get their own
2-slot lane, and the picker asks for a frame every 100 ms while anything
is pending; under idle pacing a deferred thumbnail used to wait for input
or the 2 s tick.
Behaviour changes:
- thumbnails and hover frames are LRU-capped at 64 MiB per folder;
evicted ones re-decode when they are needed again;
- leaving an animated thumbnail before its preview finishes decoding
cancels it (re-hover restarts it); a finished preview is kept;
- animated WebP files get a real thumbnail instead of the placeholder;
- images above the decoder's limits show the placeholder.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The Contacts tab read, decoded and downscaled custom avatars inside the
draw call, two per frame, rows scrolled out of view included: a 12 MP
photo froze the window for 0.25-0.45 s, a long GIF for up to 1.5 s, and
the avatar cache was never evicted. "Add image" decoded the picked file
and copied it inside the picker's confirm callback, freezing it again.
Avatars now decode on util::StartImageDecode workers (at most 2 at once).
The UI thread only uploads finished frames, at most about 4 MB per UI
frame, and asks for a frame every 100 ms while anything is pending;
under idle pacing a deferred avatar used to wait for input or the 2 s
tick.
Behaviour changes:
- an avatar scrolled out of view is not decoded until it scrolls in
(the badge shows for 0.1-0.3 s the first time);
- avatar textures are LRU-capped at 128 MiB; the least recently drawn
go first, never one drawn on-screen in the previous Contacts frame,
and they re-decode on scroll-back;
- an animated avatar starts playing once all its frames are uploaded;
- an import is validated and copied on a worker: the avatar appears
0.1-0.5 s after confirming and Save/Add stays disabled until it does;
a result only lands in the dialog session that started it;
- the copy goes to a temporary file renamed over the target (retried
briefly), so a crash or a failed copy never leaves a truncated avatar
or removes the existing library image;
- imports get the decoder's limits, and an animated WebP can now be
imported (it always failed with "couldn't be loaded").
Applying an import also no longer destroys an avatar texture the same
frame has already drawn (a use-after-free on DX11).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Image decoding lived in the GL texture file and had no limits: a file of
any size was read whole, stb and libwebp allocated whatever the header
claimed (a WebP alone up to 1 GB), and a GIF was decoded at full size,
every frame, before the 300-frame cap. A 720p 150-frame GIF avatar cost
0.2-1.5 s and 140-370 MB on the UI thread. stb's GIF loader also read
before the start of its buffer on disposal-3 frames, and when a long GIF
ran out of memory it freed the frame delays that the caller then freed
again (an abort).
Decoding, and the stb implementation, now live in the GL-free
util/image_decode_core, safe on any thread:
- the header is probed first; stills above 256 MP, any GIF or animated
WebP canvas above 32 Mi px, and files above 256 MiB are rejected
before anything is allocated (real photos, 200 MP phone shots and
wide panoramas still load);
- a GIF decodes one composited frame at a time, downscaled as it goes,
and stops at the frame cap: peak memory is stb's working buffers, two
canvases and the small frames; a truncated GIF keeps the frames
decoded so far;
- a header stb cannot read means "unsupported", never a blind decode;
- decodes above 16 MP run one at a time; a decode can be cancelled
between frames.
util::StartImageDecode runs one decode on a detached worker that owns its
job and its lane's in-flight counter, so nothing waits at exit; the next
commits move avatars and the image picker onto it. The limits apply now
to avatars and the picker's hover previews; thumbnails and avatar imports
get them when they move to workers.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
On a first run or after an upgrade, Windows release builds wrote the
bundled Sapling params, asmap, node and miner (about 88 MB) in
App::init, before the first frame: 0.5-3 s of frozen blank window, 5-30 s
on a slow disk or under antivirus. Zip, AppImage and .app builds copied
the bundled files into the daemon dir and hashed the 51.5 MB of params on
the UI thread at the first node start, a 0.3-1.5 s freeze in the wizard
or the connect loop.
A stat-only check at startup decides whether anything is missing, stale
or unverified; a normal launch starts no thread and shows nothing.
Otherwise one background job extracts (Windows) or copies (Linux, macOS)
the files, then verifies the params once:
- The first frame paints at once. If the job takes longer than 0.4 s, a
chip at the bottom shows "Preparing wallet files... N%", then
"Verifying wallet files...".
- A node start on the UI thread while the job runs is deferred and
replayed when it ends (never into a bootstrap download, a restart or a
live connection); worker threads wait, and give up on quit. After an
upgrade that ships a new miner, the node starts after the miner is
written instead of before. Pool-miner starts and the miner version
check wait too, so the old miner is never run or reported while it is
being replaced.
- Each file goes to "<name>.partial", is flushed and renamed into place
under one lock shared with the daemon start, the bundled-node
reinstall and the miner extraction, and the job stops between 4 MB
chunks at shutdown. An interrupted write used to leave a truncated
binary that was trusted forever; a leftover .partial is now removed.
Other behaviour changes:
- Linux and macOS full-node builds copy the bundled node files (about
77 MB) into the daemon dir at first launch, even if the wallet ends up
on an external node; before, this happened at the first embedded start.
- Lite no longer writes the 53 MB of params and asmap it never reads.
- A disk-full or permission failure that leaves the node files unusable
shows a warning; a miner blocked by antivirus alone does not.
- A corrupt or truncated param in a zip, AppImage or .app install is
replaced from the bundle and verified again instead of trusted forever.
- Reinstalling the bundled node holds off the connect loop and a deferred
start for its whole run (either could launch the old binary while the
new one was written), and is refused while another restart is running.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
On Windows every full-node launch read the whole installed dragonxd.exe
(about 16.7 MB) on the UI thread and byte-scanned it for a version
string, to decide whether to offer the bundled node, which only needs
whether the file exists and its size. getInstalledDaemonInfo() takes a
scanVersion flag, and the update offer now only stats the file.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Every open of the Wallets dialog, Refresh, folder add or remove, and Open
on a stale row walked each user-added folder recursively on the UI thread
before the dialog drew: up to 40,000 entries per folder, roughly
50-150 ms warm on Linux and seconds on a cold disk, a network share or
Windows.
The datadir listing (a few top-level files) stays synchronous, so a
wallet just created shows at once. The recursive walk runs on a detached
worker with the same depth, hit and entry limits; its rows are merged at
the start of a later frame and only the new rows are probed.
- The dialog opens at once with the datadir rows. The external rows
arrive when the walk ends, with "Scanning folders..." beside the
scanned-folders caption, and the card grows once.
- Reopening within 60 s with the same folders reuses the last walk
without touching the disk, so its rows and sizes can be up to a minute
stale: a wallet copied into a folder in that minute needs Refresh.
Refresh and Open on a stale row always walk again.
- While a walk or a wallet probe runs (up to 30 s) the dialog keeps
frames coming, so rows and badges appear without moving the mouse.
- The 200-row cap now holds across all folders; each extra folder could
add one row past it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Open on a wallet row whose file was moved or deleted after the list was
built rebuilt the list from inside the dialog's row loop. The loop still
held a reference to that row and walked a sort order sized for the old
list, so the click read freed memory and could index past the end. Open
now flags a rescan for the next frame; the vanished row drops out one
frame later, with no crash.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The backend's error for a key it doesn't recognize repeats the key
("'<key>' was not recognized as either a spending key or a viewing
key"). Import passed that text on as its error, so a mistyped private
key was written to the lite log (shown in the Console tab) and to the
Settings status line. A failed import now carries a fixed reason
(unrecognized format, wallet locked, key already exists, could not be
decoded, or rejected), which is all the log records. Settings shows
"Couldn't import the key — check it, unlock the wallet (if encrypted)
and try again." instead, translated in all eight languages.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Clicking the Network-tab card of the server the wallet was already
pinned to and connected to tore the controller down and reopened the
wallet against the same server. That click is now a no-op. A wallet that
failed over to another server still reconnects, which retries the pinned
one.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
New address, Import key, Encrypt and Remove encryption called the
backend on the UI thread. Each ends in the backend's save, which waits on
the lock a scan holds for its whole duration and every refresh read holds
briefly, so during a scan the window froze until the scan ended. A create
or restore with a passphrase encrypted and saved just after its first
scan had started: for a restore, a freeze for the whole initial scan. A
server switch destroyed the old controller on the UI thread and joined
its refresh worker, which can sit for minutes in a read against a dead
server (each balance, notes or list read retries for ~62 s).
- The four ops run one at a time on a background lane and their results
are applied on the main thread; Settings shows "Working…" meanwhile.
Key and passphrase copies are wiped after the call, or when a queued
op is dropped.
- A create or restore encrypts and saves (an open unlocks) before the
first scan starts, so the save takes milliseconds and the scan runs on
the encrypted file.
- Import, Encrypt, Remove encryption and Receive's New address are
disabled while a scan runs ("Wait for blockchain to sync"); Redownload
blocks is disabled while an op is queued.
- A switch retires the old controller without blocking (queued ops
dropped, pending ones reported failed, its client shut down) and
destroys it on a background thread. It no longer saves on the way out:
the new client re-scans the blocks since the last save, as after a
restart.
- A switch waits for an op in flight, at most 15 s, so the op can't
finish and save on the old client while the new one owns the wallet
file. The Network tab already shows the new selection meanwhile.
- The refresh worker stops between backend commands, skips the remaining
server reads once one fails, and then retries after 4, 8, then 15 s
instead of every 2 s.
- Quit waits up to 2.5 s for an op in flight, e.g. Encrypt's save.
Encrypt and Remove encryption now report a failed save: the backend
returns it as a JSON error that the old check never saw, so the failure
was reported as success. The trimmed copy of a key typed into Import is
now wiped too, and stopping the refresh worker can no longer miss its
wakeup.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A FIFO of blocking jobs, run one at a time on a background thread that a
post starts when none is running and that exits once the queue is empty.
Each job returns a completion, and completions run only in pump(), on
the owner's thread. Unlike RPCWorker and AsyncTaskManager nothing ever
joins: abandon() (also run by the destructor) drops queued jobs and
undelivered completions and refuses later posts, and a job stuck in a
blocking call finishes on its own thread. It is meant for backend calls
that can wait out a long scan and may have to be given up. post()
reports a dropped job (abandoned lane, or no thread could be started),
and waitIdle() is bounded.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The daemon's RSS was read on the UI thread: a scan of every /proc entry
on Linux (1-7 ms), a forked ps on macOS (up to ~30 ms). The mining poll
probed every 1-5 s on every tab, and the Mining tab's memory bar probed
again every 1.5 s.
util::BackgroundSampler now runs the probe on one lazily started thread
and caches the result. getDaemonMemoryUsageMB() returns the last sample
and asks for a new one at most every 3 s, and only while the Mining tab
memory bar or the Peers tab Memory cell is drawn: nothing on other tabs
or while minimized. EmbeddedDaemon::memoryProbe() hands the sampler a
pid snapshot (on Windows the value read from the owned process handle),
so nothing off-thread touches daemon state. The mining poll does no I/O
any more; it carries the last sample. Both shutdown paths stop the
sampler.
Fixed along the way: an unreadable /proc made the directory iterator
throw on the UI thread; the scan now just stops.
User-visible:
- the Peers tab Memory cell keeps updating during sync and shows an
external daemon's memory (it showed the mining poll's value, which
pauses during sync and covers only the embedded daemon);
- the Mining tab memory bar refreshes every ~3 s (was 1.5 s).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Finding the lowest block a node still has (Settings > Rescan, and the
automatic reconcile after a bootstrap) ran a binary search of about 42
synchronous getblockhash/getblock calls on the UI thread, each waiting
for the RPC connection behind any wallet scan (10-20 s on a big wallet)
and each with a 30 s timeout. The window froze for that long.
It now runs as one task on the wallet RPC worker, bounded to 90 s, and
the answer comes back on the UI thread; meanwhile the dialog shows
"Checking which blocks..." with Rescan disabled. An answer from an
earlier connection counts as "could not tell", and one for an earlier
opening of the dialog is dropped. The search probes block 1 first, so a
full-history node answers in one probe, and it answers "could not tell"
at once while a runtime rescan holds the lane.
Fixed along the way: any probe error (timeout, busy node) read as "block
missing", so a busy node could get a bogus "bootstrapped, rescan from
the tip"; and a non-string getblockhash reply never called back, leaving
the dialog on "Checking..." for good. Only the daemon's "can't read
block" error now counts as missing; anything else means "could not
tell".
User-visible:
- after a bootstrap, a probe that cannot tell is tried up to 6 times,
30 s apart and only while the node is not busy, then a toast points
to Settings > Rescan, instead of falling back to the -rescan
restart, which fails on a bootstrapped node;
- the Settings Rescan dialog warns when the probe could not tell.
Two new strings, translated into all eight languages.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The CoinGecko price, tickers and market chart and the exchange candle
GETs ran on the wallet's RPC worker, the same FIFO lane as balance,
history and sends. A slow or blocked endpoint held that lane for up to
10-12 s per price or ticker fetch and 2 x 30 s per chart or candle job,
and a wallet scan in turn delayed the price. At quit a hung GET held
shutdown until the 15 s watchdog cut teardown short.
They now run on their own RPCWorker thread; results are applied on the
main thread and wake the idle loop. A new util::httpGet takes a cancel
flag (checked by libcurl's progress callback), sets CURLOPT_NOSIGNAL and
returns the HTTP status and error; httpGetString wraps it. Shutdown sets
the flag, so queued GETs return at once and one in flight aborts within
about a second. The price uses a plain in-flight flag instead of the
connection-scoped refresh job, so a disconnect no longer throws away a
good in-flight price and leaves it loading until the next tick.
User-visible:
- chart and candle GETs use a 5 s connect / 20 s total timeout (were
15 s / 30 s);
- the price request goes through the shared GET: User-Agent
ObsidianDragon/1.0 (was DragonX-Wallet/1.0) and a 16 MiB reply cap;
- market fetches on connect start alongside the wallet refresh instead
of queuing behind it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Every history refresh, address refresh, connect and lock-state check
rewrote the encrypted tx-history snapshot on the UI thread: a JSON
document of every row, its encryption, then INSERT OR REPLACE and a
prune, an estimated 40-120 ms on a 5k-transaction wallet, even when
nothing had changed.
Now a signature of what would be stored (about 1 ms on 5k rows)
decides whether to write. Confirmations count only by the thresholds
the UI reads (conflicted, pending, 1-9, 10-99, 100+); per-address scan
heights, exact confirmations and the tip drift every block and form a
separate part.
- Routine refreshes write a content change at most every 20 s (a
deferred one is released from the update loop) and the per-block
drift at most every 10 min. Nothing changed means no write.
- A mining-address relabel, a passphrase change, a rescan, a
disconnect, a wallet lock and exit write whatever is not on disk yet,
at once.
- The UI thread only copies the rows and the key into a job; a single
background writer (newest job wins) builds, encrypts and stores it. A
failed write is retried.
- Exit queues the final write so it overlaps the daemon stop, then
waits up to 3 s for the writer; a disk that hangs past that keeps the
previous snapshot.
- A loaded snapshot counts as stored, so the first identical refresh
doesn't rewrite it. An address refresh re-keys the snapshot only when
the address set changed.
After a hard kill the snapshot can now be up to ~20 s behind for rows
and 10 min for scan heights, exact confirmations and the tip (it was
one refresh); the first refresh replaces it either way. Lite builds,
which never read this cache, no longer write it on Refresh or a mining
relabel, and the screenshot capture mode's demo rows never reach it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The tx-history cache was only ever used from the UI thread and had no
locking. To let snapshots be written from a background thread:
- every SQL statement runs under one database mutex and the key fields
under a second one, taken after it and never held across I/O or the
key derivation;
- a write is a self-contained, move-only SnapshotJob holding the rows
and a private copy of the key, which is wiped after the write or when
a job never runs; the JSON build and encryption hold no lock;
- a job with an all-zero key is refused instead of overwriting a good
snapshot with one nobody can read;
- the prune after a write also keeps the wallet unlocked now, so a late
job for the previous wallet can't delete the salt of the one just
switched to;
- keyId() gives a non-reversible tag of the installed key, so a
passphrase change can be told from a re-unlock with the same one.
replace() stays a synchronous wrapper with the same result. A failed
INSERT is now logged.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The thread grid is inert during a benchmark, but the -/+ buttons and the
count field still took input: a click changed the shown and saved count
while the benchmark owned the miner's (before the previous commit it
also restarted xmrig at that count and spoiled the measurement). The
buttons and the field are now disabled until the benchmark ends.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Every thread-count change on the Mining tab saved settings.json and,
with a miner running, stopped and restarted it: xmrig re-inits its
RandomX dataset and reconnects to the pool, seconds of lost hashing
each time. A drag across the grid did this once per tile crossed (up to
~24), with a "Starting pool miner" toast each; every -/+ click or Enter
did it again.
The tab now hands each pick to the App, which puts the newest in
settings at once and applies it when the input settles: on drag
release, or 0.5 s after the last click, even if the Mining tab was left
in the meantime. The header, RAM estimate and tile fill still follow
the drag live.
- Pool: the restart is decided on the mining-control thread, after every
earlier start or stop has run. It never starts a miner the user
stopped, is a no-op when xmrig already runs at that count, and is no
longer silently skipped when the pick lands in another restart's stop
window (which left xmrig on the old count).
- Solo: one setgenerate per gesture. A pick made while one is in flight
now waits for it instead of being dropped (another stale count).
- Clicking the tile that is already selected does nothing; it used to
save settings and restart the miner.
- With idle thread scaling on, the grid no longer snaps back to the old
count while a pick is on its way to the miner. A pool auto-balance
restart keeps a count still queued.
- Idle thread scaling changes the pool count through the same retarget.
Its correction after a start compares with the count last requested,
not the xmrig API's lagging report, so it comes about 1 s in instead
of after the first API report (3-5 s).
- A retarget whose pool config no longer validates shows the same error
and stops the miner, as the old stop-then-start did.
- While a benchmark runs a pick is saved, but the miner change is
dropped: the benchmark owns the count until it ends.
A crash within 0.5 s of a pick, or mid-drag, loses that pick; every
clean exit saves it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The requested thread count is written by generateConfig() on the
mining-control thread and read on the UI thread (the thread-grid sync
under idle thread scaling, the pool auto-balance restart) without any
synchronisation. The write also stored the unclamped count first and
clamped it afterwards, so a reader could see more threads than cores.
It is now an atomic, set by one store of the clamped value.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Settings::save() and WalletIndex::save() ran the atomic write (temp
file, fsync, rename, directory fsync) on the UI thread: on every
preference change, and for wallets.json after every address-scan pass
whose wallet metadata changed, i.e. repeatedly during sync, when the
disk is busiest. Each took milliseconds to tens of milliseconds, and
seconds under heavy disk load.
The JSON is still built on the caller; the write is queued on an
App-owned CoalescingWriter, latest wins per file, one write at a time.
- After a fund- or security-critical setter (seed-migration stage,
encryption pending, PIN enabled, active wallet file, wizard completed)
the next save still waits until the file is on disk, so e.g. the
sweep-submitted marker is durable before the sweep is broadcast. A
failed durable save keeps the next one durable.
- Saves before the writer is attached (the upgrade re-save at startup),
and in tests and tools, stay synchronous.
- main() flushes the queue (at most 3 s) before and after
App::shutdown(), before the single-instance lock is released, so a
relaunch never reads a stale settings.json.
A kill or power loss right after an ordinary save can now lose that
last preference change; the previous file stays intact. WalletIndex
saves report "queued" rather than "written" (no caller reads it). On a
wedged disk, exit can wait up to 2 x 3 s longer, inside the 15 s
watchdog.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Six sliders (noise opacity, UI opacity, window opacity, chat font size,
chat poll rate, contacts list scale) rewrote settings.json, with its two
fsyncs, on every frame the value changed: 60-300 durable writes per
drag, at 1-4 ms each on NVMe, 3-20 ms on a Windows SSD, 20-60 ms on a
HDD and seconds while dragonxd is busy on the disk.
The value still applies live and is kept in Settings for the per-frame
readers; settings.json is written once, when the slider is released, as
the blur and font-scale sliders already did. A drag cut off before its
release (a dialog closed mid-drag) is persisted by the next save or at
exit.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
On Linux and macOS openUrl/openFolder spawned xdg-open/open and waited
for it on the UI thread: 50-300 ms per click where the opener forks and
exits, and on generic xdg-open sessions (sway, i3, WSLg, ...), which run
the browser or file manager as xdg-open's foreground child, the window
froze until the user closed that app. The opener is now left running
and a detached thread reaps it; the click returns at once.
Its stdin, stdout and stderr go to /dev/null again. That redirect was
lost in the switch to posix_spawnp, so a text-mode browser fallback
could take over the wallet's terminal.
The result now means "launched" rather than "exited 0" (no caller reads
it), and a non-zero opener exit is reported on stderr instead of in the
debug log. Windows (ShellExecute, which never waited) is unchanged.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
On Linux and macOS the lock file was opened without O_CLOEXEC, so
xdg-open and the browser it starts, dragonxd and xmrig all inherited it.
A clean exit unlocks explicitly, but after a crash or kill the lock
stayed held for as long as any of those children ran (indefinitely with
"keep daemon running"), and the wallet refused to relaunch. The fd is
now opened O_CLOEXEC; nothing else changes.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
On Windows writeFileAtomically wrote the temp file through std::ofstream,
whose CRT handle is inheritable. xmrig and the hidden helper commands
(where, nvidia-smi, xmrig --version) are started with handle inheritance
on, so one started during a save kept "<file>.tmp" open without delete
sharing, and every later replace of that file failed for as long as the
child ran: hours while mining, with settings saves quietly not landing.
The flush then reopened the file with share mode 0, so it was silently
skipped whenever AV or the search indexer had the file open, and a
replace refused by them just failed.
- Windows: one CreateFileA handle, not inheritable, writes and flushes
the temp file; the replace is retried up to 4 times (20+40+80+160 ms)
on access-denied, sharing or lock violations, where it used to fail.
- Linux/macOS: the temp file and directory fds are opened O_CLOEXEC, so
a dragonxd or xmrig fork+exec doesn't inherit them.
A later commit moves settings writes to a background thread, which makes
every one of them concurrent with those spawns. The vault, the exports
and DRAGONX.conf use the same function and get the same fix.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
hasPendingRPCResults() becomes hasPendingBackgroundResults(): the one
check the idle loop makes, every <= 200 ms, for work that finished on a
background thread and still has to be applied by update(). Lanes added
later put their term there. It still covers only the two RPC worker
lanes; no behaviour change.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
One background thread for blocking write jobs, for the settings.json /
wallets.json saves and the tx-history snapshot that later commits move
off the UI thread. Jobs are keyed (a file path, "tx-history"):
- a submit for a key that is still queued replaces that job and keeps
its place in line (latest wins);
- keys run first in, first out, one job at a time, so two writes of one
file never overlap and an older snapshot never lands after a newer one;
- submitAndWait() blocks until that job (or a newer one for the key) has
run and returns its result, for writes the caller needs on disk;
- flush() waits with a bound; stop() runs everything queued, then joins;
- the thread starts on first use and sleeps when idle; with no thread
(none could be created, or after stop()) a job runs inline.
persistFileAtomically() routes Platform::writeFileAtomically through a
writer, or writes synchronously when there is none. Nothing uses it yet.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
"Keep in each address" posted one task that called z_mergetoaddress for
every batch of every address back to back; each call is a whole-wallet
scan under cs_main, and the daemon runs the ops one at a time anyway.
It is now an App-owned services::ConsolidationRun:
- one RPC at a time; the next merge only after the previous batch left the
queue and a gap as long as the last call took (2-120 s);
- pauses while the node syncs, the wallet is locked, a send of ours is in
progress, or another wallet scan is running;
- failures classified: nothing that may have queued is ever resent; a
breaker stops after 3 dead addresses or 3 failed ops in a row;
- each merge is charged to the scan budget; one re-read and one forced
balance at the end;
- it keeps running when the dialog closes: a status-bar chip, a run view
with progress, the wait reason and a Stop button.
The "Into one" merge can no longer be confirmed twice while its call is
in flight. fake_dragonxd gains FAKE_SCAN_SECS, FAKE_MERGE_SERIAL and
FAKE_MERGE_FAIL.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>