WorldBridge hardening
WorldBridge is a control channel into a live region, so it guards itself: every token has a rate limit, every request is audited, the scripts it replaces are cleaned up when nothing else uses them, and a busy region queues work instead of drowning in it. This page lists the settings and what each protects against.
Config keys ([WorldBridge] in OpenSim.ini)
| key | default | meaning |
|---|---|---|
RateLimitPerSec |
50 |
refill rate of each token's bucket, requests per second. 0 or less turns rate limiting off. |
RateLimitBurst |
100 |
bucket size: how many requests a token may make back to back. Minimum 1. |
DeleteSupersededScripts |
true |
set_script deletes the script asset it replaces when that asset is provably unused. false keeps every superseded asset, as before; the unchanged-text reuse still applies. |
The startup log line shows the values in force:
[WorldBridge] initialised (enabled=True, rate limit 50/s burst 100, delete superseded scripts=True, 4 workers + 64 waiting, 2 read workers + 256 waiting, queue wait 20s).
The request queue that followed (MaxConcurrent, QueueLimit, ReadWorkers, ReadQueueLimit, QueueWaitSec;
503 with Retry-After when full) and serving several regions from one listener are in Hub.cs, documented in
handbook/worldbridge.md ("The request queue", "Several regions") and handbook/config.md. The rate limit below is
unchanged by them: it is still checked per token at admission, before the queue; a request the queue refuses gets
its charge back.
1. set_script no longer leaks script assets
Before: every set_script stored a NEW LSLText asset and never removed the one it replaced. On the live store,
16,741 of 19,778 script assets (18.9 MB) were referenced by nothing.
Now:
- Same text again: the asset the prim already runs is reused. No new asset. The script is still stopped and restarted, as before.
- New text: the new asset is stored, the old item is stopped and removed, the new item is added and started.
Then each superseded asset is deleted only if ALL of these hold; otherwise it is kept:
- it is an LSLText asset whose Description is
wb script(minted by set_script, so it never came from anyone's inventory); - the object is not worn (a worn object's copy in the wearer's inventory still names the old asset until it is saved again);
- nothing still names it: no task item on any object in the scene (attachments included); no item in the
inventory (trash included) of anyone who could hold a copy, meaning the estate owner, the estate managers,
everyone present, and everyone who has ever arrived (the favor ledger records every non-NPC arrival, and a
take needs you present); and no Object or Notecard among all of those whose serialisation embeds it,
following objects nested inside objects (a box of bundles).
Before the delete, the prim's new contents are written to the region store (
StorePrimInventory), so a crash cannot leave OpenSim.db naming a deleted asset. After it, the Flotsam copy is expired, because the region asset connector deletes from the store only.
- it is an LSLText asset whose Description is
- The reply gains
asset,assetReused,supersededDeletedandsupersededKept. The Info log line gainsreused=... superseded deleted=N kept=M. Every reuse, keep (with what still holds the asset) and delete is logged at Debug.
Limits, stated plainly:
- A copy delivered by script to someone who has never visited the region (so is not in the favor ledger) is not seen. If that copy embeds a superseded script, the copy's script would be missing when it is rezzed.
- A narrow race: if the region's own backup thread copied the object just before the swap and writes that copy
just after
StorePrimInventory, the database briefly names the old asset again. The next backup pass corrects it (the object is marked changed). Harm needs a crash inside that window, and the effect is one missing script thatset_scriptrestores. - In this data, 20 set_script assets were named only inside objects in a visitor's inventory (taken copies). The inventory scan above exists because of them.
Verify live
Do this on a scratch box, not a builder's object.
python tools/wb.py spawn x=128 y=128 z=30 name=gc-testgives a uuidU.python tools/wb.py set_script uuid=U name=t script="default{state_entry(){llSay(0,\"one\");}}"ExpectassetReused: false. Note theasset, call itA1.- Repeat step 2 with the same text. Expect
assetReused: trueandassetequal toA1. - Change the text (
"two"). Expect a newassetA2,supersededDeleted: 1,supersededKept: 0. - Prove
A1is gone, including from the cache:python tools/wb.py put_inventory uuid=U asset=A1answers 404no such asset. - The keep path: duplicate the box in the viewer (shift-drag), then set new text on the original. Expect
supersededKept: 1. With the WorldBridge logger at DEBUG, the log names the duplicate as what still holds it. - Despawn the box when done.
2. prune tool for the existing leak: tools/prune_script_assets.py
This tool is offline only. It refuses any database under opensim-src/bin, in a directory holding
OpenSim.dll, under PARACOSM_BIN, or with a -journal/-wal file beside it.
A script asset counts as referenced when any of these holds:
- a prim item names it (OpenSim.db
primitems); - a user item names it (inventory.db
inventoryitems); - its uuid is written inside any Object or Notecard asset;
- it is a Library asset (the OpenSim Library is served from
bin/inventoryXML, not a database).
--apply deletes exactly the unreferenced rows from the --asset-db file, in one transaction, and touches no
other file. It refuses if any Object or Notecard asset is not readable text. --vacuum shrinks the file
afterwards.
Numbers on copies of the live files (2026-09-27):
| assets | bytes | |
|---|---|---|
| script assets | 19,778 | 26,896,165 |
| referenced | 3,037 | 7,954,511 |
| of which: by prim items / only inside object or notecard assets / Library | 2,495 / 525 / 17 | |
| unreferenced | 16,741 (16,698 tagged wb script) |
18,941,654 |
--apply --vacuum on the copy deleted exactly those 16,741 rows and nothing else. This was checked against an
independent computation: every referenced script was still present, no other asset type was touched, and
integrity_check returned ok. The file went from 1,064,165,376 to 1,036,439,552 bytes (script text is small next
to the textures and meshes). A second run reported 0 unreferenced.
Procedure (the operator runs it; the server must be STOPPED)
# 1. stop OpenSim; wait until it has exited (OpenSim.db is fully persisted at shutdown)
# 2. copy the three files out of bin (plain copies are fine once the server is stopped)
mkdir C:/prune && cp bin/Asset.db bin/OpenSim.db bin/inventory.db C:/prune/
# 3. report
python tools/prune_script_assets.py --asset-db C:/prune/Asset.db --opensim-db C:/prune/OpenSim.db --inventory-db C:/prune/inventory.db
# 4. apply to the COPY
python tools/prune_script_assets.py --asset-db C:/prune/Asset.db --opensim-db C:/prune/OpenSim.db --inventory-db C:/prune/inventory.db --apply --vacuum
# 5. keep the original, put the pruned copy in place, start OpenSim
mv bin/Asset.db bin/Asset.db.pre-prune && cp C:/prune/Asset.db bin/Asset.db
The Flotsam cache (assetcache/) still holds copies of the pruned assets until they expire. They are harmless
because nothing names them. Clear the cache while the server is stopped if the disk space matters.
3. Rate limit, per token
Each token has its own token bucket: RateLimitBurst requests back to back, refilled at RateLimitPerSec. It is
checked right after authentication, before role checks and before anything runs. state and list cost 0.1 of a
request, so polling is cheap but still bounded. A request over the limit gets:
HTTP 429 Retry-After: 1
{"error":"rate limited","command":"chat","retryAfterSec":0.12,"limitPerSec":50.0,"burst":100.0}
It is thread-safe: one lock and O(1) work. tools/wb.py now waits retryAfterSec and resends on a 429, up to 10
times. That is safe because a 429 means nothing ran, and it means a builder's burst slows down instead of dying
mid-build. mcp/worldbridge_mcp.py already passes HTTP errors back to the caller, so the assistant sees the 429
body.
Verify live
wb.py retries, so use raw HTTP to see the 429:
python - <<'EOF'
import json, urllib.request, urllib.error
import sys; sys.path.insert(0, "tools"); from paths import BIN
tok = open(BIN + "/WorldBridge.token").read().strip()
codes = []
for i in range(150):
rq = urllib.request.Request("http://127.0.0.1:9002/chat", data=b"{}", method="POST", headers={"Authorization": "Bearer " + tok})
try: codes.append(urllib.request.urlopen(rq).status)
except urllib.error.HTTPError as e: codes.append(e.code); last = (e.headers.get("Retry-After"), e.read().decode())
print(codes.count(200), "ok,", codes.count(429), "limited; last 429:", last)
EOF
Expect about 100 ok (the burst, plus whatever refilled during the loop), then 429s with Retry-After: 1 and
the JSON above. A second token's calls are unaffected meanwhile.
4. No guessed avatar
Before, an op with no avatar acted on whichever non-NPC the presence list returned first. Now:
- One visitor present: that visitor, as before.
- Several visitors and no
avatar: HTTP 400{"error":"several visitors present: name the avatar","visitors":[...]}. - A name fragment matching several: 400
several visitors match: name the avatar exactly. - A uuid or an exact full name always resolves. A unique fragment still works.
This covers teleport, sit, stand, wear, unwear, worn, give_wearable, give_object,
give_inventory, give_money (which had its own copy of the resolver, now removed), grant_estate_manager, and
local chat_send. Local chat is heard by distance, so it is refused when the target is ambiguous; region:true
chat needs no target and is unaffected, and so is wb.region_say. tools/gaze.py and tools/tour.py passed the
avatar as uuid=, which teleport ignored; they now pass avatar=.
Verify live
With two people logged in (NPCs do not count), python tools/wb.py worn gives 400 with both names.
python tools/wb.py worn avatar="Bob Resident" gives 200. With only one person in, worn gives 200 as before.
5. Which token acted, in the audit log
Each audit line has a new column after the role: tok: plus the first 6 hex of SHA-256(token). It never contains
the token itself. Columns are now: time, remote, role, token id, command, region, body.
2026-09-27T17:04:14.6986650Z [::1]:52205 observer tok:f55ba3 chat ? {}
Verify live
Make any call, then read the last audit line and compare the id with:
python -c "import sys,hashlib;sys.path.insert(0,'tools');from paths import BIN;print(hashlib.sha256(open(BIN+'/WorldBridge.token').read().strip().encode()).hexdigest()[:6])" (from the repository root).
How it was tested (offline; nothing touched the running server)
- Build:
dotnet build worldbridge/WorldBridge.csproj -c Release -p:OpenSimBin=<live bin>/gives 0 warnings and 0 errors. - Request pipeline: the built DLL was loaded in a scratch process with its own listener on a scratch port.
17 checks passed:
- 401 with no token or an unknown token;
- a burst of 10 at 5/s, then 429 with
Retry-After: 1and the JSON body; - the buckets are independent per token, refill works, and RBAC 403 still applies behind the limiter;
- 60
statecalls on a burst-10 bucket are never limited; - audit lines have 7 columns with the right
tok:ids and never the token; - 8 threads hammering one bucket for 1.5 s were granted 17 of the 17.5 allowed;
RateLimitPerSec=0disables the limit.
- Scene behaviour: the built DLL was attached to a real OpenSim
TestScene(upstreamTests/Common, compiled in a scratch project with shims for its 0.9.3.0 bitrot). The mock asset store's delete stub was given a real delete, as SQLite'sDeleteAssethas. 38 checks passed:- reuse on unchanged text;
- delete on new text, with the asset gone from the store;
- kept, each for the right logged reason: a copy in another prim, a bundle Object in the scene, an Object two levels deep, a taken copy in an away visitor's inventory (favor ledger), the owner's own inventory item, and a non-set_script asset;
- a control delete still happens with all of those present;
- all 11 targeted ops plus local
chat_sendanswer 400 with two visitors and none named; - region
chat_sendpasses; - fragment, full-name, uuid and no-match resolution.
- Prune tool: run on SQLite backup-API copies of the live files, with an independent before/after check (the numbers above).
Not verified
- Nothing was run against the live region. The DLL is not deployed, and the live checks above are for after the deploy.
- The store and cache calls were never run live. The SQLite
DeleteAssetpath and the FlotsamExpirecall are known from source only: the test scene uses a mock store and a test cache. - The prim-inventory write before a delete never reached SQLite.
StorePrimInventoryran only against the test scene's null data service. - Behaviour when a script engine is present was never exercised. The test scene has none, so the YEngine restart of a reused asset is unchecked.
- Nested-object embedding was tested synthetically only. The live data has no objects nested inside objects, so this path ran only on test assets.