Skip to content

api.remove() throws on a frozen nested plain-object export and leaves the module partially mounted #485

Closed

Description

@Shinrai

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_remove

State 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

  1. added
    type: bugSomething is broken or not behaving as expected
    priority: highNeeds attention soon — not blocking, but don't let it sit
    status: not startedNot implemented yet — no code exists for this
    on Sep 28, 2026
  2. added
    semver: patchThis release contains only backwards-compatible bug fixes
    area: coreTouches core library / runtime source code
    status: in progressPartially implemented — actively being built
    and removed
    status: not startedNot implemented yet — no code exists for this
    on Sep 28, 2026
  3. added theissue type on Sep 28, 2026
  4. added
    status: implementedBuilt and deployed, but not yet fully tested/verified
    and removed
    status: in progressPartially implemented — actively being built
    on Sep 28, 2026
  5. Shinrai commented on Sep 28, 2026

    @Shinrai
    ContributorAuthor

    Landed on next in #491 (5b2c1bd5). The wrapper's deleteProperty trap now reports a non-deletable key on a frozen/sealed export the way the object itself does, and deletePath() detaches with Reflect.deleteProperty, so removal no longer aborts. Covered in eager and lazy by tests/vitests/suites/api-manager/api-manager-remove-frozen-export.test.vitest.mjs, including re-adding the same moduleID afterwards. Ships with the next release.

  6. cldmv-bot commented on Oct 3, 2026

    @cldmv-bot
    Contributor

    Closed in v3.22.0 — resolved by #491.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    area: coreTouches core library / runtime source codepriority: highNeeds attention soon — not blocking, but don't let it sitsemver: patchThis release contains only backwards-compatible bug fixesstatus: implementedBuilt and deployed, but not yet fully tested/verifiedtype: bugSomething is broken or not behaving as expected

    Type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions