Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 35 additions & 3 deletions src/lib/handlers/api-manager.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -1049,11 +1049,34 @@ export class ApiManager extends ComponentBase {
}
}

/**
* Whether a wrapper is the live node at its own apiPath in the api tree (#555).
* @param {?object} raw - A raw wrapper, or null for a non-wrapper value.
* @returns {boolean} True when walking the tree from the root along the wrapper's apiPath reaches it.
* @private
*
* @description
* The walk reads raw wrappers' own properties (children are own data properties), so no proxy trap
* runs and nothing materializes.
*/
#isLiveWrapper(raw) {
if (!raw) return false;
let node = this.slothlet.api;
for (const segment of String(raw.____slothletInternal.apiPath).split(".")) {
node = (resolveWrapper(node) ?? node)?.[segment];
}
return resolveWrapper(node) === raw;
}

/**
* Invalidate every UnifiedWrapper found in a rejected candidate's subtree, so a still-in-flight
* `backgroundMaterialize: true` materialization cannot re-apply the rejected content later.
* @param {unknown} api - Candidate subtree (or leaf) to walk.
* @param {WeakSet} [visited] - Cycle guard for the recursive walk.
* @param {?Array<object>} [liveKept=null] - When given, a wrapper that is still the live node at its own
* apiPath is left alone (with everything beneath it) and pushed here instead. A removed module's
* captured wrapper can be the very namespace other modules merged their children into, so a removal
* invalidates only what its restore/delete pass actually detached (#555).
* @returns {void}
* @private
*
Expand All @@ -1072,10 +1095,14 @@ export class ApiManager extends ComponentBase {
* @example
* this.invalidateSpeculativeWrappers(rootSource[key]);
*/
invalidateSpeculativeWrappers(api, visited = new WeakSet()) {
invalidateSpeculativeWrappers(api, visited = new WeakSet(), liveKept = null) {
if (!api || (typeof api !== "object" && typeof api !== "function")) return;
if (visited.has(api)) return;
visited.add(api);
if (liveKept && this.#isLiveWrapper(resolveWrapper(api))) {
liveKept.push(api);
return;
}

// Children FIRST, parent LAST: ___invalidate() deletes every one of its own child
// properties as part of invalidating a wrapper. Invalidating the parent before walking
Expand All @@ -1086,7 +1113,7 @@ export class ApiManager extends ComponentBase {
const skipProps = ["__metadata", "__type", "_materialize", "_impl", "____slothletInternal"];
if (skipProps.includes(key)) continue;
if (typeof value === "function" || (value && typeof value === "object")) {
this.invalidateSpeculativeWrappers(value, visited);
this.invalidateSpeculativeWrappers(value, visited, liveKept);
}
}

Expand Down Expand Up @@ -3368,7 +3395,9 @@ export class ApiManager extends ComponentBase {
// A merge-loser's raw routine contribution is never pruned by impl:removed (it was never
// the live property at its path), so it must be pruned explicitly here alongside the
// ownership removal that just discarded every path this module owned (#372).
this.slothlet.handlers.routineManager?.pruneModule?.(moduleIDKey);
// Captured wrappers still live in the tree are left to the restore/delete passes below and
// invalidated after them, once it is known which of them those passes detached (#555).
const capturedLive = this.slothlet.handlers.routineManager.pruneModule(moduleIDKey);
// Principals (#459) the module registered go with it β€” a resolver never outlives its code.
this.slothlet.handlers.permissionManager?.onModuleRemoved?.(moduleIDKey);
// So do its event listeners (#497), for the same reason.
Expand Down Expand Up @@ -3563,6 +3592,9 @@ export class ApiManager extends ComponentBase {
}

for (const [apiPath, live] of rolledBackNodes) this.#restoreNamespaceFunction(live, apiPath);
// Now invalidate the removed module's captured wrappers that the passes above took off the tree; any
// still live (a namespace it created that other modules' children keep standing) stays usable (#555).
for (const wrapper of capturedLive) this.invalidateSpeculativeWrappers(wrapper, undefined, []);

this.state.addHistory = this.state.addHistory.filter((entry) => String(entry.moduleID) !== moduleIDKey);

Expand Down
12 changes: 9 additions & 3 deletions src/lib/handlers/routine-manager.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -675,7 +675,8 @@ export class RoutineManager extends ComponentBase {
* Prune every raw-captured contribution belonging to a module, regardless of whether it was
* ever the live property at its own path.
* @param {string} moduleID - Module identifier being fully removed.
* @returns {void}
* @returns {Array<object>} The captured wrappers still live in the api tree, left un-invalidated for the
* caller to invalidate once its removal has detached them (#555).
* @public
*
* @description
Expand All @@ -697,18 +698,23 @@ export class RoutineManager extends ComponentBase {
pruneModule(moduleID) {
this.raw = this.raw.filter((e) => e.moduleID !== moduleID);
const moduleWrappers = this.rawWrappers.get(moduleID);
if (!moduleWrappers) return;
if (!moduleWrappers) return [];
// A merge-loser's wrapper is never the live api-tree property at its path β€” ownership's
// own unregister()/removePath() has nothing to invalidate it via. Without this, a
// still-in-flight backgroundMaterialize: true materialization on this detached wrapper (or
// one of its own children) can complete after this prune and re-fire impl:changed,
// re-capturing the just-removed module into `raw` (#372/#373 review, suppressed finding).
// Recursive + children-before-parent ordering via ApiManager's own helper, since this
// wrapper can itself carry adopted child wrappers.
// A captured wrapper that is still the live node at its path is left alone and returned: it can be
// a namespace this module created and other modules merged their children into, so the caller's
// removal decides β€” after its restore/delete pass β€” whether it actually leaves the tree (#555).
const liveKept = [];
for (const wrapper of moduleWrappers.values()) {
this.slothlet.handlers.apiManager?.invalidateSpeculativeWrappers(wrapper);
this.slothlet.handlers.apiManager?.invalidateSpeculativeWrappers(wrapper, undefined, liveKept);
}
this.rawWrappers.delete(moduleID);
return liveKept;
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -314,10 +314,6 @@ for (const { config, name } of getMatrixConfigs()) {
api = null;
});

// Children are checked after a removal only when the removed module did not create the namespace:
// removing the creating module also invalidates the children other modules merged into it, which is
// a separate, pre-existing removal issue independent of the namespace's function.

/**
* Add each module at `plugins` in order.
* @param {string[]} order - moduleIDs, in add order.
Expand Down Expand Up @@ -351,7 +347,7 @@ for (const { config, name } of getMatrixConfigs()) {
// Removing the module whose function lost keeps the winner's.
await api.slothlet.api.remove(loser);
expect(await api.plugins()).toBe(MAIN[winner]);
if (loser !== order[0]) await expectLeaves(api.plugins, OTHER_LEAVES);
await expectLeaves(api.plugins, OTHER_LEAVES);
}
);

Expand All @@ -368,7 +364,7 @@ for (const { config, name } of getMatrixConfigs()) {

expect(typeof api.plugins).toBe("function");
expect(await api.plugins()).toBe(MAIN[remaining]);
if (winner !== order[0]) await expectLeaves(api.plugins, OTHER_LEAVES);
await expectLeaves(api.plugins, OTHER_LEAVES);
}
);

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,227 @@
/**
* @Project: @cldmv/slothlet
* @Filename: /tests/vitests/suites/addapi/addapi-remove-namespace-creator.test.vitest.mjs
* @Date: 2026-10-02 00:00:00 -07:00 (1790924400)
* @Author: Shinrai <CLDMV>
* @Email: <Shinrai@users.noreply.github.com>
* -----
* @Last modified by: Shinrai <CLDMV> (Shinrai@users.noreply.github.com)
* @Last modified time: 2026-10-02 00:00:00 -07:00 (1790924400)
* -----
* @Copyright: Copyright (c) 2013-2026 Catalyzed Motivation Inc. All rights reserved.
*/

/**
* @fileoverview Removing the module that created a shared namespace keeps the children other modules
* merged into it (#555).
*
* @description
* Two modules are added at `plugins`: the first creates the namespace, the second merges its children
* in (shared subfolders `tools` / `tools.net` and a leaf both export, `shared`, included). Removing the
* first module must leave every leaf of the second reachable and callable β€” with the same references β€”
* drop the first module's own leaves, hand a leaf both export to the survivor, and leave the namespace's
* keys as the survivor alone lays them out. Covered for a plain namespace creator and for one whose default export makes
* the namespace callable, in both add orders, under the `merge` and `merge-replace` collision modes.
*
* Runs across the full matrix (eager/lazy Γ— async/live Γ— hooks on/off).
*
* @module tests/vitests/suites/addapi/addapi-remove-namespace-creator
*/

process.env.SLOTHLET_INTERNAL_TEST_MODE = "true";

import { describe, it, expect, afterEach, vi } from "vitest";
import path from "node:path";
import slothlet from "@cldmv/slothlet";
import { resolveWrapper } from "#handlers/unified-wrapper";
import { getMatrixConfigs, TEST_DIRS } from "../../setup/vitest-helper.mjs";

// Instance creation plus two adds and a remove can exceed the default hook/test timeout on a loaded machine.
vi.setConfig({ hookTimeout: 60000, testTimeout: 60000 });

const API_TESTS_ROOT = path.dirname(TEST_DIRS.API_TEST);

/** Each module: its folder, the leaves it contributes (dotted path β†’ return value), and its function. */
const MODULES = {
other: {
dir: path.join(API_TESTS_ROOT, "api_test_reload_comount_other"),
leaves: {
gamma: "other:gamma",
delta: "other:delta",
shared: "other:shared",
"gear.cog": "other:cog",
"gear.spring": "other:spring",
"tools.wrench": "other:wrench",
"tools.net.dns": "other:dns",
"tools.net.ntp": "other:ntp",
"tools.kit.tape": "other:tape",
"tools.kit.box.nail": "other:nail"
},
main: null
},
ext: {
dir: path.join(API_TESTS_ROOT, "api_test_reload_comount_ext"),
leaves: {
alpha: "ext:alpha",
beta: "ext:beta",
shared: "ext:shared",
"tools.hammer": "ext:hammer",
"tools.saw": "ext:saw",
"tools.net.http": "ext:http",
"tools.net.smtp": "ext:smtp"
},
main: null
},
callable: {
dir: path.join(API_TESTS_ROOT, "api_test_reload_comount_callable"),
leaves: { extra: "callable:extra" },
main: "callable:main"
}
};

/**
* Resolve a dotted path below a node.
* @param {*} node - Starting node.
* @param {string} dotted - Dotted path relative to `node`.
* @returns {*} The value at that path, or undefined.
*/
function at(node, dotted) {
let cur = node;
for (const part of dotted.split(".")) {
if (cur === undefined || cur === null) return undefined;
cur = cur[part];
}
return cur;
}

/**
* Run a call that may throw synchronously or reject, as a promise either way.
* @param {Function} fn - The call.
* @returns {Promise<unknown>} Its settled result.
*/
function settle(fn) {
return Promise.resolve().then(fn);
}

for (const { config, name } of getMatrixConfigs()) {
for (const collisionApi of ["merge", "merge-replace"]) {
describe(`Removing the module that created a namespace (#555) > ${collisionApi} > ${name}`, () => {
let api;

afterEach(async () => {
if (api?.shutdown) await api.shutdown();
api = null;
});

/**
* Create an instance with this suite's collision mode and every mutation enabled.
* @returns {Promise<object>} Slothlet API proxy.
*/
function createInstance() {
return slothlet({
...config,
base: TEST_DIRS.API_TEST,
silent: true,
api: {
...config.api,
collision: { ...(config.api?.collision ?? {}), api: collisionApi },
mutations: { add: true, remove: true, reload: true }
}
});
}

it.each([
["a plain namespace", ["ext", "other"]],
["a plain namespace", ["other", "ext"]],
["a callable namespace", ["callable", "other"]],
["a plain namespace under a callable merge", ["other", "callable"]]
])("%s created by the first of %j", async (_label, [creator, survivor]) => {
// The key order the survivor's namespace has on its own.
const alone = await createInstance();
await alone.slothlet.api.add("plugins", MODULES[survivor].dir, { moduleID: survivor });
const survivorOrder = Object.keys(alone.plugins);
await alone.shutdown();

api = await createInstance();
await api.slothlet.api.add("plugins", MODULES[creator].dir, { moduleID: creator });
await api.slothlet.api.add("plugins", MODULES[survivor].dir, { moduleID: survivor });

// Call every surviving leaf once (materializing it under lazy mode), then hold its reference.
const survivorLeaves = MODULES[survivor].leaves;
for (const dotted of Object.keys(survivorLeaves)) await at(api.plugins, dotted)();
const refs = Object.fromEntries(Object.keys(survivorLeaves).map((dotted) => [dotted, at(api.plugins, dotted)]));

await api.slothlet.api.remove(creator);

// Every surviving leaf is reachable, callable, and returns the survivor's value. A leaf the
// survivor shared with the removed module (`shared`) now resolves to the survivor's.
for (const [dotted, expected] of Object.entries(survivorLeaves)) {
const live = at(api.plugins, dotted);
expect(typeof live, dotted).toBe("function");
expect(await live(), `${dotted}()`).toBe(expected);
if (!(dotted in MODULES[creator].leaves)) {
// Same reference as before the removal, and a held reference still works.
expect(live, `${dotted} reference`).toBe(refs[dotted]);
expect(await refs[dotted](), `held ${dotted}()`).toBe(expected);
}
}

// The removed module's own leaves are gone.
for (const dotted of Object.keys(MODULES[creator].leaves)) {
if (dotted in survivorLeaves) continue;
expect(at(api.plugins, dotted), dotted).toBeUndefined();
}

// The namespace now reads as the survivor's alone: its keys, in its order.
expect(Object.keys(api.plugins)).toEqual(survivorOrder);

// The namespace's function follows the modules that remain (#533).
if (MODULES[survivor].main) {
expect(await api.plugins()).toBe(MODULES[survivor].main);
} else if (MODULES[creator].main) {
expect(typeof api.plugins).toBe("function");
await expect(settle(() => api.plugins())).rejects.toThrow(/not a function/);
} else {
expect(typeof api.plugins).toBe("object");
}
});
});
}
}

describe("invalidateSpeculativeWrappers with a live-kept list (#555)", () => {
let api;

afterEach(async () => {
if (api?.shutdown) await api.shutdown();
api = null;
});

it("invalidates detached wrappers and plain values' wrappers, but keeps and reports live ones", async () => {
api = await slothlet({
...getMatrixConfigs()[0].config,
base: TEST_DIRS.API_TEST,
silent: true,
api: { mutations: { add: true, remove: true } }
});
await api.slothlet.api.add("plugins", MODULES.other.dir, { moduleID: "other" });
const slothletInstance = resolveWrapper(api.plugins).slothlet;
const UnifiedWrapper = resolveWrapper(api.plugins).constructor;
const gamma = api.plugins.gamma;
// A wrapper that was never placed in the tree, at a path nothing occupies.
const detached = new UnifiedWrapper(slothletInstance, { mode: "eager", apiPath: "nowhere.thing", initialImpl: () => "detached" });
const detachedProxy = detached.createProxy();
expect(await detachedProxy()).toBe("detached");

const kept = [];
slothletInstance.handlers.apiManager.invalidateSpeculativeWrappers(
{ plain: { live: gamma, detached: detachedProxy, value: 1 } },
undefined,
kept
);

expect(kept).toEqual([gamma]);
expect(await api.plugins.gamma()).toBe("other:gamma");
expect(() => detachedProxy()).toThrow(/invalidated/);
});
});
4 changes: 4 additions & 0 deletions types/src/lib/handlers/api-manager.d.mts
Original file line number Diff line number Diff line change
Expand Up @@ -225,6 +225,10 @@ export class ApiManager extends ComponentBase {
* `backgroundMaterialize: true` materialization cannot re-apply the rejected content later.
* @param {unknown} api - Candidate subtree (or leaf) to walk.
* @param {WeakSet} [visited] - Cycle guard for the recursive walk.
* @param {?Array<object>} [liveKept=null] - When given, a wrapper that is still the live node at its own
* apiPath is left alone (with everything beneath it) and pushed here instead. A removed module's
* captured wrapper can be the very namespace other modules merged their children into, so a removal
* invalidates only what its restore/delete pass actually detached (#555).
* @returns {void}
* @private
*
Expand Down
Loading
Loading