From 41977eaf1366bd6767edb65737141e26ec40d15c Mon Sep 17 00:00:00 2001 From: Guillaume ARM Date: Mon, 15 Jun 2026 20:06:45 +0200 Subject: [PATCH] fix(boot): clarify lifecycle orchestration --- AGENTS.md | 6 +- README.md | 2 +- docs/adrs/README.md | 1 + docs/adrs/adr-0020-boot-lifecycle.md | 39 +++++++ docs/periphemu.md | 4 +- install-trapos.lua | 8 +- manifest.json | 2 +- packages/index.json | 4 +- packages/trapos-boot/ccpm.json | 6 +- packages/trapos/ccpm.json | 2 +- {startup => programs}/motd.lua | 3 - startup/{servers.lua => boot.lua} | 34 ++++-- tests/boot.lua | 106 +++++++++++++++++- tests/install-trapos.lua | 18 ++- tests/startup-servers.lua | 91 --------------- .../test-integration/lua/full-boot.lua | 2 +- 16 files changed, 199 insertions(+), 129 deletions(-) create mode 100644 docs/adrs/adr-0020-boot-lifecycle.md rename {startup => programs}/motd.lua (90%) rename startup/{servers.lua => boot.lua} (64%) delete mode 100644 tests/startup-servers.lua diff --git a/AGENTS.md b/AGENTS.md index d9241b0..8fa293e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -24,7 +24,7 @@ Use [`docs/README.md`](docs/README.md) as the entrypoint for CC:Tweaked, CraftOS ## Architecture - `apis/eventloop.lua` is the single-threaded event loop around `os.pullEventRaw`; consider using it everywhere async behavior is needed. A handler that returns `api.STOP` auto-unregisters. -- `startup/servers.lua` creates a single boot eventloop at `_G.bootEventLoop` and runs it alongside the shell via `parallel.waitForAny`. Servers register handlers on it and return; programs call services via `os.pullEvent` without touching the loop. See [ADR-0002](docs/adrs/adr-0002-eventloop-and-service-bus.md). +- `startup/boot.lua` creates a single boot eventloop at `_G.bootEventLoop` and runs it alongside the shell via `parallel.waitForAny`. Servers register handlers on it and return; programs call services via `os.pullEvent` without touching the loop. See [ADR-0002](docs/adrs/adr-0002-eventloop-and-service-bus.md). - `apis/libtest.lua` is the lightweight test helper used by scripts under `tests/`; `/programs/runtest.lua` discovers tests, renders suite output, and owns the `__TRAPOS_TEST_OK__` success marker. - `apis/net.lua` is a service-name bus on a single channel (`10`). `net.serve(name, handler)` registers a server handler; `net.call(name, payload, { destId, timeout })` and `net.send(name, payload, { destId })` are the client surface. `require('/apis/net')()` returns a singleton bound to `_G.bootEventLoop` when present, otherwise an ephemeral instance. - A router (`/programs/router.lua`) must be running somewhere on the network; it registers a `modem_message` handler that stamps `routerId`, resolves label-addressed packets via a TTL map populated by `servers/net-registrar.lua`, and rebroadcasts. Without it, packets stay unrouted and consumers ignore them. @@ -33,13 +33,13 @@ Use [`docs/README.md`](docs/README.md) as the entrypoint for CC:Tweaked, CraftOS ## Boot And Install -- `startup/servers.lua` creates the boot eventloop, runs autostart server files (which register handlers and return), then runs the shell and the eventloop in parallel via `parallel.waitForAny`. +- `startup/boot.lua` creates the boot eventloop, runs autostart server files (which register handlers and return), then runs the shell and the eventloop in parallel via `parallel.waitForAny`. - Preserve `periphemu` guards used for CraftOS-PC emulation; see [`docs/craftos_pc_glossary.md`](docs/craftos_pc_glossary.md) for upstream emulator references. - Global CraftOS-PC save data lives in `~/Library/Application Support/CraftOS-PC` on macOS and `~/.local/share/craftos-pc` on Linux. Repo launchers use local state instead: `.craftos/` for TrapOS and `.craftos-vanilla/` for vanilla CraftOS-PC. See [`docs/install-craftos-pc.md`](docs/install-craftos-pc.md). - TrapOS ships as packages, each described by `packages//ccpm.json` (`name`, `version`, `dependencies`, `files`, `autostart`); `packages/index.json` lists them. Source files stay in place — descriptors only reference them. To ship a new file, add it to the right package's `files` (and `autostart` if it is a server). `packages/trapos/ccpm.json` is the full OS meta-package. See [ADR-0010](docs/adrs/adr-0010-ccpm-package-manager.md). - `install-trapos.lua` is the one-time wget bootstrap (public entry point `https://trapos.trapcloud.fr/install`). It installs `trapos-core`/`ccpm`, seeds the default `guillaumearm/cc-libs` registry as a `gitea` registry on `git.trapcloud.fr` tracking `master` (the only channel), then runs `ccpm update` + `ccpm install trapos` and `trapos-postinstall`. Pass `--cpm-only` to stop after the ccpm bootstrap. - `ccpm` (in `trapos-core`) is the package manager: `apis/libccpm.lua` is the testable core (factory with injectable `http`/`stateDir`/`installRoot`), `programs/ccpm.lua` the CLI. State: `/trapos/ccpm.json` (registries), `/trapos/ccpm.lock.json` (installed packages), and `/trapos/ccpm.cache.json` (available packages from `ccpm update`). Never use the word "manifest" in ccpm — it is reserved for the OS manifest. -- Add new servers to `startup/servers.lua` as needed. +- Add new servers to the relevant package `autostart` list as needed. ## Conventions diff --git a/README.md b/README.md index 4f34539..44f3305 100644 --- a/README.md +++ b/README.md @@ -70,7 +70,7 @@ supported. State lives in `/trapos/ccpm.json` (registries), - `/apis/net`: an API to simplify sending and receiving routed messages, based on the `eventloop` library. ## Servers -Servers listed in `manifest.autostart` are launched at boot by `startup/servers.lua`. +Servers listed in `manifest.autostart` are launched at boot by `startup/boot.lua`. - `/servers/ping-server`: allows a machine to respond to a `ping` command. diff --git a/docs/adrs/README.md b/docs/adrs/README.md index 5be50de..8263f8c 100644 --- a/docs/adrs/README.md +++ b/docs/adrs/README.md @@ -18,5 +18,6 @@ Future ADRs can reuse the shape of the existing files when it is useful. - [`adr-0017-mcp-remote-lua-execution.md`](adr-0017-mcp-remote-lua-execution.md) — MCP `exec-lua` remote execution for linked ComputerCraft computers. - [`adr-0018-justfile-organization.md`](adr-0018-justfile-organization.md) — Root Justfile split with imports while preserving flat recipe names. - [`adr-0019-mcp-over-sandbox-gateway.md`](adr-0019-mcp-over-sandbox-gateway.md) — MCP tools migrated onto the trap-sandbox gateway; reactive `libsandbox.serve`; `traposId` addressing. +- [`adr-0020-boot-lifecycle.md`](adr-0020-boot-lifecycle.md) — TrapOS boot orchestrator, session-end policy, installer reboot path, and stale startup-file self-healing. Gaps in numbering (0003, 0004, 0006, 0008, 0009, 0012, 0013, 0014, 0015) are records that were either superseded by later decisions or consolidated into the surviving ADRs above. diff --git a/docs/adrs/adr-0020-boot-lifecycle.md b/docs/adrs/adr-0020-boot-lifecycle.md new file mode 100644 index 0000000..3ca8ccc --- /dev/null +++ b/docs/adrs/adr-0020-boot-lifecycle.md @@ -0,0 +1,39 @@ +# ADR-0020: TrapOS Boot Lifecycle + +## Status + +Accepted. + +## Context + +CraftOS runs `/startup/*` files inside its own shell and expects them to return. TrapOS needs a long-lived init-like process instead: it creates `_G.bootEventLoop`, starts autostart servers, and keeps the user shell and event loop alive together with `parallel.waitForAny`. + +The old `startup/servers.lua` name hid that role, and the installer executed it directly after install. That produced a nested boot path where the installer shell, the CraftOS shell, and the TrapOS shell were all active, and where the orchestrator's shutdown behavior could stop the machine before the installer restored its working directory. + +## Decision + +TrapOS has one startup orchestrator: `startup/boot.lua`. It is treated as the init process for a TrapOS session. + +`programs/motd.lua` owns the banner and is invoked by `startup/boot.lua` before autostart servers run. `/startup/` should contain only the boot orchestrator for the TrapOS boot package. + +Session-end behavior is controlled by `trapos.on_session_end`: + +- `shutdown` is the default and is also used for unset or invalid values. +- `reboot` reboots when the TrapOS shell exits. +- `relaunch` starts a fresh shell when the current one exits, re-reading the setting after each shell session so the policy can be changed before exiting again. + +The previous `trapos.shutdown_on_shell_exit` setting is replaced without a migration shim. It was not documented or persisted as package state. + +The installer no longer executes the boot orchestrator. Full installs run `ccpm update`, `ccpm install trapos`, `trapos-postinstall`, restore the previous shell directory, print a reboot message, and call `os.reboot()`. `--cpm-only` still returns without rebooting. + +`startup/boot.lua` self-heals the known stale startup files left by package upgrades from older versions: `/startup/servers.lua` and `/startup/motd.lua`. This prevents two startup orchestrators from coexisting after `ccpm upgrade` installs the renamed files but does not prune removed package files. + +## Consequences + +Fresh install and normal power-on use the same boot path. + +Exiting the shell never drops to a bare CraftOS prompt with no TrapOS event loop. The default remains shutdown, while reboot and relaunch are explicit local policies. + +Upgraded machines converge on the new `/startup/` layout in one boot. + +General pruning of removed package files during `ccpm install --force` or `ccpm upgrade` remains future work. diff --git a/docs/periphemu.md b/docs/periphemu.md index ab53c46..f53ef11 100644 --- a/docs/periphemu.md +++ b/docs/periphemu.md @@ -6,7 +6,7 @@ Upstream: . See also the [CraftOS-PC g ## Usage in this repo -`startup/servers.lua` attaches a single top modem when running under CraftOS-PC: +`startup/boot.lua` attaches a single top modem when running under CraftOS-PC: ```lua if periphemu then @@ -23,7 +23,7 @@ periphemu create 10 computer # spawn router VM (id 10) periphemu create 1 computer # spawn a client VM ``` -Each call opens a new CraftOS-PC window (GUI mode). Keep this out of `startup/servers.lua` — persistent emulated peers clutter the desktop and leave stale state under `~/Library/Application Support/CraftOS-PC/computer//`. +Each call opens a new CraftOS-PC window (GUI mode). Keep this out of `startup/boot.lua` — persistent emulated peers clutter the desktop and leave stale state under `~/Library/Application Support/CraftOS-PC/computer//`. ## API diff --git a/install-trapos.lua b/install-trapos.lua index c1b11d4..894d645 100644 --- a/install-trapos.lua +++ b/install-trapos.lua @@ -225,9 +225,7 @@ end shell.execute('ccpm', 'update'); shell.execute('ccpm', 'install', 'trapos'); shell.execute('trapos-postinstall'); - -if fs.exists('/startup/servers.lua') then - shell.execute('/startup/servers.lua'); -end - shell.setDir(previousDir); +print(); +print('=> Rebooting...'); +os.reboot(); diff --git a/manifest.json b/manifest.json index a11ba58..fd3e926 100644 --- a/manifest.json +++ b/manifest.json @@ -1,6 +1,6 @@ { "name": "TrapOS", - "version": "0.9.5", + "version": "0.9.6", "branch": "master", "packages": [ "trapos" diff --git a/packages/index.json b/packages/index.json index 843ece9..fba18a6 100644 --- a/packages/index.json +++ b/packages/index.json @@ -2,12 +2,12 @@ "packages": { "trapos-core": "0.6.3", "trapos-test": "0.2.1", - "trapos-boot": "0.3.3", + "trapos-boot": "0.4.0", "trapos-net": "0.3.0", "trapos-ui": "0.2.2", "trapos-ai": "0.7.0", "trapos-sandbox": "0.3.0", "trapos-sandbox-legacy": "0.3.0", - "trapos": "0.9.5" + "trapos": "0.9.6" } } diff --git a/packages/trapos-boot/ccpm.json b/packages/trapos-boot/ccpm.json index 9a0463c..63c4e16 100644 --- a/packages/trapos-boot/ccpm.json +++ b/packages/trapos-boot/ccpm.json @@ -1,11 +1,11 @@ { "name": "trapos-boot", - "version": "0.3.3", + "version": "0.4.0", "description": "TrapOS boot: startup MOTD and autostart server launcher", "dependencies": ["trapos-core"], "files": [ - "startup/motd.lua", - "startup/servers.lua" + "programs/motd.lua", + "startup/boot.lua" ], "autostart": [] } diff --git a/packages/trapos/ccpm.json b/packages/trapos/ccpm.json index eebd10b..e000319 100644 --- a/packages/trapos/ccpm.json +++ b/packages/trapos/ccpm.json @@ -1,6 +1,6 @@ { "name": "trapos", - "version": "0.9.5", + "version": "0.9.6", "description": "TrapOS full install meta-package", "dependencies": [ "trapos-boot", diff --git a/startup/motd.lua b/programs/motd.lua similarity index 90% rename from startup/motd.lua rename to programs/motd.lua index ca26d6b..a34ebb3 100644 --- a/startup/motd.lua +++ b/programs/motd.lua @@ -1,8 +1,5 @@ local LOCAL_MANIFEST_PATH = "/trapos/manifest.json" --- I think this should be moved in `programs` --- then we will run this motd program from startup/boot.lua - local function readLocalManifest() if not fs.exists(LOCAL_MANIFEST_PATH) then return nil diff --git a/startup/servers.lua b/startup/boot.lua similarity index 64% rename from startup/servers.lua rename to startup/boot.lua index 556e802..b303586 100644 --- a/startup/servers.lua +++ b/startup/boot.lua @@ -1,9 +1,8 @@ local createEventLoop = require("/apis/eventloop") local LOCAL_MANIFEST_PATH = "/trapos/manifest.json" -local SHUTDOWN_ON_SHELL_EXIT_SETTING = "trapos.shutdown_on_shell_exit" - --- OK this file should not be named servers.lua we could rename it boot.lua and this could be the only file in this startup directory so the whole startup process will be orchestrated from there. +local SESSION_END_SETTING = "trapos.on_session_end" +local STALE_STARTUP_FILES = { "/startup/servers.lua", "/startup/motd.lua" } local function readLocalManifest() if not fs.exists(LOCAL_MANIFEST_PATH) then @@ -23,14 +22,25 @@ end local function init() shell.setPath(shell.path() .. ":/programs") + for _, path in ipairs(STALE_STARTUP_FILES) do + if fs.exists(path) then + fs.delete(path) + end + end end -local function shouldShutdownOnShellExit() - return settings.get(SHUTDOWN_ON_SHELL_EXIT_SETTING) ~= false +local function sessionEndAction() + local action = settings.get(SESSION_END_SETTING) + if action == "reboot" or action == "relaunch" then + return action + end + return "shutdown" end init() +shell.run("/programs/motd.lua") + if periphemu then periphemu.create("top", "modem") end @@ -38,10 +48,14 @@ end _G.bootEventLoop = createEventLoop() local shellExited = false +local action = "shutdown" local function shellFn() os.sleep(0.1) - shell.run("shell") + repeat + shell.run("shell") + action = sessionEndAction() + until action ~= "relaunch" shellExited = true end @@ -65,6 +79,10 @@ end parallel.waitForAny(shellFn, eventLoopFn) -if shellExited and shouldShutdownOnShellExit() then - os.shutdown() +if shellExited then + if action == "reboot" then + os.reboot() + else + os.shutdown() + end end diff --git a/tests/boot.lua b/tests/boot.lua index fc4c1b4..33021f2 100644 --- a/tests/boot.lua +++ b/tests/boot.lua @@ -1,10 +1,110 @@ --- Basic integration test: prove CraftOS-PC boots and can run a test script. local createLibTest = require('/apis/libtest'); local testlib = createLibTest({ ... }); -testlib.test('CraftOS-PC boots and runs Lua', function() - testlib.assertTrue(true); +local function runStartupWithStubs(settingValues) + settingValues = settingValues or {}; + local settingIndex = 0; + local calls = { + rebooted = false, + shutdown = false, + shellRuns = {}, + prints = {}, + }; + + local env = setmetatable({ + require = require, + fs = { + exists = function(path) + return path ~= '/trapos/manifest.json' and fs.exists(path); + end, + delete = function() end, + }, + os = { + reboot = function() + calls.rebooted = true; + end, + shutdown = function() + calls.shutdown = true; + end, + sleep = function() end, + }, + settings = { + get = function(key) + if key ~= 'trapos.on_session_end' then return nil; end + settingIndex = settingIndex + 1; + if type(settingValues) == 'table' then + return settingValues[settingIndex] or settingValues[#settingValues]; + end + return settingValues; + end, + }, + parallel = { + waitForAny = function(firstFn) + firstFn(); + end, + }, + print = function(message) + calls.prints[#calls.prints + 1] = tostring(message); + end, + shell = { + path = function() + return '/rom/programs'; + end, + run = function(program) + calls.shellRuns[#calls.shellRuns + 1] = program; + return true; + end, + setPath = function() end, + }, + }, { __index = _G }); + env._G = env; + + local chunk, loadErr = loadfile('/startup/boot.lua', 't', env); + if not chunk then + error(loadErr, 0); + end + + local ok, err = pcall(chunk); + if not ok then + error(err, 0); + end + + return calls; +end + +testlib.test('shell exit shuts down by default', function() + local calls = runStartupWithStubs(); + + testlib.assertEquals(calls.shellRuns[1], '/programs/motd.lua'); + testlib.assertEquals(calls.shellRuns[2], 'shell'); + testlib.assertEquals(calls.shutdown, true); + testlib.assertEquals(calls.rebooted, false); + for _, message in ipairs(calls.prints) do + testlib.assertTrue( + not string.find(message, 'Servers stopped', 1, true), + 'startup should not print a forced reboot message' + ); + end +end); + +testlib.test('shell exit reboots when policy is reboot', function() + local calls = runStartupWithStubs('reboot'); + + testlib.assertEquals(calls.shellRuns[1], '/programs/motd.lua'); + testlib.assertEquals(calls.shellRuns[2], 'shell'); + testlib.assertEquals(calls.shutdown, false); + testlib.assertEquals(calls.rebooted, true); +end); + +testlib.test('shell exit relaunches until policy changes', function() + local calls = runStartupWithStubs({ 'relaunch', 'shutdown' }); + + testlib.assertEquals(calls.shellRuns[1], '/programs/motd.lua'); + testlib.assertEquals(calls.shellRuns[2], 'shell'); + testlib.assertEquals(calls.shellRuns[3], 'shell'); + testlib.assertEquals(calls.shutdown, true); + testlib.assertEquals(calls.rebooted, false); end); testlib.run(); diff --git a/tests/install-trapos.lua b/tests/install-trapos.lua index 7834bf4..c83fee5 100644 --- a/tests/install-trapos.lua +++ b/tests/install-trapos.lua @@ -70,11 +70,18 @@ local function runInstall(root, args) executes = {}, path = '/rom/programs', dir = '/', + rebooted = false, }; local fsApi = fakeFs(root); local env = setmetatable({ fs = fsApi, http = fakeHttp(routes), + os = { + reboot = function() + calls.rebooted = true; + calls.executes[#calls.executes + 1] = { program = '__reboot__', args = {} }; + end, + }, print = function() end, read = function() return 'yes'; end, write = function() end, @@ -161,16 +168,16 @@ testlib.test('default install runs update, install trapos and postinstall', func testlib.assertTrue(findExecute(calls, 'trapos-postinstall')); end); -testlib.test('default install runs postinstall before startup servers', function() +testlib.test('default install runs postinstall before reboot', function() local root = '/install-trapos-test/order'; local calls = runInstall(root, {}); local postinstallIndex = findExecuteIndex(calls, 'trapos-postinstall'); - local startupIndex = findExecuteIndex(calls, '/startup/servers.lua'); + local rebootIndex = findExecuteIndex(calls, '__reboot__'); testlib.assertTrue(postinstallIndex ~= nil, 'postinstall was not run'); - if startupIndex ~= nil then - testlib.assertTrue(postinstallIndex < startupIndex, 'postinstall must run before startup servers'); - end + testlib.assertTrue(rebootIndex ~= nil, 'reboot was not run'); + testlib.assertTrue(postinstallIndex < rebootIndex, 'postinstall must run before reboot'); + testlib.assertEquals(calls.rebooted, true); end); testlib.test('--cpm-only stops after the ccpm bootstrap', function() @@ -181,6 +188,7 @@ testlib.test('--cpm-only stops after the ccpm bootstrap', function() testlib.assertTrue(not findExecute(calls, 'ccpm', 'update')); testlib.assertTrue(not findExecute(calls, 'ccpm', 'install')); testlib.assertTrue(not findExecute(calls, 'trapos-postinstall')); + testlib.assertEquals(calls.rebooted, false); end); testlib.run(); diff --git a/tests/startup-servers.lua b/tests/startup-servers.lua deleted file mode 100644 index c972b6f..0000000 --- a/tests/startup-servers.lua +++ /dev/null @@ -1,91 +0,0 @@ -local createLibTest = require('/apis/libtest'); - -local testlib = createLibTest({ ... }); - -local function runStartupWithStubs(settingsValues) - settingsValues = settingsValues or {}; - local calls = { - rebooted = false, - shutdown = false, - shellRuns = {}, - prints = {}, - }; - - local env = setmetatable({ - require = require, - fs = { - exists = function(path) - return path ~= '/trapos/manifest.json' and fs.exists(path); - end, - }, - os = { - reboot = function() - calls.rebooted = true; - end, - shutdown = function() - calls.shutdown = true; - end, - sleep = function() end, - }, - settings = { - get = function(key) - return settingsValues[key]; - end, - }, - parallel = { - waitForAny = function(firstFn) - firstFn(); - end, - }, - print = function(message) - calls.prints[#calls.prints + 1] = tostring(message); - end, - shell = { - path = function() - return '/rom/programs'; - end, - run = function(program) - calls.shellRuns[#calls.shellRuns + 1] = program; - return true; - end, - setPath = function() end, - }, - }, { __index = _G }); - env._G = env; - - local chunk, loadErr = loadfile('/startup/servers.lua', 't', env); - if not chunk then - error(loadErr, 0); - end - - local ok, err = pcall(chunk); - if not ok then - error(err, 0); - end - - return calls; -end - -testlib.test('shell exit shuts down by default', function() - local calls = runStartupWithStubs(); - - testlib.assertEquals(calls.shellRuns[1], 'shell'); - testlib.assertEquals(calls.shutdown, true); - testlib.assertEquals(calls.rebooted, false); - for _, message in ipairs(calls.prints) do - testlib.assertTrue( - not string.find(message, 'Servers stopped', 1, true), - 'startup should not print a forced reboot message' - ); - end -end); - -testlib.test('shell exit does not shut down when setting is disabled', function() - local calls = runStartupWithStubs({ ['trapos.shutdown_on_shell_exit'] = false }); - - testlib.assertEquals(calls.shellRuns[1], 'shell'); - testlib.assertEquals(calls.shutdown, false); - testlib.assertEquals(calls.rebooted, false); -end); - -testlib.run(); diff --git a/tools/trap-sandbox/test-integration/lua/full-boot.lua b/tools/trap-sandbox/test-integration/lua/full-boot.lua index 9e57488..691fa63 100644 --- a/tools/trap-sandbox/test-integration/lua/full-boot.lua +++ b/tools/trap-sandbox/test-integration/lua/full-boot.lua @@ -1,4 +1,4 @@ --- Full-boot e2e: replicate the real boot path (startup/servers.lua) closely enough +-- Full-boot e2e: replicate the real boot path (startup/boot.lua) closely enough -- to prove the whole stack works end to end. Sets the sandbox.* settings, creates -- the global event loop, runs the REAL servers/sandbox.lua daemon, then under -- parallel waits for the connection and runs the REAL `sandbox health` program.