diff --git a/docs/contracts/extension-api.md b/docs/contracts/extension-api.md index e1047dd..6bb4919 100644 --- a/docs/contracts/extension-api.md +++ b/docs/contracts/extension-api.md @@ -29,9 +29,25 @@ return { return a table. Initialization failure rolls back registrations for that extension and does not call its unload hook. Explicit unload invokes `unload` once, then removes the extension's commands, keymap contributions and event -listeners. Runtime shutdown drops registrations without invoking extension -unload hooks; this preserves the stated current contract and avoids running -arbitrary extension code while the runtime is closing. +listeners, styles, themes and syntax contributions. Runtime shutdown drops +registrations without invoking extension unload hooks; this preserves the +stated current contract and avoids running arbitrary extension code while +the runtime is closing. + +### Duplicate, unload and shutdown semantics + +- Loading a name that is already active is idempotent: Runtime returns the + existing active record without resolving the entry again or re-running + `init`. There is no in-place hot reload; explicitly unload the extension + before loading that name again. +- If `unload()` raises, Runtime still removes the extension's owned commands, + keymaps, event listeners, styles, themes and syntax registrations and + removes the active record before returning the structured error. A later + load of the same name starts from a clean lifecycle state. +- Unloading an unknown name returns `false` and performs no work. +- Manager shutdown removes all active owner-scoped contributions and clears + the active set without invoking extension `unload()` hooks. Shutdown is + cleanup, not a sequence of user-visible explicit unload operations. ## Capability facade @@ -70,8 +86,12 @@ For each extension, Backend resolves the entry module name `init` inside that extension's namespace. Nested Lua `require("sub.module")` calls use the same resolver and the same owning extension name. The resolver returns the exact Lua source string and may return a virtual chunk name for diagnostics. It must -return `nil` for a module that does not exist and must not expose absolute host -paths in chunk names or error messages. +return `nil` for a module that does not exist. For access, I/O or limit +failures, it may raise a structured Runtime error in the `cdin.runtime` domain. +Runtime preserves that code when the resolver fails and through entry-module +execution, nested module loading and the extension `init` hook. Ordinary Lua +errors that are not recognized Runtime records remain `BAD_EXTENSION`. +Resolvers must not expose absolute host paths in chunk names or error messages. Trusted embedders may use the following Runtime methods: diff --git a/lua/cdin/runtime/extension/sandbox.lua b/lua/cdin/runtime/extension/sandbox.lua index da04b9a..de02501 100644 --- a/lua/cdin/runtime/extension/sandbox.lua +++ b/lua/cdin/runtime/extension/sandbox.lua @@ -117,6 +117,7 @@ function Sandbox:_environment(plugin_name) local ok, result = pcall(chunk) self.active[module_name] = nil if not ok then + if errors.is(result) then error(result, 0) end errors.raise("BAD_EXTENSION", "extension module failed", { module = module_name, cause = tostring(result), @@ -177,6 +178,7 @@ function Sandbox:load(plugin_name, source, core_facade, config, chunk_name) local ok, module = pcall(chunk) if not ok then + if errors.is(module) then error(module, 0) end errors.raise("BAD_EXTENSION", "extension entry failed", { plugin = plugin_name, cause = tostring(module), @@ -201,6 +203,7 @@ function Sandbox:load(plugin_name, source, core_facade, config, chunk_name) if module.init then local init_ok, init_error = pcall(module.init, core_facade, config) if not init_ok then + if errors.is(init_error) then error(init_error, 0) end errors.raise("BAD_EXTENSION", "extension init failed", { plugin = plugin_name, cause = tostring(init_error), diff --git a/tests/integration/run.lua b/tests/integration/run.lua index 46ce718..0095253 100644 --- a/tests/integration/run.lua +++ b/tests/integration/run.lua @@ -199,7 +199,10 @@ A.falsy(runtime.syntax:get("plugin-sample") ~= nil) local command_takeover = [[return { init = function(core) core.commands:register("document.undo", function() return "hijacked" end, { overwrite = true }) end }]] -A.error_code(function() runtime.plugins:load("takeover", command_takeover, {}) end, "BAD_EXTENSION") +local takeover_error = A.error_code(function() runtime.plugins:load("takeover", command_takeover, {}) end, "PERMISSION_DENIED") +A.equal(takeover_error.domain, "cdin.runtime") +A.equal(takeover_error.details.command, "document.undo") +A.equal(takeover_error.details.owner, "core") A.truthy(runtime.commands:get("document.undo")) A.equal(runtime.commands:get("document.undo").owner, "core") local failed_plugin = [[return { init = function(core) @@ -245,10 +248,17 @@ A.error_code(function() end, "INVALID_ARGUMENT") local resolved = resolver_runtime:load_extension("sample-resolved", { suffix = "!" }) A.equal(resolved.name, "sample-resolved") +-- A duplicate load is idempotent: it returns the active record without +-- resolving modules again or re-running init with new configuration. +local resolver_calls_before_duplicate = #resolver_calls +local duplicate_resolved = resolver_runtime:load_extension("sample-resolved", { suffix = "ignored" }) +A.equal(duplicate_resolved.name, resolved.name) +A.equal(duplicate_resolved.state, "active") +A.equal(#resolver_calls, resolver_calls_before_duplicate) A.equal(resolved.state, "active") A.equal(resolved.source, "resolver") -A.equal(resolver_runtime.commands:execute("resolved.greeting", {}, {}), true) -local _, resolved_value = resolver_runtime.commands:execute("resolved.greeting", {}, {}) +local resolved_ok, resolved_value = resolver_runtime.commands:execute("resolved.greeting", {}, {}) +A.truthy(resolved_ok) A.equal(resolved_value, "hello!") A.equal(resolver_calls[1], "sample-resolved:init") A.equal(resolver_calls[2], "sample-resolved:helpers.greeting") @@ -261,7 +271,7 @@ A.equal(#resolver_runtime:list_extensions(), 0) local module_path_escape = [[return { init = function() require("../outside") end }]] A.error_code(function() resolver_runtime.plugins:load("module-escape", module_path_escape, {}) -end, "BAD_EXTENSION") +end, "PERMISSION_DENIED") A.falsy(resolver_runtime.commands:get("module-escape.command")) -- Dotted module identifiers must accept ordinary segments and reject empty @@ -280,13 +290,160 @@ for index, module_name in ipairs(invalid_module_names) do local resolver_calls_before = #resolver_calls A.error_code(function() resolver_runtime.plugins:load(plugin_name, source, {}) - end, "BAD_EXTENSION") + end, "PERMISSION_DENIED") A.equal(#resolver_calls, resolver_calls_before) A.falsy(resolver_runtime.commands:get(plugin_name .. ".command")) end +-- An unload-hook failure still releases every owner-scoped contribution and +-- removes the name from the active set. The structured failure is returned +-- only after cleanup has completed. +local unload_failure_runtime = Runtime.new({ + host = FakeHost.new(), + module_resolver = function() + return [[return { + init = function(core) + core.commands:register("unload.failure.command", function() return true end) + end, + unload = function() + error({ + domain = "cdin.runtime", + code = "PERMISSION_DENIED", + message = "extension cleanup was denied", + details = { stage = "unload" }, + }) + end, + }]] + end, +}) +unload_failure_runtime:start() +unload_failure_runtime:load_extension("unload-failure", {}) +A.truthy(unload_failure_runtime.commands:get("unload.failure.command")) +local unload_error = A.error_code(function() + unload_failure_runtime:unload_extension("unload-failure") +end, "PERMISSION_DENIED") +A.equal(unload_error.details.stage, "unload") +A.equal(#unload_failure_runtime:list_extensions(), 0) +A.equal(unload_failure_runtime.commands:get("unload.failure.command"), nil) +A.falsy(unload_failure_runtime:unload_extension("unload-failure")) +A.truthy(unload_failure_runtime:shutdown()) + +-- Manager shutdown drops owner registrations without invoking extension +-- unload hooks. A hook that would fail must not turn shutdown into a failure. +local shutdown_extension_runtime = Runtime.new({ + host = FakeHost.new(), + module_resolver = function() + return [[return { + init = function(core) + core.commands:register("shutdown.extension.command", function() return true end) + end, + unload = function() error("shutdown must not invoke extension unload") end, + }]] + end, +}) +shutdown_extension_runtime:start() +shutdown_extension_runtime:load_extension("shutdown-extension", {}) +A.truthy(shutdown_extension_runtime.commands:get("shutdown.extension.command")) +A.truthy(shutdown_extension_runtime:shutdown()) +A.equal(shutdown_extension_runtime.plugins.plugins["shutdown-extension"], nil) +A.equal(shutdown_extension_runtime.commands:get("shutdown.extension.command"), nil) + A.truthy(resolver_runtime:shutdown()) +-- Structured Backend resolver failures must survive nested require and +-- extension initialization instead of being flattened to BAD_EXTENSION. +local structured_error_runtime = Runtime.new({ + host = FakeHost.new(), + module_resolver = function(_, module_name) + if module_name == "init" then + return [[return { init = function() require("helpers.too_large") end }]] + end + error({ + domain = "cdin.runtime", + code = "LIMIT_EXCEEDED", + message = "extension source exceeds configured size", + details = { module = module_name }, + }) + end, +}) +structured_error_runtime:start() +local nested_resolver_error = A.error_code(function() + structured_error_runtime:load_extension("oversized-extension", {}) +end, "LIMIT_EXCEEDED") +A.equal(nested_resolver_error.domain, "cdin.runtime") +A.equal(nested_resolver_error.details.module, "helpers.too_large") +A.equal(#structured_error_runtime:list_extensions(), 0) +A.truthy(structured_error_runtime:shutdown()) + + +-- A structured resolver failure while resolving the entry source must retain +-- its Backend code and details before any extension is registered. +local structured_entry_resolver_runtime = Runtime.new({ + host = FakeHost.new(), + module_resolver = function() + error({ + domain = "cdin.runtime", + code = "IO_ERROR", + message = "extension entry could not be read", + details = { module = "init" }, + }) + end, +}) +structured_entry_resolver_runtime:start() +local entry_resolver_error = A.error_code(function() + structured_entry_resolver_runtime:load_extension("entry-read-failure", {}) +end, "IO_ERROR") +A.equal(entry_resolver_error.domain, "cdin.runtime") +A.equal(entry_resolver_error.details.module, "init") +A.equal(#structured_entry_resolver_runtime:list_extensions(), 0) +A.truthy(structured_entry_resolver_runtime:shutdown()) + +-- Structured errors raised by the entry chunk and init hook must cross the +-- sandbox boundary unchanged; failed init still rolls back owned contributions. +local structured_entry_runtime = Runtime.new({ + host = FakeHost.new(), + module_resolver = function() + return [[error({ + domain = "cdin.runtime", + code = "IO_ERROR", + message = "entry execution failed", + details = { stage = "entry" }, + })]] + end, +}) +structured_entry_runtime:start() +local entry_error = A.error_code(function() + structured_entry_runtime:load_extension("entry-execution-failure", {}) +end, "IO_ERROR") +A.equal(entry_error.details.stage, "entry") +A.equal(#structured_entry_runtime:list_extensions(), 0) +A.truthy(structured_entry_runtime:shutdown()) + +local structured_init_runtime = Runtime.new({ + host = FakeHost.new(), + module_resolver = function() + return [[return { + init = function(core) + core.commands:register("structured.init.partial", function() return true end) + error({ + domain = "cdin.runtime", + code = "PERMISSION_DENIED", + message = "extension initialization was denied", + details = { stage = "init" }, + }) + end, + }]] + end, +}) +structured_init_runtime:start() +local init_error = A.error_code(function() + structured_init_runtime:load_extension("init-permission-failure", {}) +end, "PERMISSION_DENIED") +A.equal(init_error.details.stage, "init") +A.falsy(structured_init_runtime.commands:get("structured.init.partial")) +A.equal(#structured_init_runtime:list_extensions(), 0) +A.truthy(structured_init_runtime:shutdown()) + local missing_resolver_runtime = Runtime.new({ host = FakeHost.new(), module_resolver = function() return nil end, diff --git a/tests/unit/run.lua b/tests/unit/run.lua index 9c84d0b..0a1c086 100644 --- a/tests/unit/run.lua +++ b/tests/unit/run.lua @@ -167,8 +167,10 @@ local module = sandbox:load("safe", "return { init = function(core, config) core commands = { register = function(_, name) A.equal(name, "safe.ok") end }, keymap = {}, events = {}, log = function() end, ui = {}, }, {}) A.equal(type(module.init), "function") -A.error_code(function() sandbox:load("unsafe", "return { init = function() return os.execute('x') end }", {}, {}) end, "BAD_EXTENSION") -A.error_code(function() sandbox:load("global", "leak = true; return {}", {}, {}) end, "BAD_EXTENSION") +-- Accessing a forbidden global is a capability denial, not a generic malformed +-- extension. Preserve the sandbox's stable authorization error code. +A.error_code(function() sandbox:load("unsafe", "return { init = function() return os.execute('x') end }", {}, {}) end, "PERMISSION_DENIED") +A.error_code(function() sandbox:load("global", "leak = true; return {}", {}, {}) end, "PERMISSION_DENIED") require("unit.log_tests")