cc-libs/.plans/cloud-login-interactive-shell-history-fix.md

191 lines
7.4 KiB
Markdown

# Plan: fix interactive `cloud login` retry killing the shell
## Context
Interactive `cloud login` now verifies a candidate password by asking the cloud daemon to reconnect
with that candidate, then waiting for either `trapos_cloud_connected` or
`trapos_cloud_unauthorized`.
Observed bug:
- Run `cloud login` with no password argument.
- Enter a wrong password.
- Instead of printing `invalid password, try again` and prompting again, the command exits back to
the CraftOS shell.
- The shell's in-memory history is gone, so up-arrow no longer recalls the previous `cloud login`
command.
The interactive branch in `packages/trapos-cloud/programs/cloud.lua` already looks logically
correct: on an `unauthorized` verdict it calls `restore()`, prints the retry message, and continues
the loop. Existing unit coverage also models wrong-then-right retry successfully. That points to the
live boot/runtime topology rather than the branch itself.
## Root Cause Hypothesis
The short-lived verifier event loop inside `cloud login` can accidentally stop the boot event loop.
Relevant flow:
- `packages/trapos-cloud/programs/cloud.lua` creates a local event loop inside `verifyPassword()`.
- When the daemon emits `trapos_cloud_unauthorized`, the verifier calls `el.stopLoop()`.
- `packages/trapos-core/apis/eventloop.lua` implements `stopLoop()` by globally queueing a synthetic
event named `@libeventloop/END_OF_LOOP/<id>`.
- Event loop IDs currently come from a module-local counter.
- If `/apis/eventloop.lua` is loaded as independent module instances, both the boot event loop and
the local verifier loop can use ID `1`.
- The verifier's synthetic stop event can therefore match the boot loop's stop event name.
- `packages/trapos-boot/startup/boot.lua` runs the shell and boot event loop with
`parallel.waitForAny(shellFn, eventLoopFn)`.
- If the boot event loop exits, `parallel.waitForAny` returns and the shell coroutine is killed.
- Killing/restarting the shell explains losing shell history.
The stale hello `messageId` issue found during exploration is real hardening work, but it does not
directly explain shell history loss. It can cause wrong verdict attribution, not shell teardown.
## Goals
- Wrong passwords in interactive `cloud login` re-prompt without leaving the command.
- Stopping a short-lived local event loop must not stop `_G.bootEventLoop`.
- Preserve CraftOS shell history after wrong-password retry.
- Keep the fix minimal and local to the event loop semantics where possible.
- Add regression tests that reproduce the event-loop collision class.
## Non-Goals
- Do not replace `cloud login` verification with a program-owned websocket.
- Do not add a standalone Lua test harness.
- Do not redesign `startup/boot.lua` or shell lifecycle unless the event-loop fix proves insufficient.
- Do not add persisted shell history as a workaround for shell teardown.
## Implementation
### 1. Make event loop IDs process-global
File: `packages/trapos-core/apis/eventloop.lua`
Replace the module-local `next_eventloop_id` allocation with a `_G`-backed counter so independently
loaded copies of the module cannot reuse the same synthetic end event name.
Sketch:
```lua
local COUNTER_KEY = '__trapos_next_eventloop_id';
local function nextEventLoopId()
local id = tonumber(_G[COUNTER_KEY]) or 1;
_G[COUNTER_KEY] = id + 1;
return id;
end
```
Then use:
```lua
local eventloop_id = nextEventLoopId();
```
This keeps the existing `@libeventloop/END_OF_LOOP/<id>` design but removes collisions across module
instances.
### 2. Avoid queued stop events when stopping from inside the same loop
File: `packages/trapos-core/apis/eventloop.lua`
Add a local `stopRequested = false` flag.
When `stopLoop()` is called while the loop is actively dispatching its own handler or timeout, set
`stopRequested = true` instead of queueing a global event. After dispatching the current event, break
the loop if `stopRequested` is set.
Keep queueing the synthetic end event when `stopLoop()` is called from another coroutine, because the
loop may be blocked inside `os.pullEventRaw()` and needs to be woken.
This is a second layer of protection: the verifier's `finish()` calls `stopLoop()` from inside its own
event handler, so no global end event should be needed for that path at all.
### 3. Add an event-loop collision regression test
File: `packages/trapos-core/tests/eventloop.lua`
Add a test that simulates independent module loads:
- Load `/apis/eventloop.lua` twice via `loadfile()` or an equivalent isolated environment.
- Create one boot-like loop from the first module and one verifier-like loop from the second module.
- Run both under `parallel`.
- Stop the verifier loop from one of its handlers.
- Assert the boot-like loop still handles a later probe event.
- Stop the boot-like loop explicitly at the end so the test cannot hang.
The regression should fail against the current module-local ID allocation and pass after the fix.
### 4. Add or extend cloud program coverage
File: `packages/trapos-cloud/tests/cloud-program.lua`
Keep the existing wrong-then-right interactive test, because it verifies the program branch behavior.
Add a small test for prompt-time terminate handling if practical:
- Fake `read('*')` raises `Terminated`.
- Assert `cloud login` queues `login-restore` when needed and re-raises `Terminated`.
This is not the primary wrong-password bug, but it closes an adjacent interactive cancellation gap.
### 5. Optional cloud hardening: match hello responses by `messageId`
File: `packages/trapos-cloud/apis/libcloud.lua`
Store the active hello `messageId`, pass `frame.messageId` through `api.onMessage()` to `onHello`,
and ignore hello responses that do not match the currently active hello attempt.
This prevents stale hello responses from earlier reconnects being attributed to the current
`cloud login` token. It should be treated as a separate hardening change if the event-loop fix is
intended to stay small.
## Package Versions
If only `eventloop.lua` changes:
- Bump `trapos-core` in `packages/trapos-core/ccpm.json`.
- Mirror the same version in `packages/index.json`.
If cloud program or cloud daemon behavior also changes:
- Bump `trapos-cloud` in `packages/trapos-cloud/ccpm.json`.
- Mirror the same version in `packages/index.json`.
Only bump the full `trapos` meta-package if the repository's release convention for this change
requires it.
## Verification
Targeted iteration:
```sh
just trapos-exec 'shell.run("/programs/runtest.lua", "--pretty", "/tests/eventloop.lua", "/tests/cloud-program.lua", "/tests/cloud.lua")'
```
Required after Lua/package edits:
```sh
just check
just test --pretty
```
Manual validation on a running TrapOS computer:
- Run `cloud login --force`.
- Enter a known wrong password.
- Confirm it prints `invalid password, try again (ctrl+t to cancel)` and prompts again.
- Press up-arrow after eventually cancelling or completing, and confirm shell history still contains
`cloud login --force`.
## Risks
- Event loop stop semantics are shared by servers and programs, so regression tests should cover both
same-loop stop and cross-coroutine stop.
- If CraftOS-PC loads `require('/apis/eventloop')` as a singleton in more cases than expected, the ID
collision may be intermittent; the `_G` counter still makes the behavior deterministic and safer.
- The optional hello `messageId` hardening will require updating existing cloud tests that currently
send hello responses without message IDs.