Skip to content

Local Assets: post-merge defect-review fixes#298

Merged
RyeMutt merged 18 commits into
developfrom
rye/local-assets-review-fixes
Jun 10, 2026
Merged

Local Assets: post-merge defect-review fixes#298
RyeMutt merged 18 commits into
developfrom
rye/local-assets-review-fixes

Conversation

@RyeMutt

@RyeMutt RyeMutt commented Jun 10, 2026

Copy link
Copy Markdown
Member

Follow-up to #296: fixes for the defects found in a deep post-merge review of the Local Assets feature (use-after-free, server-traffic leaks, persistence and live-reload correctness), plus two engine-level issues the review surfaced along the way.

Crash / use-after-free fixes

  • Anim live-reload UAF: doUpdates() freed the globally cached JointMotionList before stopping the motions playing it; setStopTime/constraint teardown then read freed memory on every reload while a preview anim played. New LLMotionController/LLCharacter::purgeMotionInstances() excises every instance of an id — including deprecated duplicates still easing out, which removeMotion() can't reach — swept across all characters before the cache purge. Also prunes dead avatars from the play map (a dead entry made the reload retry + restart the anim on every live avatar every 3s, forever) and fixes a JointMotionList leak per extra avatar per reload.
  • Texture picker multi-select Remove: the row-pointer snapshot died mid-loop once delUnit() started firing the units-changed signal synchronously (refresh → clearRows()); snapshot values instead.
  • Threaded model loads vs teleport/logout: LoadContext held a raw LLVOAvatar* the DAE loader dereferences (and writes joint overrides through) on the worker thread while region teardown could free the preview avatar; now held by LLPointer.
  • LLDrawInfo::mSkinInfo is now owning (like its mAvatar): the mesh repo culls cached skins on a pure getNumRefs() == 1 check every 10s, so the refcount is the skin's lifetime contract — but the per-frame matrix-palette uploads read through a raw pointer the cull couldn't see. LLFace::mSkinInfo stays a raw mirror deliberately: faces and their group's draw infos are (re)built together, so a face's skin is always pinned by its volume or the co-generated draw infos.

Local previews must never talk to the simulator

  • sendSelectionMove() (joystick/spacenav build-move) was the one transform sender without the all-local gate.
  • The grab tool sent ObjectGrab/ObjectGrabUpdate/ObjectDeGrab/ObjectSpin* for previews (they spawn with FLAGS_OBJECT_MOVE, so the grab activated); every send is now gated on isLocalOnly.
  • canSelectObject()'s local/real homogeneity check sat below the mForceSelection early-return, so right-click/temp selects could build a mixed selection that fails every whole-selection gate open; the check now runs ahead of force-selection.
  • A server temp attachment (null item id) landing on a worn preview's attach point could dedup-kill the preview and send ObjectDetach with localid 0; the dedup now skips when the matched object is local-only.
  • getDecomposition/fetchPhysicsShape/hasPhysicsShape short-circuit local ids instead of parking them in the repo's loading sets forever.
  • Duplicate (Ctrl+D / shift-drag-copy) on a preview was a silent no-op; it now routes to a client-side duplicatePreview().

Remove / persistence correctness

  • Remove never stuck: removePath() ran before delUnit(), whose teardown signals fired while the dying unit was still listed — the add-only path persistence re-recorded it (mesh teardown order, and multi-material glTF sibling units). delUnit now delists before signal-firing teardown and the floater forgets the path last.
  • User and mesh-owned units of the same file are distinct; the filename lookups now take the ownership class explicitly, so the Textures/Materials tabs can no longer resolve (and delete) a loaded mesh's hidden imports, and mesh face binding prefers the mesh's own imports.
  • Rez/Attach on a not-yet-decoded row no longer erases the persisted Joint Positions flag.
  • local_assets.xml is written via temp file + rename so a crash mid-write can't blank the saved working set.
  • Picker selection restore matched rows by stringified map LLSD (always row 0); it matches unit id + type now.

