Repository navigation
api.remove() throws on a frozen nested plain-object export and leaves the module partially mounted #485
Description
Summary
api.slothlet.api.remove(moduleID) throws TypeError: Cannot delete property '<key>' of #<Object> when the removed module's export tree contains a plain-object value that slothlet did not Proxy-wrap and that is Object.freezed (or otherwise has non-configurable own properties). Frozen objects are the common idiom for exported constant tables.
The removal aborts partway through. Some leaves stay reachable after remove() rejects, and a wrapped sibling leaf is left invalidated. Re-adding the same moduleID does not repair that leaf.
Root cause
src/lib/handlers/api-manager.mjs, ApiManager.deletePath(), around line 1507:
// Also delete the property from the object/proxy itself
delete current[finalKey];This runs unconditionally after the wrapper-aware block (if (resolveWrapper(current)) { … }). A nested plain object attached to the tree by direct reference is not wrapped, so the wrapper branch is skipped. The native delete then runs on the consumer's own object and throws in strict mode when that object is frozen.
Reproduction
// api/store/errors.mjs
const CODES = Object.freeze({ NOT_FOUND: "not-found", CONFLICT: "conflict" });
export const errors = Object.freeze({ CODES, isStoreError: (err) => !!err });const { default: slothlet } = await import("@cldmv/slothlet");
const api = await slothlet({ dir: "./emptybase" });
await api.slothlet.api.add(["store"], "./api/store", { moduleID: "m" });
await api.slothlet.api.remove("m");
// TypeError: Cannot delete property 'NOT_FOUND' of #<Object>
// at ApiManager.deletePath / removeApiComponent / slothlet_api_removeState after the failed remove:
post-remove api.store -> { errors: { CODES: { CONFLICT: conflict }, isStoreError: null } }
post-remove api.store.errors.CODES.NOT_FOUND -> not-found
re-add same moduleID -> { errors: { CODES: {…}, isStoreError: null } } // function leaf not restored
Observed on 3.20.0 (dist) and confirmed in source on feat/permission-principals.
Impact
remove()rejects, and the module's remaining owned paths are never removed, so stale leaves stay on the live api.- The wrapped function leaf stays invalidated (
impl = null,invalid = true) across a re-add until the instance is recreated. - Hit in practice: rummage's ext-host unmount of
@cldmv/rummage-ext-store(a frozen error-taxonomy export) fails on every test-host teardown and on extension deactivation.
Suggested fix
Never mutate a value the module owns. To detach a node, replace or delete the reference in the parent slothlet controls, not the properties of the consumer's object. At minimum, guard the fallback delete the way the wrapper branch is guarded (check configurability, or catch) so one failed detach cannot abort the rest of the removal. Wrapping nested namespace objects consistently would route every removal through the wrapper-aware path.
Activity
Shinrai commentedon Sep 28, 2026
Landed on
nextin #491 (5b2c1bd5). The wrapper'sdeletePropertytrap now reports a non-deletable key on a frozen/sealed export the way the object itself does, anddeletePath()detaches withReflect.deleteProperty, so removal no longer aborts. Covered in eager and lazy bytests/vitests/suites/api-manager/api-manager-remove-frozen-export.test.vitest.mjs, including re-adding the same moduleID afterwards. Ships with the next release.release: v3.22.0 - type the composed api, self, and slothlet() from e…
cldmv-bot commentedon Oct 3, 2026
Closed in v3.22.0 — resolved by #491.