Live-reload correctness

  • Hot-swap face restore no longer clobbers file-level material edits: live state is captured before ingestScene commits (ingest's release of dropped imports already resets faces), and only fields that differ from the old file's import — actual user edits — are restored over the freshly applied file state.
  • Material files regroup on live edit: import-time dedup collapses content-identical glTF materials onto one unit with alias names; a divergent edit now splits the group (fresh unit) instead of leaving the binding name aliased at the stale canonical forever, added/removed materials add/drop units, and meshes re-bind their faces by the stored binding name — skipping faces the user re-materialed in-world.
  • Toggling Joint Positions during the initial parse no longer launches a second concurrent loader.
  • mMaterialName is no longer blanked by a failed re-decode (mid-save retry window), which mis-bound faces via the mat<index> fallback.
  • Anim mtime checks use fsyspath like the sibling managers (non-ASCII Windows paths silently disabled live reload).

Engine-level

  • LLModelLoader::run() posted its completion via doOnIdleOneTime() from the worker thread; LLCallbackList has no synchronization (the upstream TODO at the call site says as much). It now posts through the thread-safe "mainloop" WorkQueue, with the idle-list fallback for headless/tests.
  • The skin cull posted mThread->mSkinMap.erase(id) for every cached skin every tick — outside the refcount check — emptying the repo thread's deep-copy mirror within one tick of arrival (defeating its per-joint bounding-box use for late-loading volumes). It now erases only evicted ids, batched into a single work item.

🤖 Generated with Claude Code

RyeMutt and others added 17 commits June 9, 2026 23:29
doUpdates() freed the globally cached JointMotionList (removeKeyframeData)
BEFORE stopping the motions playing it; LLKeyframeMotion::setStopTime and
constraint teardown then dereferenced the freed list on every live reload
while a preview anim was playing. Stop everything first, then purge.

Stopping via mPlaying alone also missed instances the map no longer tracks:
replaced/stopped motions still easing out and deprecated duplicates from a
double Play. New LLMotionController/LLCharacter::purgeMotionInstances()
immediately excises EVERY instance of an id (canonical + deprecated); the
delUnit/reload paths sweep all characters with it before freeing the cache.

Also:
- prune mPlaying entries whose avatar died instead of counting them as a
  reapply failure -- a dead entry left the mtime unconsumed, re-parsing the
  file and restarting the anim on every live avatar every 3s, forever
- reapplyToAvatar deserializes only on cache miss: each deserialize builds a
  new JointMotionList and addKeyframeData() overwrites the slot without
  freeing, leaking one list per extra avatar per reload
- wrap last_write_time in fsyspath like the sibling managers; non-ASCII
  Windows paths silently disabled live reload and the deferred BVH re-decode

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
onBtnRemove snapshotted raw LLScrollListItem pointers, but delUnit() now
fires the units-changed signal synchronously: the picker's own
onLocalAssetsChanged() -> refreshLocalList() -> clearRows() frees every
row item mid-loop, and the next iteration read freed memory. Snapshot the
(tracking id, asset type) values instead and mutate the managers after.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
LoadContext held a raw LLVOAvatar*; the DAE loader resolves (and writes
joint-position overrides onto) joints through it from the model loader's
worker thread. A teleport away from the spawn region or a logout mid-parse
(despawnObjectsInRegion/cleanup) markDead()s the preview avatar and drops
the manager's reference, freeing it under the loader. Hold it by LLPointer:
joints on a dead-but-referenced avatar stay valid, and the ref releases on
the main thread when the load callback frees the context.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ttach

Remove never stuck: removePath() ran first, then delUnit() fired manager
signals while the dying unit was still listed (mesh: despawnUnit's signal +
owned-import releases fire before the list erase; materials: a multi-
material file's sibling units survive each per-unit delUnit) -- and the
add-only LLLocalAssetPaths::onUnitsChanged() re-recorded the path from
getFilenames(), resurrecting the row every session.

- LLLocalMeshMgr::delUnit() now delists the unit before any teardown that
  fires signals, so listeners never see a half-deleted unit
- the floater Remove paths delete the units first and forget the path last

Rez/Attach on an undecoded (dimmed) row decoded via addUnit's default
include_joints=false, bypassing the saved flag loadPath applies -- and the
next onUnitsChanged then erased the saved flag to match. addAndSpawn/
addAndAttach take include_joints now; the floater passes the persisted one.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- sendSelectionMove() (the joystick/spacenav build-move path) was the one
  transform sender without the all-local gate; it shipped
  MultipleObjectUpdate blocks with localid 0 for previews
- the grab tool sent ObjectGrab/ObjectGrabUpdate/ObjectDeGrab and the
  ObjectSpin* stream for previews (they spawn with FLAGS_OBJECT_MOVE, so
  permMove() let the grab activate); gate every send on isLocalOnly
- canSelectObject()'s local/real homogeneity check sat below the
  mForceSelection early-return, so temp/right-click (forced) selects could
  build a mixed local+real selection -- every whole-selection local gate
  then fails open (localid-0 blocks go out, and selectDelete stops routing
  previews to the local delete path). Check homogeneity ahead of
  force-selection.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The same-inventory-item dedup in LLViewerJointAttachment::addObject was
guarded only for the INCOMING object being a local preview. A real
attachment with a null item id (a temporary attachment carries no
AttachItemID) landing on the same point still matched the worn preview's
null item id, markDead()'d the preview, and -- since previews carry
FLAGS_OBJECT_YOU_OWNER -- sent an ObjectDetach with localid 0 to the sim.
Skip the dedup when the matched object is local-only too.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A user-loaded texture/material and a mesh-owned import of the same file are
deliberately distinct units, but getUnitID/getTrackingIDs/getWorldIDsByName
matched by filename alone. Depending on load order, the Textures/Materials
tabs could claim a mesh's hidden import as their row's unit and delete it
(stripping the material a loaded local mesh still renders with, while the
user's own unit survives and re-records the just-removed path), and a mesh
could bind its faces to the user's deletable copies instead of its own
imports. The lookups now take the ownership class explicitly:

- the floater tabs and picker resolve user units only (also un-hides the
  dimmed saved row when only a mesh-owned twin of the file is loaded)
- mesh texture import reuses its own prior import first, then a user copy
- mesh face binding maps mesh-owned materials, with the user set as a
  fallback only if the mesh-owned import failed to load

Also stop clobbering LLLocalGLTFMaterial::mMaterialName on a failed
re-decode (file locked mid-save): the blanked name made getWorldIDsByName
fall back to "mat<index>" and bind faces wrongly during the retry window.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
forceReload() only checked mReloading, so toggling Joint Positions on a
still-decoding unit (ST_LOADING) launched a second concurrent parse whose
completions crossed states with the first -- a late failure would even
delUnit a unit whose first parse had succeeded. Skip the reload in that
state: ingestScene() reads the build options when the in-flight parse
lands, so the new flag is picked up anyway.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Ctrl+D / shift-drag-copy on a preview was a silent no-op: the all-local
gate swallowed the ObjectDuplicate send, and the gate comment wrongly
claimed LLLocalMeshMgr already handled duplication. selectDuplicate() now
routes an all-local selection to LLLocalMeshMgr::duplicatePreview(), which
rezzes a fresh instance at the source copy's transform plus the offset
(same user-delta rotation capture as respawnInstancesInPlace), honoring
select_copy. Worn copies still don't duplicate, matching the sim path;
duplicate-on-ray stays intentionally dropped, and the gate comment now
says so.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
getDecomposition()/fetchPhysicsShape() queued repo-thread requests for
local preview ids (reachable from physics-shape rendering and the build
floater's physics tab); with no header and no HTTP they just retried and
parked the id in mLoadingDecompositions/mLoadingPhysicsShapes forever.
hasPhysicsShape() triggered the same fetch. Short-circuit all three for
local ids like the repo's other entry points.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
onLocalAssetsChanged() re-selected via setSelectedByValue() with the row's
map-valued LLSD; an LLSD map stringifies to "" under that comparator, so
the highlight always jumped to the first enabled row after any local-asset
change elsewhere. Match the unit id + type against the row values instead.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
writeToDisk() truncate-wrote the file in place; a crash mid-write left a
truncated file that reloadForAccount() silently normalized to an empty
working set, losing the saved paths. Write to a sibling .tmp and rename it
over the old file (std::filesystem::rename replaces in one step).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
LLModelLoader::run() posted its completion with doOnIdleOneTime() from the
loader's worker thread, but LLCallbackList has no synchronization -- a race
the local-assets live reload turned from a rare upload-floater event into a
steady-state mechanism (the upstream TODO at the call site even called it
out). Post through the thread-safe "mainloop" WorkQueue instead, falling
back to the idle list when no queue exists (unit tests / headless).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The mesh repo culls cached skins on a pure refcount check (mSkinMap
entries with getNumRefs() == 1, every 10 seconds), so the refcount IS the
skin's lifetime contract -- but the per-frame matrix-palette uploads read
the skin through a raw LLDrawInfo pointer the cull can't see. A volume
that dropped its reference (mesh id change, or a local-mesh reload/delete
releasing the unit and volume refs mid-frame) left the render path one
cull tick away from reading freed memory.

LLDrawInfo::mSkinInfo becomes an LLPointer like its mAvatar: every
in-world uploadMatrixPalette() call site reads the skin through a draw
info, so the draw call now pins what it renders. LLFace::mSkinInfo stays
a raw mirror (matching its mAvatar): faces and their group's draw infos
are (re)built together in rebuildGeom, so a face's skin is always pinned
by its volume or by the co-generated draw-info references -- the one
face-based reader outside rebuilds is the octree debug display, covered
by the same invariant.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The skin cull posted mThread->mSkinMap.erase(id) for EVERY skin in the
main map -- the post sat outside the getNumRefs() == 1 eviction check. The
repo thread's map is a deep-copy mirror kept so volume loads can compute
per-joint bounding boxes without taking the main map's path; erasing it
unconditionally emptied the mirror within one 10s tick of arrival, quietly
defeating that cache for any volume loading later than that, and posted
one work item per cached skin every tick.

Erase the mirror only for skins actually evicted from the main map, and
batch the evicted ids into a single posted work item.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… edits

restorePreservedFaces() re-applied every captured TE wholesale after
applyPartGeometry(), so a reload that changed a face's texture, color,
fullbright or material in the SOURCE FILE was immediately overwritten with
pre-reload state -- live material edits never showed on spawned copies, and
each swap re-captured the stale state so it stuck for good. Worse, the
capture ran after ingestScene had already released dropped imports, whose
teardown resets live faces to IMG_DEFAULT -- so a rebound face could
capture-and-restore the placeholder forever.

The restore now diffs instead: the live face state and the OLD parts'
imported materials are snapshotted in onLoadResult BEFORE ingest commits,
and after the new file state is applied only fields that differ from the
old import -- i.e. actual user edits -- are restored (texture compared by
local-bitmap tracking id too, so world-id rotation on a texture's own
reload doesn't read as an edit). Fields no import can author (glow,
transforms, bump/shiny, Blinn-Phong maps, glTF overrides) restore as-is.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…eshes

Import-time dedup collapses content-identical glTF materials onto one unit
and records the other binding names as aliases, but a live reload only
re-decoded each unit's own material index -- the alias structure was frozen
at import. Editing the file so a formerly identical material diverges left
its binding name aliased at the stale canonical unit forever (and faces
bound to it rendering the wrong material); added materials got no unit,
removed ones left orphans retrying.

LLLocalGLTFMaterialMgr::doUpdates now runs a structural pass per changed
(file, ownership class) group: surviving units keep their tracking/world
ids and re-decode as before, diverged or newly added indices get fresh
units, removed indices drop theirs, and every alias is rebuilt from the
current per-index content signatures.

Local meshes bind faces to these materials by name only at ingest, so when
a regroup moves the file's name -> world id mapping the mesh manager now
re-binds: LLLocalMeshFaceMaterial keeps the glTF binding name, and
rebindFaceMaterials() re-resolves the stored face materials and re-points
spawned faces -- skipping faces the user re-materialed in-world (the same
only-if-still-showing-the-import rule as the hot-swap diff-restore), and
updating the stored import so later hot-swap diffs compare correctly.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Jun 10, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Summary by CodeRabbit

  • New Features

    • Local mesh preview duplication with correct offsets; improved hot-swap reload preserving per-prim edits.
    • Option to include joint metadata when spawning/attaching previews.
  • Bug Fixes

    • Preserve Local-tab selection in texture picker; prevent mixed local/network selections.
    • Suppress simulator/network messages for client-only (local preview) objects.
  • Improvements

    • Better ownership-aware asset tracking (user vs mesh-owned) and material rebinds after live edits.
    • More robust local-asset file writes and streamlined global motion-instance cleanup.

Walkthrough

The PR adds purgeMotionInstances APIs and uses them for safe anim reloads, disambiguates mesh-owned vs user-owned local assets, refactors local-mesh hot-swap with pre-swap snapshots and diff-restore, gates interactions for client-only previews, and applies infra fixes (work-queue handoff, atomic local-asset writes, skin-info ownership).

Changes

Motion Purging Foundation for Animation Reloads

Layer / File(s) Summary
Motion purge API and headers
indra/llcharacter/llcharacter.h, indra/llcharacter/llmotioncontroller.h
New purgeMotionInstances(const LLUUID& id) declarations in LLCharacter and LLMotionController to remove canonical and deprecated instances.
LLMotionController purge implementation
indra/llcharacter/llmotioncontroller.cpp
Implements purgeMotionInstances: remove canonical motion via removeMotion(id) then remove matching deprecated instances from mDeprecatedMotions.
LLCharacter forwarding
indra/llcharacter/llcharacter.cpp
LLCharacter::purgeMotionInstances forwards the request to mMotionController.purgeMotionInstances(id).

Local Anim Reload Flow

Layer / File(s) Summary
LLLocalAnimMgr lifecycle refactor
indra/newview/lllocalanim.cpp
Switch to fsyspath for last_write_time, rewrite delUnit/reapplyToAvatar/doUpdates to prune stale mPlaying entries, purge motion instances globally, remove cached keyframe data once, and reapply only to resolved avatars.

Asset Ownership Disambiguation Across Managers

Layer / File(s) Summary
Bitmap manager ownership-aware lookup
indra/newview/lllocalbitmaps.h, indra/newview/lllocalbitmaps.cpp
getUnitID now takes mesh_owned and matches filename + ownership.
GLTF material manager ownership-aware lookups & regrouping
indra/newview/lllocalgltfmaterials.h, indra/newview/lllocalgltfmaterials.cpp
Add mesh_owned parameter to lookup APIs, add regroupFileMaterials(), and change doUpdates() to group by (filename, mesh_owned) and rebind when mappings change.
Local Assets floater ownership filtering
indra/newview/llfloaterlocalassets.cpp
Texture/material tabs query with mesh_owned=false; mesh tab passes saved joint overrides; remove flow defers path deletion until after unit teardown.

Mesh Reload Hot-swap and Diff-restore

Layer / File(s) Summary
Pre-swap snapshot and capture
indra/newview/lllocalmesh.h, indra/newview/lllocalmesh.cpp
Add LLLocalMeshPreSwapSnapshot and capturePreSwap() to record per-copy per-prim TE/render-material state and old imported materials.
Diff-restore and material rebinding
indra/newview/lllocalmesh.cpp
Refactor restorePreservedFaces to diff-restore using snapshot, restore render-material overrides, and add rebindFaceMaterials() to remap face render-material IDs as needed.
Hot-swap integration
indra/newview/lllocalmesh.cpp
Capture pre-swap in onLoadResult, thread snapshot into hotSwapInWorld, and apply per-prim diff-restore during hot-swap.
Reload safety and lifecycle
indra/newview/lllocalmesh.cpp
Block forceReload during ST_LOADING and rewrite delUnit to remove unit from mMeshList before despawn/signals and only remove owned imports when unreferenced.
GLTF ingest & import preference
indra/newview/lllocalmesh.cpp
Always record per-face material binding name; resolve render-material ID only when mapping exists; prefer mesh-owned imports when available.

Local-only Preview Object Interaction Control

Layer / File(s) Summary
Selection and duplication gating
indra/newview/llselectmgr.cpp
Enforce homogeneous selection (isLocalOnly), intercept all-local-preview duplicate/repeatDuplicate to duplicate locally via LLLocalMeshMgr::duplicatePreview and skip network ObjectDuplicate.
Grab/spin and message suppression
indra/newview/lltoolgrab.cpp
Guard startSpin/stopSpin/hover/grab message sends with isLocalOnly to suppress simulator traffic for client-only previews.
Attachment deduplication change
indra/newview/llviewerjointattachment.cpp
Skip same-inventory dedup/markDead when either incoming or matched attachment is local-only.
Preview duplication and joint APIs
indra/newview/lllocalmesh.h, indra/newview/lllocalmesh.cpp
Add duplicatePreview and include_joints flags on addAndSpawn/addAndAttach; floater passes joint overrides for undecoded rows.

Infrastructure and Supporting Systems

Layer / File(s) Summary
Skin info ownership & mesh repo batching
indra/newview/llspatialpartition.h, indra/newview/llmeshrepository.cpp
Convert LLDrawInfo::mSkinInfo to LLPointer to pin skin info; batch skin-map eviction and short-circuit physics/decomposition for local meshes.
Model loader completion handoff
indra/llprimitive/llmodelloader.cpp
Post loadModelCallback to mainloop LL::WorkQueue when present, with fallback to doOnIdleOneTime() for headless/no-mainloop cases.
Atomic local-asset persistence
indra/newview/lllocalassetpaths.cpp
Write local_assets.xml via temp-file + rename with error handling to avoid corrupt writes.
Texture picker robustness
indra/newview/lltexturectrl.cpp
Snapshot selected units before deletion to avoid re-entrancy invalidation; restore selection by matching LLSD id/type fields.

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly Related PRs

Suggested Labels

c/cpp, llui, llprimitive, viewer

Poem

🐰 A rabbit hopped through meshes new and old,
Purged motions gone, and snapshots gently hold,
Joints kept tidy, local previews stayed light,
Hot-swaps stitched with care by lanterned night,
Hooray — no network chatter spoiled the sight!

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 16.67% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed Title clearly summarizes the main change: a follow-up PR addressing defects found in post-merge review of the Local Assets feature.
Description check ✅ Passed Description is comprehensive and covers all major changes: crash fixes, server-traffic leaks, persistence/live-reload correctness, and engine-level issues with detailed explanations.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

Warning

There were issues while running some tools. Please review the errors and either fix the tool's configuration or disable the tool if it's a critical failure.

🔧 Infer (1.2.0)
indra/llprimitive/llmodelloader.cpp

indra/llprimitive/llmodelloader.cpp:27:10: fatal error: 'linden_common.h' file not found
27 | #include "linden_common.h"
| ^~~~~~~~~~~~~~~~~
1 error generated.
Error: the following clang command did not run successfully:
/opt/infer-linux-x86_64-v1.2.0/lib/infer/facebook-clang-plugins/clang/install/bin/clang-18
@/tmp/coderabbit-infer/1b2fe34742f6583f075481e20bb207b7fe386ea5-da9c907b2ba1668a/tmp/clang_command_.tmp.5c874c.txt
++Contents of '/tmp/coderabbit-infer/1b2fe34742f6583f075481e20bb207b7fe386ea5-da9c907b2ba1668a/tmp/clang_command_.tmp.5c874c.txt':
"-cc1" "-load"
"/opt/infer-linux-x86_64-v1.2.0/lib/infer/infer/bin/../../facebook-clang-plugins/libtooling/build/FacebookClangPlugin.dylib"
"-add-plugin" "BiniouASTExporter" "-plugin-arg-BiniouASTExporter" "-"
"-plugin-arg-BiniouASTExporter" "PREPEND_CURRENT_DIR=1"
"-plugin-arg-BiniouASTExporter" "MAX_STRING_SIZE=65535" "-cc1" "-triple"
"x86_64-unknown-linux-gnu" "-emit-obj" "-mrelax-all

... [truncated 1095 characters] ...

lib/clang/18/include"
"-internal-isystem" "/usr/local/include" "-internal-isystem"
"/usr/lib/gcc/x86_64-linux-gnu/12/../../../../x86_64-linux-gnu/include"
"-internal-externc-isystem" "/usr/include/x86_64-linux-gnu"
"-internal-externc-isystem" "/include" "-internal-externc-isystem"
"/usr/include" "-Wno-ignored-optimization-argument" "-Wno-everything"
"-fdeprecated-macro" "-ferror-limit" "19" "-fgnuc-version=4.2.1"
"-fskip-odr-check-in-gmf" "-fcxx-exceptions" "-fexceptions"
"-D__GCC_HAVE_DWARF2_CFI_ASM=1" "-o"
"/tmp/coderabbit-infer/da9c907b2ba1668a/file.o" "-x" "c++"
"indra/llprimitive/llmodelloader.cpp" "-O0" "-fno-builtin" "-include"
"/opt/infer-linux-x86_64-v1.2.0/lib/infer/infer/bin/../lib/clang_wrappers/global_defines.h"
"-Wno-everything"

indra/newview/lllocalassetpaths.cpp

In file included from indra/newview/lllocalassetpaths.cpp:25:
indra/newview/llviewerprecompiledheaders.h:36:10: fatal error: 'linden_common.h' file not found
36 | #include "linden_common.h"
| ^~~~~~~~~~~~~~~~~
1 error generated.
Error: the following clang command did not run successfully:
/opt/infer-linux-x86_64-v1.2.0/lib/infer/facebook-clang-plugins/clang/install/bin/clang-18
@/tmp/coderabbit-infer/1b2fe34742f6583f075481e20bb207b7fe386ea5-34e705a0992ff2f2/tmp/clang_command_.tmp.213f56.txt
++Contents of '/tmp/coderabbit-infer/1b2fe34742f6583f075481e20bb207b7fe386ea5-34e705a0992ff2f2/tmp/clang_command_.tmp.213f56.txt':
"-cc1" "-load"
"/opt/infer-linux-x86_64-v1.2.0/lib/infer/infer/bin/../../facebook-clang-plugins/libtooling/build/FacebookClangPlugin.dylib"
"-add-plugin" "BiniouASTExporter" "-plugin-arg-BiniouASTExporter" "-"
"-plugin-arg-BiniouASTExporter" "PREPEND_CURRENT_DIR=1"
"-plugin-arg-BiniouASTExporter" "MAX_STRING_SIZE=65535" "-c

... [truncated 1168 characters] ...

lib/clang/18/include"
"-internal-isystem" "/usr/local/include" "-internal-isystem"
"/usr/lib/gcc/x86_64-linux-gnu/12/../../../../x86_64-linux-gnu/include"
"-internal-externc-isystem" "/usr/include/x86_64-linux-gnu"
"-internal-externc-isystem" "/include" "-internal-externc-isystem"
"/usr/include" "-Wno-ignored-optimization-argument" "-Wno-everything"
"-fdeprecated-macro" "-ferror-limit" "19" "-fgnuc-version=4.2.1"
"-fskip-odr-check-in-gmf" "-fcxx-exceptions" "-fexceptions"
"-D__GCC_HAVE_DWARF2_CFI_ASM=1" "-o"
"/tmp/coderabbit-infer/34e705a0992ff2f2/file.o" "-x" "c++"
"indra/newview/lllocalassetpaths.cpp" "-O0" "-fno-builtin" "-include"
"/opt/infer-linux-x86_64-v1.2.0/lib/infer/infer/bin/../lib/clang_wrappers/global_defines.h"
"-Wno-everything"

indra/newview/lltoolgrab.cpp

In file included from indra/newview/lltoolgrab.cpp:27:
indra/newview/llviewerprecompiledheaders.h:36:10: fatal error: 'linden_common.h' file not found
36 | #include "linden_common.h"
| ^~~~~~~~~~~~~~~~~
1 error generated.
Error: the following clang command did not run successfully:
/opt/infer-linux-x86_64-v1.2.0/lib/infer/facebook-clang-plugins/clang/install/bin/clang-18
@/tmp/coderabbit-infer/1b2fe34742f6583f075481e20bb207b7fe386ea5-43fbc6e4946f6180/tmp/clang_command_.tmp.6bf007.txt
++Contents of '/tmp/coderabbit-infer/1b2fe34742f6583f075481e20bb207b7fe386ea5-43fbc6e4946f6180/tmp/clang_command_.tmp.6bf007.txt':
"-cc1" "-load"
"/opt/infer-linux-x86_64-v1.2.0/lib/infer/infer/bin/../../facebook-clang-plugins/libtooling/build/FacebookClangPlugin.dylib"
"-add-plugin" "BiniouASTExporter" "-plugin-arg-BiniouASTExporter" "-"
"-plugin-arg-BiniouASTExporter" "PREPEND_CURRENT_DIR=1"
"-plugin-arg-BiniouASTExporter" "MAX_STRING_SIZE=65535" "-cc1" "-t

... [truncated 1147 characters] ...

nstall/lib/clang/18/include"
"-internal-isystem" "/usr/local/include" "-internal-isystem"
"/usr/lib/gcc/x86_64-linux-gnu/12/../../../../x86_64-linux-gnu/include"
"-internal-externc-isystem" "/usr/include/x86_64-linux-gnu"
"-internal-externc-isystem" "/include" "-internal-externc-isystem"
"/usr/include" "-Wno-ignored-optimization-argument" "-Wno-everything"
"-fdeprecated-macro" "-ferror-limit" "19" "-fgnuc-version=4.2.1"
"-fskip-odr-check-in-gmf" "-fcxx-exceptions" "-fexceptions"
"-D__GCC_HAVE_DWARF2_CFI_ASM=1" "-o"
"/tmp/coderabbit-infer/43fbc6e4946f6180/file.o" "-x" "c++"
"indra/newview/lltoolgrab.cpp" "-O0" "-fno-builtin" "-include"
"/opt/infer-linux-x86_64-v1.2.0/lib/infer/infer/bin/../lib/clang_wrappers/global_defines.h"
"-Wno-everything"

  • 1 others

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
indra/newview/lltoolgrab.cpp (1)

584-611: ⚠️ Potential issue | 🔴 Critical | ⚡ Quick win

Missing isLocalOnly() guard leaks ObjectSpinUpdate messages for local previews.

When spinning a local-only object:

  1. startSpin() correctly returns early (line 342-346) without sending ObjectSpinStart
  2. However, mSpinGrabbing is unconditionally set to spin_grabbing at line 546
  3. Subsequent hover events enter this block and send ObjectSpinUpdate to the server

This defeats the purpose of the guards added to startSpin()/stopSpin().

🐛 Proposed fix: add isLocalOnly guard to spin update path
         if (mSpinGrabbing)
         {
             //------------------------------------------------------
             // Handle spinning
             //------------------------------------------------------
+            
+            // Client-only local previews have no sim object to spin.
+            if (objectp->isLocalOnly())
+            {
+                // Still update local rotation for visual feedback
+                LLVector3 up(0.f, 0.f, 1.f);
+                LLQuaternion rotation_around_vertical( dx*RADIANS_PER_PIXEL_X, up );
+                const LLVector3 &agent_left = LLViewerCamera::getInstance()->getLeftAxis();
+                LLQuaternion rotation_around_left( dy*RADIANS_PER_PIXEL_Y, agent_left );
+                mSpinRotation = mSpinRotation * rotation_around_vertical;
+                mSpinRotation = mSpinRotation * rotation_around_left;
+            }
+            else
+            {
                 // x motion maps to rotation around vertical axis
                 LLVector3 up(0.f, 0.f, 1.f);
                 ...
                 msg->sendMessage( objectp->getRegion()->getHost() );
+            }
         }

Alternatively, a simpler fix if local rotation feedback isn't needed:

-        if (mSpinGrabbing)
+        if (mSpinGrabbing && !objectp->isLocalOnly())
         {
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@indra/newview/lltoolgrab.cpp` around lines 584 - 611, The spin-update block
unconditionally sends ObjectSpinUpdate for local-only objects; guard it by
checking the object's isLocalOnly() (or the same local-only condition used in
startSpin()/stopSpin()) before building/sending the message. In practice, modify
the mSpinGrabbing handling path in lltoolgrab.cpp (the block that computes
rotation_around_vertical/left, updates mSpinRotation, and sends
ObjectSpinUpdate) to early-return or skip the message send when
objectp->isLocalOnly() (or equivalent local-only flag) so previews do not emit
network spin updates; keep local mSpinRotation updates only if you still want
local visual feedback. Ensure you reference mSpinGrabbing, mSpinRotation,
startSpin()/stopSpin(), and ObjectSpinUpdate when making the change.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@indra/llprimitive/llmodelloader.cpp`:
- Around line 196-208: The else branch must not call doOnIdleOneTime from the
worker thread; instead ensure LLModelLoader::loadModelCallback is scheduled onto
the main thread via a thread-safe enqueue mechanism. Change the fallback so that
when LL::WorkQueue::getInstance("mainloop") returns null you push the bound
callback (boost::bind(&LLModelLoader::loadModelCallback, this)) into a
thread-safe queue or a new helper (e.g.,
LLModelLoader::enqueueMainThreadCallback or a global thread-safe list) and have
the main loop drain and execute that queue; do not call doOnIdleOneTime from
run() on the worker thread. Ensure the new enqueue API and the main-loop drain
run only on the main thread.

In `@indra/newview/lllocalassetpaths.cpp`:
- Line 115: The final LLFile::rename(tmp_path, path) call in
lllocalassetpaths.cpp is currently ignoring its return value so rename failures
leave stale data and a temp file; modify the code around
LLFile::rename(tmp_path, path) to check its boolean result, and on failure log
an error including tmp_path and path, attempt cleanup of tmp_path (e.g.,
LLFile::remove or equivalent), and if appropriate attempt a safe fallback (such
as copying tmp_path to path and removing tmp_path) to avoid leaving stale state
and orphaned temp files. Ensure the change touches the code that writes out the
new XML and uses the tmp_path/path symbols so failures are handled and reported.

In `@indra/newview/llselectmgr.cpp`:
- Around line 4711-4737: The early-return path inside
selectionAllLocalPreview(mSelectedObjects) creates new_root previews via
LLLocalMeshMgr::duplicatePreview but skips setting up duplicate bookkeeping
(mDuplicated, mDuplicatePos, mDuplicateRot) so repeatDuplicate() won't recognize
them; after successfully creating each new_root in the new_roots loop, populate
mDuplicated[new_root->getID()] = original_root->getID() (or equivalent keying
used elsewhere) and record the corresponding mDuplicatePos and mDuplicateRot
using the original object's position/rotation (same fields updated in the normal
path), then proceed with the select_copy handling
(deselectAll/selectObjectAndFamily) and return—ensure you reference and use the
same identifiers and update logic as used for duplicates in the main
(non-local-preview) path so repeatDuplicate() will treat these previews as
duplicates.

---

Outside diff comments:
In `@indra/newview/lltoolgrab.cpp`:
- Around line 584-611: The spin-update block unconditionally sends
ObjectSpinUpdate for local-only objects; guard it by checking the object's
isLocalOnly() (or the same local-only condition used in startSpin()/stopSpin())
before building/sending the message. In practice, modify the mSpinGrabbing
handling path in lltoolgrab.cpp (the block that computes
rotation_around_vertical/left, updates mSpinRotation, and sends
ObjectSpinUpdate) to early-return or skip the message send when
objectp->isLocalOnly() (or equivalent local-only flag) so previews do not emit
network spin updates; keep local mSpinRotation updates only if you still want
local visual feedback. Ensure you reference mSpinGrabbing, mSpinRotation,
startSpin()/stopSpin(), and ObjectSpinUpdate when making the change.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: cd88c03f-5e12-4324-b161-21b2bbfaae19

📥 Commits

Reviewing files that changed from the base of the PR and between 88b6187 and d292e5e.

📒 Files selected for processing (21)
  • indra/llcharacter/llcharacter.cpp
  • indra/llcharacter/llcharacter.h
  • indra/llcharacter/llmotioncontroller.cpp
  • indra/llcharacter/llmotioncontroller.h
  • indra/llprimitive/llmodelloader.cpp
  • indra/newview/llfloaterlocalassets.cpp
  • indra/newview/lllocalanim.cpp
  • indra/newview/lllocalassetpaths.cpp
  • indra/newview/lllocalbitmaps.cpp
  • indra/newview/lllocalbitmaps.h
  • indra/newview/lllocalgltfmaterials.cpp
  • indra/newview/lllocalgltfmaterials.h
  • indra/newview/lllocalmesh.cpp
  • indra/newview/lllocalmesh.h
  • indra/newview/llmeshrepository.cpp
  • indra/newview/llselectmgr.cpp
  • indra/newview/llspatialpartition.h
  • indra/newview/lltexturectrl.cpp
  • indra/newview/lltoolgrab.cpp
  • indra/newview/llviewerjointattachment.cpp
  • indra/newview/llviewerwindow.cpp

Comment thread indra/llprimitive/llmodelloader.cpp
Comment thread indra/newview/lllocalassetpaths.cpp Outdated
Comment thread indra/newview/llselectmgr.cpp
- gate the ObjectSpinUpdate send on isLocalOnly: handleHoverActive re-sets
  mSpinGrabbing from the mask every hover, so startSpin()'s early-out alone
  did not keep local previews out of the spin-update block
- make repeat-duplicate work for previews: the local selectDuplicate branch
  now mirrors the sim path's mDuplicated/mDuplicatePos/mDuplicateRot
  bookkeeping (when not select_copy), and repeatDuplicate() routes the
  copy-in-place through duplicatePreview for an all-local selection so the
  shared move-by-delta chain behaves like the sim path
- check the final rename in LLLocalAssetPaths::writeToDisk and remove the
  temp file on failure (the old file stays as the persisted state)
- document why the no-mainloop-queue fallback in LLModelLoader keeps using
  the idle list: the viewer always has the queue (gMainloopWork is a
  global), and invoking loadModelCallback() inline would deadlock -- it
  waits for this thread to stop, then deletes the loader

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

♻️ Duplicate comments (1)
indra/newview/llselectmgr.cpp (1)

4729-4749: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Apply duplicate bookkeeping after the select_copy reselection too.

When select_copy is true, this branch reselects the new local copies but never sets mDuplicated / mDuplicatePos / mDuplicateRot on those fresh nodes. repeatDuplicate() then treats them as non-duplicates, deselects them at Line 4800, and no-ops on the next repeat. Move the bookkeeping loop so it runs for whichever roots remain selected after the optional reselection.

Suggested fix
         if (select_copy && !new_roots.empty())
         {
             deselectAll();
             for (LLViewerObject* new_root : new_roots)
             {
                 selectObjectAndFamily(new_root);
             }
         }
-        else if (!select_copy)
+        if (!select_copy || !new_roots.empty())
         {
             // Mirror the sim path's bookkeeping below so repeatDuplicate()
             // recognizes these as duplicated and chains the copy/move sequence.
             for (LLObjectSelection::root_iterator it = getSelection()->root_begin();
                  it != getSelection()->root_end(); ++it)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@indra/newview/llselectmgr.cpp` around lines 4729 - 4749, The bookkeeping that
marks nodes as duplicates (LLSelectNode::mDuplicated, mDuplicatePos,
mDuplicateRot) must run after the optional reselection so it applies to
whichever roots remain selected; move the loop that iterates
getSelection()->root_begin()..root_end() out of the else branch and run it
unconditionally after the select_copy handling (use
node->getObject()->getPositionGlobal() and node->getObject()->getRotation() to
populate mDuplicatePos and mDuplicateRot), so repeatDuplicate() will recognize
the freshly selected copies as duplicated.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Duplicate comments:
In `@indra/newview/llselectmgr.cpp`:
- Around line 4729-4749: The bookkeeping that marks nodes as duplicates
(LLSelectNode::mDuplicated, mDuplicatePos, mDuplicateRot) must run after the
optional reselection so it applies to whichever roots remain selected; move the
loop that iterates getSelection()->root_begin()..root_end() out of the else
branch and run it unconditionally after the select_copy handling (use
node->getObject()->getPositionGlobal() and node->getObject()->getRotation() to
populate mDuplicatePos and mDuplicateRot), so repeatDuplicate() will recognize
the freshly selected copies as duplicated.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: a79cd5f1-56a5-4e8c-b22b-454e062af03c

📥 Commits

Reviewing files that changed from the base of the PR and between d292e5e and 1b2fe34.

📒 Files selected for processing (4)
  • indra/llprimitive/llmodelloader.cpp
  • indra/newview/lllocalassetpaths.cpp
  • indra/newview/llselectmgr.cpp
  • indra/newview/lltoolgrab.cpp
🚧 Files skipped from review as they are similar to previous changes (3)
  • indra/newview/lllocalassetpaths.cpp
  • indra/newview/lltoolgrab.cpp
  • indra/llprimitive/llmodelloader.cpp

@RyeMutt

RyeMutt commented Jun 10, 2026

Copy link
Copy Markdown
Member Author

@coderabbitai Declining the select_copy bookkeeping suggestion on llselectmgr.cpp deliberately: it would diverge from the simulator path. With select_copy, the sim-created copy arrives freshly selected with no duplicate bookkeeping either (the client cannot set it -- the copy arrives asynchronously), so local previews currently behave identically to real objects in that flow. The select_copy=true caller is LLSelectMgr::duplicate() (Ctrl+D), whose repeat gesture is pressing Ctrl+D again -- that re-duplicates the fresh selection and already works for previews. repeatDuplicate() belongs to the shift-drag-copy chain, which uses select_copy=false and gets the bookkeeping.

@coderabbitai

coderabbitai Bot commented Jun 10, 2026

Copy link
Copy Markdown

Tip

For best results, initiate chat on the files or code changes.

@RyeMutt: Understood — thanks for the detailed rationale. The symmetry with the sim path makes the design clearly intentional: since the asynchronous sim copy arrives with no bookkeeping state that the client could set, having local previews behave identically is the right call. I'll keep this in mind for future reviews.

(ᵔᴥᵔ)


✏️ Learnings added
Learnt from: RyeMutt
URL: https://github.com/AlchemyViewer/Alchemy/pull/298

Timestamp: 2026-06-10T05:30:18.055Z
Learning: In `indra/newview/llselectmgr.cpp`, the `selectDuplicate()` path with `select_copy=true` (Ctrl+D / `LLSelectMgr::duplicate()`) intentionally does NOT set duplicate-bookkeeping state for local mesh preview objects. This matches the simulator path behavior, where the sim-created copy arrives asynchronously and the client cannot set bookkeeping state on it. The repeat gesture for Ctrl+D is simply pressing Ctrl+D again on the freshly selected copy, which already works for previews. `repeatDuplicate()` (shift-drag-copy chain) uses `select_copy=false` and is where the bookkeeping belongs. Do not suggest adding bookkeeping to the `select_copy=true` branch for local previews.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.

@RyeMutt RyeMutt merged commit 9f7fe9c into develop Jun 10, 2026
17 checks passed
@RyeMutt RyeMutt deleted the rye/local-assets-review-fixes branch June 10, 2026 07:43
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant