refactor: rename accountId in networkId + cleanup
This commit is contained in:
parent
4f2c64ef1d
commit
9bc1ce711d
@ -18,7 +18,7 @@ Use [`docs/README.md`](docs/README.md) as the entrypoint for CC:Tweaked, CraftOS
|
|||||||
- Git hooks own commit/push verification: pre-commit runs `just check test`, and pre-push runs `just ci`. When explicitly asked to commit and/or push, do not run `just test` manually first; rely on the hooks. See [ADR-0011](docs/adrs/adr-0011-repo-conventions.md).
|
- Git hooks own commit/push verification: pre-commit runs `just check test`, and pre-push runs `just ci`. When explicitly asked to commit and/or push, do not run `just test` manually first; rely on the hooks. See [ADR-0011](docs/adrs/adr-0011-repo-conventions.md).
|
||||||
- After editing Lua or Markdown, run `just check` and fix all `luacheck` warnings and `lychee` broken-link reports (markdown link validation is offline-only via `just lint-markdown`).
|
- After editing Lua or Markdown, run `just check` and fix all `luacheck` warnings and `lychee` broken-link reports (markdown link validation is offline-only via `just lint-markdown`).
|
||||||
- Use 2-space indent, semicolons, and `local function`.
|
- Use 2-space indent, semicolons, and `local function`.
|
||||||
- `require` paths are absolute ComputerCraft paths, for example `require('/apis/net')()`.
|
- `require` paths are absolute ComputerCraft paths, for example `require('/apis/eventloop')()`.
|
||||||
- Most API modules return factories; call the required module once before use.
|
- Most API modules return factories; call the required module once before use.
|
||||||
|
|
||||||
## Running CraftOS-PC
|
## Running CraftOS-PC
|
||||||
@ -40,10 +40,7 @@ Always go through the `just` recipes — they set repo-local state and the macOS
|
|||||||
- `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.
|
- `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/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).
|
- `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/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.
|
|
||||||
- `servers/` register handlers on the boot eventloop and return; `programs/` are clients that exit.
|
- `servers/` register handlers on the boot eventloop and return; `programs/` are clients that exit.
|
||||||
- Single well-known channel: `10` (the bus). Service multiplexing happens inside the packet body.
|
|
||||||
|
|
||||||
## Boot And Install
|
## Boot And Install
|
||||||
|
|
||||||
|
|||||||
@ -18,7 +18,6 @@ cherry-pick packages yourself:
|
|||||||
```
|
```
|
||||||
wget run https://os.trapcloud.fr/install --cpm-only
|
wget run https://os.trapcloud.fr/install --cpm-only
|
||||||
ccpm update
|
ccpm update
|
||||||
ccpm install trapos-net
|
|
||||||
ccpm install trapos-ui
|
ccpm install trapos-ui
|
||||||
```
|
```
|
||||||
|
|
||||||
@ -31,10 +30,9 @@ TrapOS is split into packages, each described by a `packages/<name>/ccpm.json`:
|
|||||||
- `trapos-core`: the package manager (`ccpm`), event loop, `trapos-upgrade`, `trapos-postinstall`, `events`.
|
- `trapos-core`: the package manager (`ccpm`), event loop, `trapos-upgrade`, `trapos-postinstall`, `events`.
|
||||||
- `trapos-test`: the test framework (`libtest`) and suite runner (`runtest`).
|
- `trapos-test`: the test framework (`libtest`) and suite runner (`runtest`).
|
||||||
- `trapos-boot`: the startup MOTD and autostart server launcher.
|
- `trapos-boot`: the startup MOTD and autostart server launcher.
|
||||||
- `trapos-net`: routed modem networking (`net`, `router`, `ping`, `ping-server`).
|
|
||||||
- `trapos-ui`: the terminal UI toolkit (`libtui`, `tuidemo`).
|
- `trapos-ui`: the terminal UI toolkit (`libtui`, `tuidemo`).
|
||||||
- `trapos-ai`: the AI client for `opencode serve`.
|
- `trapos-ai`: the AI client for `opencode serve`.
|
||||||
- `trapos`: full TrapOS meta-package (`trapos-boot`, `trapos-net`, `trapos-ui`, `trapos-test`, `trapos-ai`, `trapos-cloud`).
|
- `trapos`: full TrapOS meta-package (`trapos-boot`, `trapos-ui`, `trapos-test`, `trapos-ai`, `trapos-cloud`).
|
||||||
|
|
||||||
The `trapos` meta-package is the user-facing full install. Package descriptors list
|
The `trapos` meta-package is the user-facing full install. Package descriptors list
|
||||||
files and autostart servers; installed state is tracked under `/trapos`.
|
files and autostart servers; installed state is tracked under `/trapos`.
|
||||||
@ -67,16 +65,11 @@ supported. State lives in `/trapos/ccpm.json` (registries),
|
|||||||
|
|
||||||
## APIs
|
## APIs
|
||||||
- `/apis/eventloop`: a simple event loop API.
|
- `/apis/eventloop`: a simple event loop API.
|
||||||
- `/apis/net`: an API to simplify sending and receiving routed messages, based on the `eventloop` library.
|
|
||||||
|
|
||||||
## Servers
|
## Servers
|
||||||
Servers listed in `manifest.autostart` are launched at boot by `startup/boot.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.
|
|
||||||
|
|
||||||
## Programs
|
## Programs
|
||||||
- `router`: routes messages. You need to set up a router to use all `apis/net`-based programs and libraries.
|
|
||||||
- `ping`: pings machines using `apis/net`.
|
|
||||||
- `events`: emits and logs computer events.
|
- `events`: emits and logs computer events.
|
||||||
- `trapos-upgrade`: runs `ccpm update` then `ccpm upgrade`; pass `--reboot`/`-r` to reboot after upgrading.
|
- `trapos-upgrade`: runs `ccpm update` then `ccpm upgrade`; pass `--reboot`/`-r` to reboot after upgrading.
|
||||||
- `trapos-postinstall`: prints the post-install welcome and next steps (run automatically by the installer).
|
- `trapos-postinstall`: prints the post-install welcome and next steps (run automatically by the installer).
|
||||||
|
|||||||
@ -13,7 +13,7 @@ Accepted
|
|||||||
ComputerCraft is event-driven. Direct `os.pullEvent` loops are easy to write but hard to compose when multiple things need to happen at the same time. Without a single substrate the repo accumulated several distinct problems:
|
ComputerCraft is event-driven. Direct `os.pullEvent` loops are easy to write but hard to compose when multiple things need to happen at the same time. Without a single substrate the repo accumulated several distinct problems:
|
||||||
|
|
||||||
- Each long-lived process owned a private event loop, including the router (`programs/router.lua` was a hand-rolled `while true / os.pullEvent`). With N autostart servers, `parallel.waitForAll` ran N coroutines each pumping an independent `os.pullEventRaw`. Events were broadcast to every coroutine but only one would have a relevant handler — wasteful and conceptually awkward.
|
- Each long-lived process owned a private event loop, including the router (`programs/router.lua` was a hand-rolled `while true / os.pullEvent`). With N autostart servers, `parallel.waitForAll` ran N coroutines each pumping an independent `os.pullEventRaw`. Events were broadcast to every coroutine but only one would have a relevant handler — wasteful and conceptually awkward.
|
||||||
- `_G.isRouterEnabled` mutated send behavior across the codebase. [`apis/net.lua`](../../packages/trapos-net/apis/net.lua) `sendRaw` switched its transmit path based on a global flag set by the router program, so the same function call meant different things depending on which machine ran it.
|
- `_G.isRouterEnabled` mutated send behavior across the codebase. `apis/net.lua` `sendRaw` switched its transmit path based on a global flag set by the router program, so the same function call meant different things depending on which machine ran it.
|
||||||
- Channel numbers leaked into every client. `servers/ping-server.lua` and `programs/ping.lua` both duplicated a `PING_CHANNEL` constant; there was no service registry. Adding a new service meant picking a free integer and replicating it on both ends.
|
- Channel numbers leaked into every client. `servers/ping-server.lua` and `programs/ping.lua` both duplicated a `PING_CHANNEL` constant; there was no service registry. Adding a new service meant picking a free integer and replicating it on both ends.
|
||||||
- Label collision was a silent footgun. Two machines sharing the same label both accepted and rebroadcast packets addressed to that label, producing duplicate responses and duplicate retransmits.
|
- Label collision was a silent footgun. Two machines sharing the same label both accepted and rebroadcast packets addressed to that label, producing duplicate responses and duplicate retransmits.
|
||||||
- `os.sleep` looked innocent but broke the substrate. Its CC:Tweaked implementation yields via `os.pullEvent("timer")`. While the sleep is in flight, the enclosing eventloop's `os.pullEventRaw` is paused; non-`timer` events are silently discarded; even `eventloop.setTimeout` callbacks scheduled before the sleep cannot fire until it returns. This bit `apis/libai.lua` `pollMessage`, which used a sleep-based throttle and froze the whole loop the moment a caller invoked it from inside a handler.
|
- `os.sleep` looked innocent but broke the substrate. Its CC:Tweaked implementation yields via `os.pullEvent("timer")`. While the sleep is in flight, the enclosing eventloop's `os.pullEventRaw` is paused; non-`timer` events are silently discarded; even `eventloop.setTimeout` callbacks scheduled before the sleep cannot fire until it returns. This bit `apis/libai.lua` `pollMessage`, which used a sleep-based throttle and froze the whole loop the moment a caller invoked it from inside a handler.
|
||||||
@ -27,7 +27,7 @@ Net's blast radius at the time of the bus rewrite was small (only ping consumed
|
|||||||
New async code uses [`apis/eventloop.lua`](../../packages/trapos-core/apis/eventloop.lua). Event handlers, timers, server listeners, and UI behavior compose through the eventloop instead of each feature owning its own blocking loop.
|
New async code uses [`apis/eventloop.lua`](../../packages/trapos-core/apis/eventloop.lua). Event handlers, timers, server listeners, and UI behavior compose through the eventloop instead of each feature owning its own blocking loop.
|
||||||
|
|
||||||
- Prefer `eventloop.register`, `setTimeout`, `onStart`, `onStop`, and `startLoop` for async behavior.
|
- Prefer `eventloop.register`, `setTimeout`, `onStart`, `onStop`, and `startLoop` for async behavior.
|
||||||
- APIs that listen for events accept an existing event loop as a constructor argument, the way [`apis/net.lua`](../../packages/trapos-net/apis/net.lua) does. Do not create a private loop inside a module.
|
- APIs that listen for events accept an existing event loop as a constructor argument, the way `apis/net.lua` does. Do not create a private loop inside a module.
|
||||||
- Direct `os.pullEvent` loops should be rare and justified (CLI programs waiting for a single reply are the main exception).
|
- Direct `os.pullEvent` loops should be rare and justified (CLI programs waiting for a single reply are the main exception).
|
||||||
- A handler that returns `api.STOP` auto-unregisters.
|
- A handler that returns `api.STOP` auto-unregisters.
|
||||||
|
|
||||||
@ -35,7 +35,7 @@ New async code uses [`apis/eventloop.lua`](../../packages/trapos-core/apis/event
|
|||||||
|
|
||||||
`startup/servers.lua` creates a single `createEventLoop()` instance, stores it at `_G.bootEventLoop`, runs autostart server files (which register handlers and return without blocking), then runs `parallel.waitForAny(shellFn, eventLoopFn)`. The shell and the eventloop are the only two coroutines.
|
`startup/servers.lua` creates a single `createEventLoop()` instance, stores it at `_G.bootEventLoop`, runs autostart server files (which register handlers and return without blocking), then runs `parallel.waitForAny(shellFn, eventLoopFn)`. The shell and the eventloop are the only two coroutines.
|
||||||
|
|
||||||
[`apis/net.lua`](../../packages/trapos-net/apis/net.lua) exposes a service-name bus on a single channel:
|
`apis/net.lua` exposes a service-name bus on a single channel:
|
||||||
|
|
||||||
- `net.serve(name, handler)` — register a server handler (server-side).
|
- `net.serve(name, handler)` — register a server handler (server-side).
|
||||||
- `net.call(name, payload, opts)` — request/response with timeout (client-side).
|
- `net.call(name, payload, opts)` — request/response with timeout (client-side).
|
||||||
@ -44,11 +44,11 @@ New async code uses [`apis/eventloop.lua`](../../packages/trapos-core/apis/event
|
|||||||
|
|
||||||
All traffic flows on channel `10` and is demultiplexed inside the packet body via a `service` field. Channel numbers stop being a public concept. `require('/apis/net')()` returns a singleton bound to `_G.bootEventLoop` when present, otherwise an ephemeral instance. CLI programs stay standalone: `net.call` internally uses `os.pullEvent` with a timer, so programs do not need the boot eventloop to receive a response.
|
All traffic flows on channel `10` and is demultiplexed inside the packet body via a `service` field. Channel numbers stop being a public concept. `require('/apis/net')()` returns a singleton bound to `_G.bootEventLoop` when present, otherwise an ephemeral instance. CLI programs stay standalone: `net.call` internally uses `os.pullEvent` with a timer, so programs do not need the boot eventloop to receive a response.
|
||||||
|
|
||||||
[`programs/router.lua`](../../packages/trapos-net/programs/router.lua) registers handlers on the same boot eventloop everything else uses. It owns a TTL-based label map extracted into [`apis/librouter.lua`](../../packages/trapos-net/apis/librouter.lua) for testability. Machines with a label autostart [`servers/net-registrar.lua`](../../packages/trapos-net/servers/net-registrar.lua), which periodically broadcasts `(id, label)` so the router can resolve label-addressed packets. Duplicate label registrations are rejected with a printed warning. `_G.isRouterEnabled` is gone; the router service flips a local flag via `net.setRouter(true)` instead.
|
`programs/router.lua` registers handlers on the same boot eventloop everything else uses. It owns a TTL-based label map extracted into `apis/librouter.lua` for testability. Machines with a label autostart `servers/net-registrar.lua`, which periodically broadcasts `(id, label)` so the router can resolve label-addressed packets. Duplicate label registrations are rejected with a printed warning. `_G.isRouterEnabled` is gone; the router service flips a local flag via `net.setRouter(true)` instead.
|
||||||
|
|
||||||
### 3. `os.sleep` discipline
|
### 3. `os.sleep` discipline
|
||||||
|
|
||||||
In library, server, and program code that may run inside an eventloop (directly or transitively), use `eventloop.setTimeout` for any waiting, throttling, polling, or retry-with-delay. Libraries that need to temporize must take an eventloop factory through their constructor rather than baking a hardcoded sleep call. [`apis/net.lua`](../../packages/trapos-net/apis/net.lua) `sendRequest` is the canonical private-eventloop pattern: create a private eventloop, schedule the wait through `setTimeout`, then `runLoop` until the work resolves — synchronous from the caller's perspective, but the dispatcher stays alive internally so handlers can compose around it via `parallel.waitForAll`.
|
In library, server, and program code that may run inside an eventloop (directly or transitively), use `eventloop.setTimeout` for any waiting, throttling, polling, or retry-with-delay. Libraries that need to temporize must take an eventloop factory through their constructor rather than baking a hardcoded sleep call. `apis/net.lua` `sendRequest` is the canonical private-eventloop pattern: create a private eventloop, schedule the wait through `setTimeout`, then `runLoop` until the work resolves — synchronous from the caller's perspective, but the dispatcher stays alive internally so handlers can compose around it via `parallel.waitForAll`.
|
||||||
|
|
||||||
`os.sleep` remains acceptable only in narrow cases:
|
`os.sleep` remains acceptable only in narrow cases:
|
||||||
|
|
||||||
@ -64,8 +64,8 @@ New code must not expose a `sleep` injection point on its constructor. If a wait
|
|||||||
- The router program returns immediately instead of blocking the shell. Users type `router` once on the chosen machine and continue using the shell.
|
- The router program returns immediately instead of blocking the shell. Users type `router` once on the chosen machine and continue using the shell.
|
||||||
- Label collisions are detected and rejected at registration time, with a clear warning, instead of causing silent duplicate delivery.
|
- Label collisions are detected and rejected at registration time, with a clear warning, instead of causing silent duplicate delivery.
|
||||||
- A router must still be running somewhere on the network for cross-machine label-addressed packets; without one, non-router senders produce packets with `routerId = nil` and consumers drop them on receive.
|
- A router must still be running somewhere on the network for cross-machine label-addressed packets; without one, non-router senders produce packets with `routerId = nil` and consumers drop them on receive.
|
||||||
- Programs that need to wait for events still work by direct `os.pullEvent`, but if a program registers a long-lived handler on `_G.bootEventLoop` and exits, the handler keeps firing with a stale closure. Programs should prefer `call`/`send` over `serve`/`listen`. This is documented in [`apis/net.lua`](../../packages/trapos-net/apis/net.lua) but not enforced.
|
- Programs that need to wait for events still work by direct `os.pullEvent`, but if a program registers a long-lived handler on `_G.bootEventLoop` and exits, the handler keeps firing with a stale closure. Programs should prefer `call`/`send` over `serve`/`listen`. This is documented in `apis/net.lua` but not enforced.
|
||||||
- Tests for the router state machine live in [`tests/router.lua`](../../packages/trapos-net/tests/router.lua) and exercise [`apis/librouter.lua`](../../packages/trapos-net/apis/librouter.lua) with an injected clock. Tests for the net packet shape and dispatch live in [`tests/net.lua`](../../packages/trapos-net/tests/net.lua) with a fake modem.
|
- Tests for the router state machine live in `tests/router.lua` and exercise `apis/librouter.lua` with an injected clock. Tests for the net packet shape and dispatch live in `tests/net.lua` with a fake modem.
|
||||||
- Slightly more ceremony in "synchronous-looking" library functions that wait: a private eventloop plus a small `attempt`/`finish` pair. The benefit is clean composition with any caller's eventloop.
|
- Slightly more ceremony in "synchronous-looking" library functions that wait: a private eventloop plus a small `attempt`/`finish` pair. The benefit is clean composition with any caller's eventloop.
|
||||||
- Test fakes shift from a `sleep` stub to a synchronous eventloop double. Ergonomics are comparable; the eventloop fake additionally lets tests observe `pending` and `stopped` state, catching leaks the sleep stub would have missed.
|
- Test fakes shift from a `sleep` stub to a synchronous eventloop double. Ergonomics are comparable; the eventloop fake additionally lets tests observe `pending` and `stopped` state, catching leaks the sleep stub would have missed.
|
||||||
- Existing call sites are migrated opportunistically when they cause observable bugs. The first `os.sleep` migration is `apis/libai.lua`.
|
- Existing call sites are migrated opportunistically when they cause observable bugs. The first `os.sleep` migration is `apis/libai.lua`.
|
||||||
|
|||||||
@ -28,7 +28,7 @@ Move the MCP features onto the cloud gateway and retire the dedicated MCP transp
|
|||||||
|
|
||||||
- **Host (`tools/trapos-server`):** a second Fastify listener (`src/modules/mcp`) bound to
|
- **Host (`tools/trapos-server`):** a second Fastify listener (`src/modules/mcp`) bound to
|
||||||
`127.0.0.1` (default `MCP_PORT=4445`) speaks the same custom JSON-RPC 2.0 as the old
|
`127.0.0.1` (default `MCP_PORT=4445`) speaks the same custom JSON-RPC 2.0 as the old
|
||||||
bridge. Each tool call is a `GatewayRegistry.request(accountId, traposId, type, payload)`
|
bridge. Each tool call is a `GatewayRegistry.request(networkId, traposId, type, payload)`
|
||||||
reusing the existing gateway socket. Computers are addressed by **`traposId`**
|
reusing the existing gateway socket. Computers are addressed by **`traposId`**
|
||||||
(`"<id>:<label>"`, the registry's key) rather than a numeric `computerId`;
|
(`"<id>:<label>"`, the registry's key) rather than a numeric `computerId`;
|
||||||
`probe-computers` lists the connected traposIds.
|
`probe-computers` lists the connected traposIds.
|
||||||
|
|||||||
@ -65,5 +65,5 @@ Call `periphemu.names()` from a CraftOS-PC shell to confirm what the current bui
|
|||||||
|
|
||||||
- **CC:T has no `periphemu`.** Always guard with `if periphemu then`. [AGENTS.md](./../AGENTS.md) and [ADR-0005](adrs/adr-0005-craftos-pc-harness-and-probes.md) treat that guard as mandatory.
|
- **CC:T has no `periphemu`.** Always guard with `if periphemu then`. [AGENTS.md](./../AGENTS.md) and [ADR-0005](adrs/adr-0005-craftos-pc-harness-and-probes.md) treat that guard as mandatory.
|
||||||
- **Speaker `playAudio` differs from CC:T** unless [standards mode](https://www.craftos-pc.cc/docs/standards) is on — CraftOS-PC queues audio without ever returning `false`.
|
- **Speaker `playAudio` differs from CC:T** unless [standards mode](https://www.craftos-pc.cc/docs/standards) is on — CraftOS-PC queues audio without ever returning `false`.
|
||||||
- **Modem network id** segregates emulated networks. Two modems with different ids will not see each other; useful for testing the [router](../packages/trapos-net/programs/router.lua) against a sealed network.
|
- **Modem network id** segregates emulated networks. Two modems with different ids will not see each other; useful for testing modem-based code against a sealed network.
|
||||||
- **Persisted per-computer state** lives at `~/Library/Application Support/CraftOS-PC/computer/<id>/` and `config/<id>.json` (labels stored base64-encoded). Spawning a new id leaves that state behind across sessions.
|
- **Persisted per-computer state** lives at `~/Library/Application Support/CraftOS-PC/computer/<id>/` and `config/<id>.json` (labels stored base64-encoded). Spawning a new id leaves that state behind across sessions.
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "TrapOS",
|
"name": "TrapOS",
|
||||||
"version": "0.12.4",
|
"version": "0.12.5",
|
||||||
"branch": "master",
|
"branch": "master",
|
||||||
"packages": [
|
"packages": [
|
||||||
"trapos"
|
"trapos"
|
||||||
|
|||||||
@ -3,11 +3,10 @@
|
|||||||
"trapos-core": "0.9.4",
|
"trapos-core": "0.9.4",
|
||||||
"trapos-test": "0.2.3",
|
"trapos-test": "0.2.3",
|
||||||
"trapos-boot": "0.4.3",
|
"trapos-boot": "0.4.3",
|
||||||
"trapos-net": "0.3.2",
|
|
||||||
"trapos-ui": "0.2.4",
|
"trapos-ui": "0.2.4",
|
||||||
"trapos-ai": "0.8.0",
|
"trapos-ai": "0.8.0",
|
||||||
"trapos-cloud": "0.4.4",
|
"trapos-cloud": "0.4.4",
|
||||||
"trapos-sandbox-legacy": "0.3.2",
|
"trapos-sandbox-legacy": "0.3.2",
|
||||||
"trapos": "0.12.4"
|
"trapos": "0.12.5"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -131,13 +131,13 @@ end);
|
|||||||
testlib.test('onMessage drives hello via onHello', function()
|
testlib.test('onMessage drives hello via onHello', function()
|
||||||
local cloud = createCloud();
|
local cloud = createCloud();
|
||||||
local calls = {};
|
local calls = {};
|
||||||
cloud.onMessage(encode({ event = 'server_response', type = 'hello', ok = true, payload = { accountId = 'local' } }), {
|
cloud.onMessage(encode({ event = 'server_response', type = 'hello', ok = true, payload = { networkId = 'global' } }), {
|
||||||
onHello = function(ok, payload) calls[#calls + 1] = { ok = ok, payload = payload }; end,
|
onHello = function(ok, payload) calls[#calls + 1] = { ok = ok, payload = payload }; end,
|
||||||
});
|
});
|
||||||
|
|
||||||
testlib.assertEquals(#calls, 1);
|
testlib.assertEquals(#calls, 1);
|
||||||
testlib.assertEquals(calls[1].ok, true);
|
testlib.assertEquals(calls[1].ok, true);
|
||||||
testlib.assertEquals(calls[1].payload.accountId, 'local');
|
testlib.assertEquals(calls[1].payload.networkId, 'global');
|
||||||
end);
|
end);
|
||||||
|
|
||||||
testlib.test('onMessage queues a reply for a non-hello server_response', function()
|
testlib.test('onMessage queues a reply for a non-hello server_response', function()
|
||||||
|
|||||||
@ -52,8 +52,8 @@ testlib.test("registryBaseUrl resolves a gitea branch", function()
|
|||||||
"https://git.trapcloud.fr/guillaumearm/cc-libs/raw/branch/next/"
|
"https://git.trapcloud.fr/guillaumearm/cc-libs/raw/branch/next/"
|
||||||
)
|
)
|
||||||
testlib.assertEquals(
|
testlib.assertEquals(
|
||||||
ccpm.descriptorUrl({ name = "guillaumearm/cc-libs", type = "gitea", branch = "next" }, "trapos-net"),
|
ccpm.descriptorUrl({ name = "guillaumearm/cc-libs", type = "gitea", branch = "next" }, "trapos-ui"),
|
||||||
"https://git.trapcloud.fr/guillaumearm/cc-libs/raw/branch/next/packages/trapos-net/ccpm.json"
|
"https://git.trapcloud.fr/guillaumearm/cc-libs/raw/branch/next/packages/trapos-ui/ccpm.json"
|
||||||
)
|
)
|
||||||
end)
|
end)
|
||||||
|
|
||||||
@ -64,8 +64,8 @@ testlib.test("registry urls resolve an http base", function()
|
|||||||
"http://example.com/repo/"
|
"http://example.com/repo/"
|
||||||
)
|
)
|
||||||
testlib.assertEquals(
|
testlib.assertEquals(
|
||||||
ccpm.descriptorUrl({ name = "http://example.com/repo/", type = "http" }, "trapos-net"),
|
ccpm.descriptorUrl({ name = "http://example.com/repo/", type = "http" }, "trapos-ui"),
|
||||||
"http://example.com/repo/packages/trapos-net/ccpm.json"
|
"http://example.com/repo/packages/trapos-ui/ccpm.json"
|
||||||
)
|
)
|
||||||
end)
|
end)
|
||||||
|
|
||||||
@ -148,34 +148,34 @@ testlib.test("install downloads files and records the lock", function()
|
|||||||
dependencies = {},
|
dependencies = {},
|
||||||
files = { "apis/eventloop.lua" },
|
files = { "apis/eventloop.lua" },
|
||||||
}),
|
}),
|
||||||
[base .. "packages/trapos-net/ccpm.json"] = textutils.serializeJSON({
|
[base .. "packages/trapos-ui/ccpm.json"] = textutils.serializeJSON({
|
||||||
name = "trapos-net",
|
name = "trapos-ui",
|
||||||
version = "1",
|
version = "1",
|
||||||
dependencies = { "trapos-core" },
|
dependencies = { "trapos-core" },
|
||||||
files = { "apis/net.lua" },
|
files = { "apis/libtui.lua" },
|
||||||
}),
|
}),
|
||||||
[base .. "packages/trapos-core/apis/eventloop.lua"] = "eventloop-body",
|
[base .. "packages/trapos-core/apis/eventloop.lua"] = "eventloop-body",
|
||||||
[base .. "packages/trapos-net/apis/net.lua"] = "net-body",
|
[base .. "packages/trapos-ui/apis/libtui.lua"] = "ui-body",
|
||||||
}
|
}
|
||||||
local sd, root = freshDirs()
|
local sd, root = freshDirs()
|
||||||
local ccpm = createCcpm({ stateDir = sd, installRoot = root, http = fakeHttp(routes) })
|
local ccpm = createCcpm({ stateDir = sd, installRoot = root, http = fakeHttp(routes) })
|
||||||
ccpm.writeConfig({ registries = { { name = "me/repo", type = "gitea", branch = "master" } } })
|
ccpm.writeConfig({ registries = { { name = "me/repo", type = "gitea", branch = "master" } } })
|
||||||
|
|
||||||
local ok = ccpm.install("trapos-net", {})
|
local ok = ccpm.install("trapos-ui", {})
|
||||||
testlib.assertTrue(ok)
|
testlib.assertTrue(ok)
|
||||||
testlib.assertTrue(fs.exists(root .. "/apis/net.lua"))
|
testlib.assertTrue(fs.exists(root .. "/apis/libtui.lua"))
|
||||||
testlib.assertTrue(fs.exists(root .. "/apis/eventloop.lua"))
|
testlib.assertTrue(fs.exists(root .. "/apis/eventloop.lua"))
|
||||||
|
|
||||||
local f = fs.open(root .. "/apis/net.lua", "r")
|
local f = fs.open(root .. "/apis/libtui.lua", "r")
|
||||||
local body = f.readAll()
|
local body = f.readAll()
|
||||||
f.close()
|
f.close()
|
||||||
testlib.assertEquals(body, "net-body")
|
testlib.assertEquals(body, "ui-body")
|
||||||
|
|
||||||
local lock = ccpm.readLock()
|
local lock = ccpm.readLock()
|
||||||
testlib.assertTrue(lock.packages["trapos-net"])
|
testlib.assertTrue(lock.packages["trapos-ui"])
|
||||||
testlib.assertTrue(lock.packages["trapos-core"])
|
testlib.assertTrue(lock.packages["trapos-core"])
|
||||||
testlib.assertEquals(lock.packages["trapos-net"].registry, "me/repo")
|
testlib.assertEquals(lock.packages["trapos-ui"].registry, "me/repo")
|
||||||
testlib.assertEquals(lock.packages["trapos-net"].explicit, true)
|
testlib.assertEquals(lock.packages["trapos-ui"].explicit, true)
|
||||||
testlib.assertEquals(lock.packages["trapos-core"].explicit, false)
|
testlib.assertEquals(lock.packages["trapos-core"].explicit, false)
|
||||||
end)
|
end)
|
||||||
|
|
||||||
@ -188,31 +188,31 @@ testlib.test("install downloads files from a gitea registry", function()
|
|||||||
dependencies = {},
|
dependencies = {},
|
||||||
files = { "apis/eventloop.lua" },
|
files = { "apis/eventloop.lua" },
|
||||||
}),
|
}),
|
||||||
[base .. "packages/trapos-net/ccpm.json"] = textutils.serializeJSON({
|
[base .. "packages/trapos-ui/ccpm.json"] = textutils.serializeJSON({
|
||||||
name = "trapos-net",
|
name = "trapos-ui",
|
||||||
version = "1",
|
version = "1",
|
||||||
dependencies = { "trapos-core" },
|
dependencies = { "trapos-core" },
|
||||||
files = { "apis/net.lua" },
|
files = { "apis/libtui.lua" },
|
||||||
}),
|
}),
|
||||||
[base .. "packages/trapos-core/apis/eventloop.lua"] = "eventloop-body",
|
[base .. "packages/trapos-core/apis/eventloop.lua"] = "eventloop-body",
|
||||||
[base .. "packages/trapos-net/apis/net.lua"] = "net-body",
|
[base .. "packages/trapos-ui/apis/libtui.lua"] = "ui-body",
|
||||||
}
|
}
|
||||||
local sd, root = freshDirs()
|
local sd, root = freshDirs()
|
||||||
local ccpm = createCcpm({ stateDir = sd, installRoot = root, http = fakeHttp(routes) })
|
local ccpm = createCcpm({ stateDir = sd, installRoot = root, http = fakeHttp(routes) })
|
||||||
ccpm.writeConfig({ registries = { { name = "guillaumearm/cc-libs", type = "gitea", branch = "next" } } })
|
ccpm.writeConfig({ registries = { { name = "guillaumearm/cc-libs", type = "gitea", branch = "next" } } })
|
||||||
|
|
||||||
local ok = ccpm.install("trapos-net", {})
|
local ok = ccpm.install("trapos-ui", {})
|
||||||
testlib.assertTrue(ok)
|
testlib.assertTrue(ok)
|
||||||
testlib.assertTrue(fs.exists(root .. "/apis/net.lua"))
|
testlib.assertTrue(fs.exists(root .. "/apis/libtui.lua"))
|
||||||
testlib.assertTrue(fs.exists(root .. "/apis/eventloop.lua"))
|
testlib.assertTrue(fs.exists(root .. "/apis/eventloop.lua"))
|
||||||
|
|
||||||
local f = fs.open(root .. "/apis/net.lua", "r")
|
local f = fs.open(root .. "/apis/libtui.lua", "r")
|
||||||
local body = f.readAll()
|
local body = f.readAll()
|
||||||
f.close()
|
f.close()
|
||||||
testlib.assertEquals(body, "net-body")
|
testlib.assertEquals(body, "ui-body")
|
||||||
|
|
||||||
local lock = ccpm.readLock()
|
local lock = ccpm.readLock()
|
||||||
testlib.assertEquals(lock.packages["trapos-net"].registry, "guillaumearm/cc-libs")
|
testlib.assertEquals(lock.packages["trapos-ui"].registry, "guillaumearm/cc-libs")
|
||||||
end)
|
end)
|
||||||
|
|
||||||
testlib.test("installing trapos writes aggregated os state", function()
|
testlib.test("installing trapos writes aggregated os state", function()
|
||||||
@ -221,7 +221,7 @@ testlib.test("installing trapos writes aggregated os state", function()
|
|||||||
[base .. "packages/trapos/ccpm.json"] = textutils.serializeJSON({
|
[base .. "packages/trapos/ccpm.json"] = textutils.serializeJSON({
|
||||||
name = "trapos",
|
name = "trapos",
|
||||||
version = "1",
|
version = "1",
|
||||||
dependencies = { "trapos-net" },
|
dependencies = { "trapos-ui" },
|
||||||
files = {},
|
files = {},
|
||||||
}),
|
}),
|
||||||
[base .. "packages/trapos-core/ccpm.json"] = textutils.serializeJSON({
|
[base .. "packages/trapos-core/ccpm.json"] = textutils.serializeJSON({
|
||||||
@ -230,15 +230,15 @@ testlib.test("installing trapos writes aggregated os state", function()
|
|||||||
dependencies = {},
|
dependencies = {},
|
||||||
files = { "programs/ccpm.lua" },
|
files = { "programs/ccpm.lua" },
|
||||||
}),
|
}),
|
||||||
[base .. "packages/trapos-net/ccpm.json"] = textutils.serializeJSON({
|
[base .. "packages/trapos-ui/ccpm.json"] = textutils.serializeJSON({
|
||||||
name = "trapos-net",
|
name = "trapos-ui",
|
||||||
version = "1",
|
version = "1",
|
||||||
dependencies = { "trapos-core" },
|
dependencies = { "trapos-core" },
|
||||||
files = { "apis/net.lua" },
|
files = { "apis/libtui.lua" },
|
||||||
autostart = { "servers/ping-server" },
|
autostart = { "servers/demo" },
|
||||||
}),
|
}),
|
||||||
[base .. "packages/trapos-core/programs/ccpm.lua"] = "ccpm-body",
|
[base .. "packages/trapos-core/programs/ccpm.lua"] = "ccpm-body",
|
||||||
[base .. "packages/trapos-net/apis/net.lua"] = "net-body",
|
[base .. "packages/trapos-ui/apis/libtui.lua"] = "ui-body",
|
||||||
}
|
}
|
||||||
local sd, root = freshDirs()
|
local sd, root = freshDirs()
|
||||||
local ccpm = createCcpm({ stateDir = sd, installRoot = root, http = fakeHttp(routes) })
|
local ccpm = createCcpm({ stateDir = sd, installRoot = root, http = fakeHttp(routes) })
|
||||||
@ -251,7 +251,7 @@ testlib.test("installing trapos writes aggregated os state", function()
|
|||||||
f.close()
|
f.close()
|
||||||
testlib.assertEquals(manifest.version, "1")
|
testlib.assertEquals(manifest.version, "1")
|
||||||
testlib.assertEquals(#manifest.files, 2)
|
testlib.assertEquals(#manifest.files, 2)
|
||||||
testlib.assertEquals(manifest.autostart[1], "servers/ping-server")
|
testlib.assertEquals(manifest.autostart[1], "servers/demo")
|
||||||
end)
|
end)
|
||||||
|
|
||||||
testlib.test("registry add and remove round-trip", function()
|
testlib.test("registry add and remove round-trip", function()
|
||||||
@ -522,19 +522,19 @@ testlib.test("uninstall refuses a package with dependents", function()
|
|||||||
ccpm.writeLock({
|
ccpm.writeLock({
|
||||||
packages = {
|
packages = {
|
||||||
["trapos-core"] = { version = "1", files = {}, dependencies = {} },
|
["trapos-core"] = { version = "1", files = {}, dependencies = {} },
|
||||||
["trapos-net"] = { version = "1", files = {}, dependencies = { "trapos-core" } },
|
["trapos-ui"] = { version = "1", files = {}, dependencies = { "trapos-core" } },
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
local ok, err = ccpm.uninstall("trapos-core", {})
|
local ok, err = ccpm.uninstall("trapos-core", {})
|
||||||
testlib.assertTrue(not ok)
|
testlib.assertTrue(not ok)
|
||||||
testlib.assertTrue(string.find(err, "required by", 1, true))
|
testlib.assertTrue(string.find(err, "required by", 1, true))
|
||||||
testlib.assertTrue(string.find(err, "trapos-net", 1, true))
|
testlib.assertTrue(string.find(err, "trapos-ui", 1, true))
|
||||||
end)
|
end)
|
||||||
|
|
||||||
testlib.test("uninstall removes a leaf package and its files", function()
|
testlib.test("uninstall removes a leaf package and its files", function()
|
||||||
local sd, root = freshDirs()
|
local sd, root = freshDirs()
|
||||||
fs.makeDir(root .. "/apis")
|
fs.makeDir(root .. "/apis")
|
||||||
local f = fs.open(root .. "/apis/net.lua", "w")
|
local f = fs.open(root .. "/apis/libtui.lua", "w")
|
||||||
f.write("x")
|
f.write("x")
|
||||||
f.close()
|
f.close()
|
||||||
|
|
||||||
@ -542,13 +542,13 @@ testlib.test("uninstall removes a leaf package and its files", function()
|
|||||||
ccpm.writeLock({
|
ccpm.writeLock({
|
||||||
packages = {
|
packages = {
|
||||||
["trapos-core"] = { version = "1", files = {}, dependencies = {} },
|
["trapos-core"] = { version = "1", files = {}, dependencies = {} },
|
||||||
["trapos-net"] = { version = "1", files = { "apis/net.lua" }, dependencies = { "trapos-core" } },
|
["trapos-ui"] = { version = "1", files = { "apis/libtui.lua" }, dependencies = { "trapos-core" } },
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
testlib.assertTrue(ccpm.uninstall("trapos-net", {}))
|
testlib.assertTrue(ccpm.uninstall("trapos-ui", {}))
|
||||||
testlib.assertTrue(not fs.exists(root .. "/apis/net.lua"))
|
testlib.assertTrue(not fs.exists(root .. "/apis/libtui.lua"))
|
||||||
testlib.assertTrue(ccpm.readLock().packages["trapos-net"] == nil)
|
testlib.assertTrue(ccpm.readLock().packages["trapos-ui"] == nil)
|
||||||
testlib.assertTrue(ccpm.readLock().packages["trapos-core"] ~= nil)
|
testlib.assertTrue(ccpm.readLock().packages["trapos-core"] ~= nil)
|
||||||
end)
|
end)
|
||||||
|
|
||||||
|
|||||||
@ -44,10 +44,10 @@ end);
|
|||||||
|
|
||||||
testlib.test('forFile falls back to packages/<name>/ccpm.json when no lock entry matches', function()
|
testlib.test('forFile falls back to packages/<name>/ccpm.json when no lock entry matches', function()
|
||||||
local stateDir, repoRoot = freshDirs();
|
local stateDir, repoRoot = freshDirs();
|
||||||
writeJson(repoRoot .. '/packages/trapos-net/ccpm.json', {
|
writeJson(repoRoot .. '/packages/trapos-cloud/ccpm.json', {
|
||||||
name = 'trapos-net',
|
name = 'trapos-cloud',
|
||||||
version = '0.2.1',
|
version = '0.2.1',
|
||||||
files = { 'apis/net.lua', 'programs/router.lua' },
|
files = { 'apis/libcloud.lua', 'programs/cloud.lua' },
|
||||||
});
|
});
|
||||||
writeJson(repoRoot .. '/packages/trapos-ui/ccpm.json', {
|
writeJson(repoRoot .. '/packages/trapos-ui/ccpm.json', {
|
||||||
name = 'trapos-ui',
|
name = 'trapos-ui',
|
||||||
@ -55,7 +55,7 @@ testlib.test('forFile falls back to packages/<name>/ccpm.json when no lock entry
|
|||||||
files = { 'apis/libtui.lua' },
|
files = { 'apis/libtui.lua' },
|
||||||
});
|
});
|
||||||
local v = createVersion({ stateDir = stateDir, repoRoot = repoRoot });
|
local v = createVersion({ stateDir = stateDir, repoRoot = repoRoot });
|
||||||
testlib.assertEquals(v.forFile('/programs/router.lua'), '0.2.1');
|
testlib.assertEquals(v.forFile('/programs/cloud.lua'), '0.2.1');
|
||||||
testlib.assertEquals(v.forFile('/apis/libtui.lua'), '0.2.2');
|
testlib.assertEquals(v.forFile('/apis/libtui.lua'), '0.2.2');
|
||||||
end);
|
end);
|
||||||
|
|
||||||
@ -63,19 +63,19 @@ testlib.test('forFile prefers the lock entry over the repo descriptor', function
|
|||||||
local stateDir, repoRoot = freshDirs();
|
local stateDir, repoRoot = freshDirs();
|
||||||
writeJson(stateDir .. '/ccpm.lock.json', {
|
writeJson(stateDir .. '/ccpm.lock.json', {
|
||||||
packages = {
|
packages = {
|
||||||
['trapos-net'] = {
|
['trapos-ui'] = {
|
||||||
version = '9.9.9',
|
version = '9.9.9',
|
||||||
files = { 'programs/ping.lua' },
|
files = { 'programs/tuidemo.lua' },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
writeJson(repoRoot .. '/packages/trapos-net/ccpm.json', {
|
writeJson(repoRoot .. '/packages/trapos-ui/ccpm.json', {
|
||||||
name = 'trapos-net',
|
name = 'trapos-ui',
|
||||||
version = '0.2.1',
|
version = '0.2.1',
|
||||||
files = { 'programs/ping.lua' },
|
files = { 'programs/tuidemo.lua' },
|
||||||
});
|
});
|
||||||
local v = createVersion({ stateDir = stateDir, repoRoot = repoRoot });
|
local v = createVersion({ stateDir = stateDir, repoRoot = repoRoot });
|
||||||
testlib.assertEquals(v.forFile('/programs/ping.lua'), '9.9.9');
|
testlib.assertEquals(v.forFile('/programs/tuidemo.lua'), '9.9.9');
|
||||||
end);
|
end);
|
||||||
|
|
||||||
testlib.test('forFile returns "?" when neither source knows the file', function()
|
testlib.test('forFile returns "?" when neither source knows the file', function()
|
||||||
|
|||||||
@ -1,49 +0,0 @@
|
|||||||
-- TrapOS router state machine: (label -> id) map with TTL.
|
|
||||||
--
|
|
||||||
-- Pure logic so it can be unit-tested without a modem or eventloop.
|
|
||||||
-- The wire glue lives in /programs/router.lua.
|
|
||||||
local DEFAULT_TTL = 90;
|
|
||||||
|
|
||||||
local function createRouter(options)
|
|
||||||
options = options or {};
|
|
||||||
local ttl = options.ttl or DEFAULT_TTL;
|
|
||||||
local nowFn = options.now or os.clock;
|
|
||||||
|
|
||||||
local labelMap = {};
|
|
||||||
|
|
||||||
local function isExpired(entry)
|
|
||||||
return entry.expiresAt < nowFn();
|
|
||||||
end
|
|
||||||
|
|
||||||
local function register(label, id)
|
|
||||||
local existing = labelMap[label];
|
|
||||||
if existing and existing.id ~= id and not isExpired(existing) then
|
|
||||||
return false, 'duplicate label';
|
|
||||||
end
|
|
||||||
labelMap[label] = { id = id, expiresAt = nowFn() + ttl };
|
|
||||||
return true;
|
|
||||||
end
|
|
||||||
|
|
||||||
local function resolve(label)
|
|
||||||
local entry = labelMap[label];
|
|
||||||
if not entry then return nil end
|
|
||||||
if isExpired(entry) then
|
|
||||||
labelMap[label] = nil;
|
|
||||||
return nil;
|
|
||||||
end
|
|
||||||
return entry.id;
|
|
||||||
end
|
|
||||||
|
|
||||||
local function forget(label)
|
|
||||||
labelMap[label] = nil;
|
|
||||||
end
|
|
||||||
|
|
||||||
return {
|
|
||||||
register = register,
|
|
||||||
resolve = resolve,
|
|
||||||
forget = forget,
|
|
||||||
ttl = ttl,
|
|
||||||
};
|
|
||||||
end
|
|
||||||
|
|
||||||
return createRouter;
|
|
||||||
@ -1,212 +0,0 @@
|
|||||||
-- TrapOS networking: service-name bus on a single channel.
|
|
||||||
--
|
|
||||||
-- Servers register handlers on the boot eventloop:
|
|
||||||
-- net.serve('ping', function(msg, reply) reply('pong') end)
|
|
||||||
-- net.listen('events.foo', function(msg, packet) ... end)
|
|
||||||
--
|
|
||||||
-- Clients (CLI programs) call or send without an eventloop:
|
|
||||||
-- local ok, res = net.call('ping', 'ping', { destId = 5, timeout = 0.5 })
|
|
||||||
-- net.send('events.foo', payload, { destId = 'alice' })
|
|
||||||
--
|
|
||||||
-- A router service (servers/router-server.lua) must run on exactly one machine.
|
|
||||||
-- It resolves label-addressed packets, stamps routerId, and rebroadcasts.
|
|
||||||
local createEventLoop = require('/apis/eventloop');
|
|
||||||
|
|
||||||
local BUS_CHANNEL = 10;
|
|
||||||
local DEFAULT_TIMEOUT = 0.5;
|
|
||||||
|
|
||||||
local nextRequestSeq = 1;
|
|
||||||
local function newRequestId()
|
|
||||||
local id = tostring(os.getComputerID()) .. ':' .. tostring(nextRequestSeq) .. ':' .. tostring(os.clock());
|
|
||||||
nextRequestSeq = nextRequestSeq + 1;
|
|
||||||
return id;
|
|
||||||
end
|
|
||||||
|
|
||||||
local function isPacketOk(packet)
|
|
||||||
if type(packet) ~= 'table' then return false end
|
|
||||||
if not packet.routerId or not packet.sourceId then return false end
|
|
||||||
if packet.destId == nil then return true end
|
|
||||||
if type(packet.destId) == 'number' and packet.destId == os.getComputerID() then return true end
|
|
||||||
if type(packet.destId) == 'string' and packet.destId == os.getComputerLabel() then return true end
|
|
||||||
return false
|
|
||||||
end
|
|
||||||
|
|
||||||
local function createNetwork(el, modem, modemSide)
|
|
||||||
el = el or createEventLoop();
|
|
||||||
modem = modem or peripheral.find('modem') or error('modem not found');
|
|
||||||
modem.open(BUS_CHANNEL);
|
|
||||||
|
|
||||||
local isRouter = false;
|
|
||||||
modemSide = modemSide or peripheral.getName(modem);
|
|
||||||
|
|
||||||
local function buildPacket(service, kind, payload, destId, requestId)
|
|
||||||
return {
|
|
||||||
sourceId = os.getComputerID(),
|
|
||||||
sourceLabel = os.getComputerLabel(),
|
|
||||||
destId = tonumber(destId) or destId,
|
|
||||||
service = service,
|
|
||||||
kind = kind,
|
|
||||||
requestId = requestId,
|
|
||||||
payload = payload,
|
|
||||||
routerId = isRouter and os.getComputerID() or nil,
|
|
||||||
};
|
|
||||||
end
|
|
||||||
|
|
||||||
local function transmit(packet)
|
|
||||||
local selfId = os.getComputerID();
|
|
||||||
local selfLabel = os.getComputerLabel();
|
|
||||||
|
|
||||||
local destIsSelfId = packet.destId == selfId;
|
|
||||||
local destIsSelfLabel = selfLabel ~= nil and packet.destId == selfLabel;
|
|
||||||
local destIsSelf = destIsSelfId or destIsSelfLabel;
|
|
||||||
local matchesSelf = packet.destId == nil or destIsSelf;
|
|
||||||
|
|
||||||
if matchesSelf then
|
|
||||||
local localPacket = {};
|
|
||||||
for k, v in pairs(packet) do localPacket[k] = v end
|
|
||||||
localPacket.routerId = localPacket.routerId or selfId;
|
|
||||||
os.queueEvent('modem_message', modemSide, BUS_CHANNEL, BUS_CHANNEL, localPacket, 0);
|
|
||||||
end
|
|
||||||
|
|
||||||
if not destIsSelf then
|
|
||||||
modem.transmit(BUS_CHANNEL, BUS_CHANNEL, packet);
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
local function serve(serviceName, handler)
|
|
||||||
return el.register('modem_message', function(_, _, replyChannel, packet)
|
|
||||||
if replyChannel ~= BUS_CHANNEL then return end
|
|
||||||
if not isPacketOk(packet) then return end
|
|
||||||
if packet.service ~= serviceName or packet.kind ~= 'req' then return end
|
|
||||||
|
|
||||||
local function reply(responsePayload)
|
|
||||||
local response = buildPacket(serviceName, 'res', responsePayload, packet.sourceId, packet.requestId);
|
|
||||||
transmit(response);
|
|
||||||
end
|
|
||||||
|
|
||||||
handler(packet.payload, reply, packet);
|
|
||||||
end);
|
|
||||||
end
|
|
||||||
|
|
||||||
local function listen(serviceName, handler)
|
|
||||||
return el.register('modem_message', function(_, _, replyChannel, packet)
|
|
||||||
if replyChannel ~= BUS_CHANNEL then return end
|
|
||||||
if not isPacketOk(packet) then return end
|
|
||||||
if packet.service ~= serviceName or packet.kind ~= 'evt' then return end
|
|
||||||
|
|
||||||
handler(packet.payload, packet);
|
|
||||||
end);
|
|
||||||
end
|
|
||||||
|
|
||||||
local function send(serviceName, payload, opts)
|
|
||||||
opts = opts or {};
|
|
||||||
transmit(buildPacket(serviceName, 'evt', payload, opts.destId, nil));
|
|
||||||
end
|
|
||||||
|
|
||||||
local function awaitResponse(serviceName, requestId, timeout, collectMultiple)
|
|
||||||
local timerId = os.startTimer(timeout);
|
|
||||||
local results = {};
|
|
||||||
local packets = {};
|
|
||||||
|
|
||||||
while true do
|
|
||||||
local event, p1, _, p3, p4 = os.pullEvent();
|
|
||||||
if event == 'timer' and p1 == timerId then
|
|
||||||
if collectMultiple then
|
|
||||||
if #results == 0 then return false, 'net.call timeout', {} end
|
|
||||||
return true, results, packets;
|
|
||||||
end
|
|
||||||
return false, 'net.call timeout', nil;
|
|
||||||
elseif event == 'modem_message' then
|
|
||||||
local replyChannel = p3;
|
|
||||||
local recvPacket = p4;
|
|
||||||
if replyChannel == BUS_CHANNEL
|
|
||||||
and isPacketOk(recvPacket)
|
|
||||||
and recvPacket.service == serviceName
|
|
||||||
and recvPacket.kind == 'res'
|
|
||||||
and recvPacket.requestId == requestId then
|
|
||||||
if collectMultiple then
|
|
||||||
table.insert(results, recvPacket.payload);
|
|
||||||
table.insert(packets, recvPacket);
|
|
||||||
else
|
|
||||||
os.cancelTimer(timerId);
|
|
||||||
return true, recvPacket.payload, recvPacket;
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
local function call(serviceName, payload, opts)
|
|
||||||
opts = opts or {};
|
|
||||||
local timeout = opts.timeout or DEFAULT_TIMEOUT;
|
|
||||||
local requestId = newRequestId();
|
|
||||||
transmit(buildPacket(serviceName, 'req', payload, opts.destId, requestId));
|
|
||||||
return awaitResponse(serviceName, requestId, timeout, false);
|
|
||||||
end
|
|
||||||
|
|
||||||
local function callMultiple(serviceName, payload, opts)
|
|
||||||
opts = opts or {};
|
|
||||||
local timeout = opts.timeout or DEFAULT_TIMEOUT;
|
|
||||||
local requestId = newRequestId();
|
|
||||||
transmit(buildPacket(serviceName, 'req', payload, opts.destId, requestId));
|
|
||||||
return awaitResponse(serviceName, requestId, timeout, true);
|
|
||||||
end
|
|
||||||
|
|
||||||
local function setRouter(enabled)
|
|
||||||
isRouter = enabled and true or false;
|
|
||||||
end
|
|
||||||
|
|
||||||
local function onUnrouted(handler)
|
|
||||||
return el.register('modem_message', function(_, _, replyChannel, packet)
|
|
||||||
if replyChannel ~= BUS_CHANNEL then return end
|
|
||||||
if type(packet) ~= 'table' then return end
|
|
||||||
if packet.routerId then return end
|
|
||||||
if not packet.sourceId then return end
|
|
||||||
handler(packet);
|
|
||||||
end);
|
|
||||||
end
|
|
||||||
|
|
||||||
local function rebroadcast(packet)
|
|
||||||
packet.routerId = packet.routerId or os.getComputerID();
|
|
||||||
local selfId = os.getComputerID();
|
|
||||||
local selfLabel = os.getComputerLabel();
|
|
||||||
local destIsSelfId = packet.destId == selfId;
|
|
||||||
local destIsSelfLabel = selfLabel ~= nil and packet.destId == selfLabel;
|
|
||||||
local destIsSelf = destIsSelfId or destIsSelfLabel;
|
|
||||||
local matchesSelf = packet.destId == nil or destIsSelf;
|
|
||||||
|
|
||||||
if matchesSelf then
|
|
||||||
os.queueEvent('modem_message', modemSide, BUS_CHANNEL, BUS_CHANNEL, packet, 0);
|
|
||||||
end
|
|
||||||
if not destIsSelf then
|
|
||||||
modem.transmit(BUS_CHANNEL, BUS_CHANNEL, packet);
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
return {
|
|
||||||
BUS_CHANNEL = BUS_CHANNEL,
|
|
||||||
DEFAULT_TIMEOUT = DEFAULT_TIMEOUT,
|
|
||||||
eventloop = el,
|
|
||||||
isPacketOk = isPacketOk,
|
|
||||||
serve = serve,
|
|
||||||
listen = listen,
|
|
||||||
send = send,
|
|
||||||
call = call,
|
|
||||||
callMultiple = callMultiple,
|
|
||||||
setRouter = setRouter,
|
|
||||||
onUnrouted = onUnrouted,
|
|
||||||
rebroadcast = rebroadcast,
|
|
||||||
};
|
|
||||||
end
|
|
||||||
|
|
||||||
local singleton = nil;
|
|
||||||
|
|
||||||
return function(el, modem, modemSide)
|
|
||||||
if el == nil and modem == nil and _G.bootEventLoop then
|
|
||||||
if not singleton then
|
|
||||||
singleton = createNetwork(_G.bootEventLoop, nil, nil);
|
|
||||||
end
|
|
||||||
return singleton;
|
|
||||||
end
|
|
||||||
return createNetwork(el, modem, modemSide);
|
|
||||||
end
|
|
||||||
@ -1,15 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "trapos-net",
|
|
||||||
"version": "0.3.2",
|
|
||||||
"description": "TrapOS networking: service-name bus, router, ping",
|
|
||||||
"dependencies": ["trapos-core"],
|
|
||||||
"files": [
|
|
||||||
"apis/net.lua",
|
|
||||||
"apis/librouter.lua",
|
|
||||||
"programs/router.lua",
|
|
||||||
"programs/ping.lua",
|
|
||||||
"servers/ping-server.lua",
|
|
||||||
"servers/net-registrar.lua"
|
|
||||||
],
|
|
||||||
"autostart": ["servers/ping-server", "servers/net-registrar"]
|
|
||||||
}
|
|
||||||
@ -1,35 +0,0 @@
|
|||||||
local firstArg = ...;
|
|
||||||
if firstArg == '-version' or firstArg == '--version' then
|
|
||||||
print('v' .. require('/apis/libversion')().forSelf());
|
|
||||||
return;
|
|
||||||
end
|
|
||||||
|
|
||||||
if firstArg == '-help' or firstArg == '--help' then
|
|
||||||
print('Usage: ping [<id|label>]');
|
|
||||||
return;
|
|
||||||
end
|
|
||||||
|
|
||||||
local createNet = require('/apis/net');
|
|
||||||
local net = createNet();
|
|
||||||
|
|
||||||
local args = table.pack(...);
|
|
||||||
local targetComputerId = tonumber(args[1]) or args[1];
|
|
||||||
|
|
||||||
local sourceId = os.getComputerID();
|
|
||||||
local sourceLabel = os.getComputerLabel();
|
|
||||||
|
|
||||||
local ok, results, packets = net.callMultiple('ping', 'ping', { destId = targetComputerId });
|
|
||||||
|
|
||||||
if not ok and targetComputerId ~= sourceId and targetComputerId ~= sourceLabel then
|
|
||||||
error(results);
|
|
||||||
end
|
|
||||||
|
|
||||||
if not ok then return end
|
|
||||||
|
|
||||||
for k, message in ipairs(results) do
|
|
||||||
if message == 'pong' then
|
|
||||||
local packet = packets[k];
|
|
||||||
print("=> pong from " .. tostring(packet.sourceId)
|
|
||||||
.. (packet.sourceLabel and " (label=" .. tostring(packet.sourceLabel) .. ")" or ""));
|
|
||||||
end
|
|
||||||
end
|
|
||||||
@ -1,69 +0,0 @@
|
|||||||
local firstArg = ...;
|
|
||||||
|
|
||||||
if firstArg == '-version' or firstArg == '--version' then
|
|
||||||
print('v' .. require('/apis/libversion')().forSelf());
|
|
||||||
return;
|
|
||||||
end
|
|
||||||
|
|
||||||
if firstArg == '-help' or firstArg == '--help' then
|
|
||||||
print('Usage: router [-silent|--silent]');
|
|
||||||
print('Enables routing on this machine. Registers handlers on the boot eventloop.');
|
|
||||||
return;
|
|
||||||
end
|
|
||||||
|
|
||||||
local silent = (firstArg == '-silent' or firstArg == '--silent');
|
|
||||||
local printVerbose = silent and function() end or print;
|
|
||||||
|
|
||||||
local createEventLoop = require('/apis/eventloop');
|
|
||||||
local createNet = require('/apis/net');
|
|
||||||
local createRouter = require('/apis/librouter');
|
|
||||||
|
|
||||||
local ownsLoop = false;
|
|
||||||
if not _G.bootEventLoop then
|
|
||||||
_G.bootEventLoop = createEventLoop();
|
|
||||||
ownsLoop = true;
|
|
||||||
end
|
|
||||||
|
|
||||||
local net = createNet();
|
|
||||||
net.setRouter(true);
|
|
||||||
|
|
||||||
local router = createRouter();
|
|
||||||
|
|
||||||
net.listen('router.register', function(payload, packet)
|
|
||||||
if type(payload) ~= 'table' or type(payload.label) ~= 'string' then return end
|
|
||||||
local ok = router.register(payload.label, packet.sourceId);
|
|
||||||
if ok then
|
|
||||||
printVerbose("router: registered '" .. payload.label .. "' -> " .. tostring(packet.sourceId));
|
|
||||||
else
|
|
||||||
printVerbose("router: duplicate label '" .. payload.label .. "' from id " .. tostring(packet.sourceId));
|
|
||||||
end
|
|
||||||
end);
|
|
||||||
|
|
||||||
net.onUnrouted(function(packet)
|
|
||||||
if type(packet.destId) == 'string' then
|
|
||||||
local id = router.resolve(packet.destId);
|
|
||||||
if id then
|
|
||||||
packet.destId = id;
|
|
||||||
else
|
|
||||||
printVerbose("router: unknown label '" .. tostring(packet.destId) .. "' (dropping)");
|
|
||||||
return;
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
if packet.destId then
|
|
||||||
printVerbose("router: " .. tostring(packet.sourceId) .. " -> " .. tostring(packet.destId)
|
|
||||||
.. " [" .. tostring(packet.service) .. "/" .. tostring(packet.kind) .. "]");
|
|
||||||
else
|
|
||||||
printVerbose("router: " .. tostring(packet.sourceId) .. " broadcast"
|
|
||||||
.. " [" .. tostring(packet.service) .. "/" .. tostring(packet.kind) .. "]");
|
|
||||||
end
|
|
||||||
|
|
||||||
net.rebroadcast(packet);
|
|
||||||
end);
|
|
||||||
|
|
||||||
printVerbose('router v' .. require('/apis/libversion')().forSelf()
|
|
||||||
.. ' started on bus channel ' .. tostring(net.BUS_CHANNEL));
|
|
||||||
|
|
||||||
if ownsLoop then
|
|
||||||
_G.bootEventLoop.startLoop();
|
|
||||||
end
|
|
||||||
@ -1,24 +0,0 @@
|
|||||||
-- Periodically broadcasts (id, label) to the router so label-addressed
|
|
||||||
-- packets can be resolved network-wide. Skips machines with no label.
|
|
||||||
local createNet = require("/apis/net")
|
|
||||||
|
|
||||||
-- TOFIX: the idea of this file is to dynamically listen the computerLabel so in fact "os.getComputerLabel" should be called inside refresh()
|
|
||||||
local label = os.getComputerLabel()
|
|
||||||
if not label then
|
|
||||||
return
|
|
||||||
end
|
|
||||||
|
|
||||||
local net = createNet()
|
|
||||||
local el = net.eventloop
|
|
||||||
|
|
||||||
-- In the future we might want to consider to have core events like computer-label-changed (at os level)
|
|
||||||
local REFRESH_SECONDS = 30
|
|
||||||
|
|
||||||
local function refresh()
|
|
||||||
-- Note: I don't like "router.register" and net-registrar naming here
|
|
||||||
-- I think what we want here is a sort of hack to get an event computer_label_changed that will be used by the router directly, so maybe move this directly in `startup` dir ?
|
|
||||||
net.send("router.register", { label = label })
|
|
||||||
el.setTimeout(refresh, REFRESH_SECONDS)
|
|
||||||
end
|
|
||||||
|
|
||||||
el.onStart(refresh)
|
|
||||||
@ -1,23 +0,0 @@
|
|||||||
local createNet = require("/apis/net")
|
|
||||||
local createVersion = require("/apis/libversion")
|
|
||||||
|
|
||||||
local MODEM_DETECTION_TIME = 3
|
|
||||||
|
|
||||||
if not peripheral.find("modem") then
|
|
||||||
print("Warning: modem not found!")
|
|
||||||
end
|
|
||||||
|
|
||||||
-- TOFIX: os.sleep should not be used anymore since we are in the main eventloop here.
|
|
||||||
while not peripheral.find("modem") do
|
|
||||||
os.sleep(MODEM_DETECTION_TIME)
|
|
||||||
end
|
|
||||||
|
|
||||||
local net = createNet()
|
|
||||||
|
|
||||||
net.serve("ping", function(message, reply)
|
|
||||||
if message == "ping" then
|
|
||||||
reply("pong")
|
|
||||||
end
|
|
||||||
end)
|
|
||||||
|
|
||||||
print("ping-server v" .. createVersion().forSelf() .. " started.")
|
|
||||||
@ -1,164 +0,0 @@
|
|||||||
local createLibTest = require('/apis/libtest');
|
|
||||||
local createEventLoop = require('/apis/eventloop');
|
|
||||||
local createNet = require('/apis/net');
|
|
||||||
|
|
||||||
local testlib = createLibTest({ ... });
|
|
||||||
|
|
||||||
local function fakeModem()
|
|
||||||
local transmits = {};
|
|
||||||
return {
|
|
||||||
open = function() end,
|
|
||||||
close = function() end,
|
|
||||||
transmit = function(channel, replyChannel, payload)
|
|
||||||
transmits[#transmits + 1] = {
|
|
||||||
channel = channel,
|
|
||||||
replyChannel = replyChannel,
|
|
||||||
payload = payload,
|
|
||||||
};
|
|
||||||
end,
|
|
||||||
isOpen = function() return true end,
|
|
||||||
_transmits = transmits,
|
|
||||||
};
|
|
||||||
end
|
|
||||||
|
|
||||||
testlib.test('isPacketOk requires sourceId and routerId', function()
|
|
||||||
local modem = fakeModem();
|
|
||||||
local net = createNet(createEventLoop(), modem, 'test_modem');
|
|
||||||
|
|
||||||
testlib.assertEquals(net.isPacketOk(nil), false);
|
|
||||||
testlib.assertEquals(net.isPacketOk({}), false);
|
|
||||||
testlib.assertEquals(net.isPacketOk({ sourceId = 1 }), false);
|
|
||||||
testlib.assertEquals(net.isPacketOk({ sourceId = 1, routerId = 2 }), true);
|
|
||||||
end);
|
|
||||||
|
|
||||||
testlib.test('isPacketOk matches destId by id or label', function()
|
|
||||||
local modem = fakeModem();
|
|
||||||
local net = createNet(createEventLoop(), modem, 'test_modem');
|
|
||||||
|
|
||||||
local selfId = os.getComputerID();
|
|
||||||
|
|
||||||
testlib.assertEquals(
|
|
||||||
net.isPacketOk({ sourceId = 9, routerId = 9, destId = selfId }),
|
|
||||||
true
|
|
||||||
);
|
|
||||||
testlib.assertEquals(
|
|
||||||
net.isPacketOk({ sourceId = 9, routerId = 9, destId = selfId + 100 }),
|
|
||||||
false
|
|
||||||
);
|
|
||||||
end);
|
|
||||||
|
|
||||||
testlib.test('send transmits an evt packet on the bus channel', function()
|
|
||||||
local modem = fakeModem();
|
|
||||||
local net = createNet(createEventLoop(), modem, 'test_modem');
|
|
||||||
|
|
||||||
net.send('myservice', { hello = 'world' }, { destId = 42 });
|
|
||||||
|
|
||||||
testlib.assertEquals(#modem._transmits, 1);
|
|
||||||
local t = modem._transmits[1];
|
|
||||||
testlib.assertEquals(t.channel, net.BUS_CHANNEL);
|
|
||||||
testlib.assertEquals(t.replyChannel, net.BUS_CHANNEL);
|
|
||||||
testlib.assertEquals(t.payload.service, 'myservice');
|
|
||||||
testlib.assertEquals(t.payload.kind, 'evt');
|
|
||||||
testlib.assertEquals(t.payload.destId, 42);
|
|
||||||
testlib.assertEquals(t.payload.payload.hello, 'world');
|
|
||||||
end);
|
|
||||||
|
|
||||||
testlib.test('setRouter stamps routerId on outbound packets', function()
|
|
||||||
local modem = fakeModem();
|
|
||||||
local net = createNet(createEventLoop(), modem, 'test_modem');
|
|
||||||
|
|
||||||
net.send('foo', 'bar', { destId = 42 });
|
|
||||||
testlib.assertEquals(modem._transmits[1].payload.routerId, nil);
|
|
||||||
|
|
||||||
net.setRouter(true);
|
|
||||||
net.send('foo', 'bar', { destId = 42 });
|
|
||||||
testlib.assertEquals(modem._transmits[2].payload.routerId, os.getComputerID());
|
|
||||||
end);
|
|
||||||
|
|
||||||
testlib.test('serve dispatches a request loopback to a registered handler', function()
|
|
||||||
local modem = fakeModem();
|
|
||||||
local el = createEventLoop();
|
|
||||||
local net = createNet(el, modem, 'test_modem');
|
|
||||||
|
|
||||||
local received = nil;
|
|
||||||
|
|
||||||
net.serve('echo', function(payload, reply)
|
|
||||||
received = payload;
|
|
||||||
reply({ echoed = payload });
|
|
||||||
el.stopLoop();
|
|
||||||
end);
|
|
||||||
|
|
||||||
local packet = {
|
|
||||||
sourceId = os.getComputerID(),
|
|
||||||
sourceLabel = os.getComputerLabel(),
|
|
||||||
destId = os.getComputerID(),
|
|
||||||
service = 'echo',
|
|
||||||
kind = 'req',
|
|
||||||
requestId = 'test-req-1',
|
|
||||||
payload = { foo = 'bar' },
|
|
||||||
routerId = os.getComputerID(),
|
|
||||||
};
|
|
||||||
os.queueEvent('modem_message', 'test_modem', net.BUS_CHANNEL, net.BUS_CHANNEL, packet, 0);
|
|
||||||
|
|
||||||
el.runLoop();
|
|
||||||
|
|
||||||
testlib.assertEquals(received.foo, 'bar');
|
|
||||||
end);
|
|
||||||
|
|
||||||
testlib.test('serve ignores packets with a different service', function()
|
|
||||||
local modem = fakeModem();
|
|
||||||
local el = createEventLoop();
|
|
||||||
local net = createNet(el, modem, 'test_modem');
|
|
||||||
|
|
||||||
local hit = false;
|
|
||||||
|
|
||||||
net.serve('echo', function() hit = true end);
|
|
||||||
|
|
||||||
el.setTimeout(function() el.stopLoop() end, 0);
|
|
||||||
|
|
||||||
local packet = {
|
|
||||||
sourceId = os.getComputerID(),
|
|
||||||
sourceLabel = os.getComputerLabel(),
|
|
||||||
destId = os.getComputerID(),
|
|
||||||
service = 'other',
|
|
||||||
kind = 'req',
|
|
||||||
requestId = 'test-req-2',
|
|
||||||
payload = nil,
|
|
||||||
routerId = os.getComputerID(),
|
|
||||||
};
|
|
||||||
os.queueEvent('modem_message', 'test_modem', net.BUS_CHANNEL, net.BUS_CHANNEL, packet, 0);
|
|
||||||
|
|
||||||
el.runLoop();
|
|
||||||
|
|
||||||
testlib.assertEquals(hit, false);
|
|
||||||
end);
|
|
||||||
|
|
||||||
testlib.test('serve ignores unrouted packets (no routerId)', function()
|
|
||||||
local modem = fakeModem();
|
|
||||||
local el = createEventLoop();
|
|
||||||
local net = createNet(el, modem, 'test_modem');
|
|
||||||
|
|
||||||
local hit = false;
|
|
||||||
|
|
||||||
net.serve('echo', function() hit = true end);
|
|
||||||
|
|
||||||
el.setTimeout(function() el.stopLoop() end, 0);
|
|
||||||
|
|
||||||
local packet = {
|
|
||||||
sourceId = os.getComputerID(),
|
|
||||||
sourceLabel = os.getComputerLabel(),
|
|
||||||
destId = os.getComputerID(),
|
|
||||||
service = 'echo',
|
|
||||||
kind = 'req',
|
|
||||||
requestId = 'test-req-3',
|
|
||||||
payload = nil,
|
|
||||||
-- no routerId
|
|
||||||
};
|
|
||||||
os.queueEvent('modem_message', 'test_modem', net.BUS_CHANNEL, net.BUS_CHANNEL, packet, 0);
|
|
||||||
|
|
||||||
el.runLoop();
|
|
||||||
|
|
||||||
testlib.assertEquals(hit, false);
|
|
||||||
end);
|
|
||||||
|
|
||||||
testlib.run();
|
|
||||||
@ -1,74 +0,0 @@
|
|||||||
local createLibTest = require('/apis/libtest');
|
|
||||||
local createRouter = require('/apis/librouter');
|
|
||||||
|
|
||||||
local testlib = createLibTest({ ... });
|
|
||||||
|
|
||||||
local function makeClock()
|
|
||||||
local now = 0;
|
|
||||||
return function() return now; end, function(delta) now = now + delta; end;
|
|
||||||
end
|
|
||||||
|
|
||||||
testlib.test('register accepts a new label', function()
|
|
||||||
local now, _ = makeClock();
|
|
||||||
local router = createRouter({ now = now, ttl = 10 });
|
|
||||||
|
|
||||||
local ok, err = router.register('alice', 5);
|
|
||||||
testlib.assertEquals(ok, true);
|
|
||||||
testlib.assertEquals(err, nil);
|
|
||||||
testlib.assertEquals(router.resolve('alice'), 5);
|
|
||||||
end);
|
|
||||||
|
|
||||||
testlib.test('register rejects a duplicate label from a different id', function()
|
|
||||||
local now, _ = makeClock();
|
|
||||||
local router = createRouter({ now = now, ttl = 10 });
|
|
||||||
|
|
||||||
router.register('alice', 5);
|
|
||||||
local ok, err = router.register('alice', 7);
|
|
||||||
testlib.assertEquals(ok, false);
|
|
||||||
testlib.assertEquals(err, 'duplicate label');
|
|
||||||
testlib.assertEquals(router.resolve('alice'), 5);
|
|
||||||
end);
|
|
||||||
|
|
||||||
testlib.test('register refreshes a label from the same id', function()
|
|
||||||
local now, advance = makeClock();
|
|
||||||
local router = createRouter({ now = now, ttl = 10 });
|
|
||||||
|
|
||||||
router.register('alice', 5);
|
|
||||||
advance(5);
|
|
||||||
local ok = router.register('alice', 5);
|
|
||||||
testlib.assertEquals(ok, true);
|
|
||||||
|
|
||||||
advance(8);
|
|
||||||
testlib.assertEquals(router.resolve('alice'), 5);
|
|
||||||
end);
|
|
||||||
|
|
||||||
testlib.test('resolve returns nil after TTL expiry', function()
|
|
||||||
local now, advance = makeClock();
|
|
||||||
local router = createRouter({ now = now, ttl = 10 });
|
|
||||||
|
|
||||||
router.register('alice', 5);
|
|
||||||
advance(11);
|
|
||||||
testlib.assertEquals(router.resolve('alice'), nil);
|
|
||||||
end);
|
|
||||||
|
|
||||||
testlib.test('expired entries can be re-registered by a different id', function()
|
|
||||||
local now, advance = makeClock();
|
|
||||||
local router = createRouter({ now = now, ttl = 10 });
|
|
||||||
|
|
||||||
router.register('alice', 5);
|
|
||||||
advance(11);
|
|
||||||
local ok = router.register('alice', 7);
|
|
||||||
testlib.assertEquals(ok, true);
|
|
||||||
testlib.assertEquals(router.resolve('alice'), 7);
|
|
||||||
end);
|
|
||||||
|
|
||||||
testlib.test('forget removes a label', function()
|
|
||||||
local now, _ = makeClock();
|
|
||||||
local router = createRouter({ now = now, ttl = 10 });
|
|
||||||
|
|
||||||
router.register('alice', 5);
|
|
||||||
router.forget('alice');
|
|
||||||
testlib.assertEquals(router.resolve('alice'), nil);
|
|
||||||
end);
|
|
||||||
|
|
||||||
testlib.run();
|
|
||||||
@ -1,10 +1,9 @@
|
|||||||
{
|
{
|
||||||
"name": "trapos",
|
"name": "trapos",
|
||||||
"version": "0.12.4",
|
"version": "0.12.5",
|
||||||
"description": "TrapOS full install meta-package",
|
"description": "TrapOS full install meta-package",
|
||||||
"dependencies": [
|
"dependencies": [
|
||||||
"trapos-boot",
|
"trapos-boot",
|
||||||
"trapos-net",
|
|
||||||
"trapos-ui",
|
"trapos-ui",
|
||||||
"trapos-test",
|
"trapos-test",
|
||||||
"trapos-ai",
|
"trapos-ai",
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import Fastify, { type FastifyInstance, type FastifyServerOptions } from "fastify";
|
import Fastify, { type FastifyInstance, type FastifyServerOptions } from "fastify";
|
||||||
import websocket from "@fastify/websocket";
|
import websocket from "@fastify/websocket";
|
||||||
import { resolveAccount, LOCAL_ACCOUNT_ID } from "./auth.js";
|
import { resolveNetwork, GLOBAL_NETWORK_ID } from "./auth.js";
|
||||||
import { createOpencodeProbe } from "./opencode.js";
|
import { createOpencodeProbe } from "./opencode.js";
|
||||||
import gatewayModule from "./modules/trapos-cloud-gateway/index.js";
|
import gatewayModule from "./modules/trapos-cloud-gateway/index.js";
|
||||||
import { GatewayRegistry } from "./modules/trapos-cloud-gateway/registry.js";
|
import { GatewayRegistry } from "./modules/trapos-cloud-gateway/registry.js";
|
||||||
@ -48,7 +48,7 @@ export async function createApp(
|
|||||||
await app.register(gatewayModule, {
|
await app.register(gatewayModule, {
|
||||||
registry,
|
registry,
|
||||||
probe,
|
probe,
|
||||||
resolveAccount: (secret) => resolveAccount(secret, config.serverPassword),
|
resolveNetwork: (secret) => resolveNetwork(secret, config.serverPassword),
|
||||||
});
|
});
|
||||||
|
|
||||||
const mcpApp = config.mcpEnabled ? await createMcpApp(config, registry) : null;
|
const mcpApp = config.mcpEnabled ? await createMcpApp(config, registry) : null;
|
||||||
@ -60,7 +60,7 @@ async function createMcpApp(config: Config, registry: GatewayRegistry): Promise<
|
|||||||
const mcpApp = Fastify({ logger: config.logger, disableRequestLogging: true });
|
const mcpApp = Fastify({ logger: config.logger, disableRequestLogging: true });
|
||||||
await mcpApp.register(mcpModule, {
|
await mcpApp.register(mcpModule, {
|
||||||
registry,
|
registry,
|
||||||
accountId: LOCAL_ACCOUNT_ID,
|
networkId: GLOBAL_NETWORK_ID,
|
||||||
defaultTimeoutMs: config.mcpDefaultTimeoutMs,
|
defaultTimeoutMs: config.mcpDefaultTimeoutMs,
|
||||||
maxTimeoutMs: config.mcpMaxTimeoutMs,
|
maxTimeoutMs: config.mcpMaxTimeoutMs,
|
||||||
probeTimeoutMs: config.mcpProbeTimeoutMs,
|
probeTimeoutMs: config.mcpProbeTimeoutMs,
|
||||||
|
|||||||
@ -1,19 +1,19 @@
|
|||||||
export type AccountId = "local";
|
export type NetworkId = "global";
|
||||||
|
|
||||||
// The single account today. Centralized so the gateway and the MCP server agree on
|
// The single network today. Centralized so the gateway and the MCP server agree on
|
||||||
// the registry key (the future multi-account swap touches only resolveAccount).
|
// the registry key (the future multi-network swap touches only resolveNetwork).
|
||||||
export const LOCAL_ACCOUNT_ID: AccountId = "local";
|
export const GLOBAL_NETWORK_ID: NetworkId = "global";
|
||||||
|
|
||||||
// The single future swap-point: credential -> account home. Today it also owns the
|
// The single future swap-point: credential -> network home. Today it also owns the
|
||||||
// password gate. Returns the account id for a valid secret, or null = unauthorized.
|
// password gate. Returns the network id for a valid secret, or null = unauthorized.
|
||||||
// - password unset/empty => auth disabled, any secret (or none) maps to "local"
|
// - password unset/empty => auth disabled, any secret (or none) maps to "global"
|
||||||
// - password set => secret must match exactly, else null
|
// - password set => secret must match exactly, else null
|
||||||
export function resolveAccount(
|
export function resolveNetwork(
|
||||||
secret: string | undefined,
|
secret: string | undefined,
|
||||||
password: string | undefined,
|
password: string | undefined,
|
||||||
): AccountId | null {
|
): NetworkId | null {
|
||||||
if (password === undefined || password === "") {
|
if (password === undefined || password === "") {
|
||||||
return "local";
|
return GLOBAL_NETWORK_ID;
|
||||||
}
|
}
|
||||||
return secret === password ? "local" : null;
|
return secret === password ? GLOBAL_NETWORK_ID : null;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -57,7 +57,7 @@ function buildLogger(): Config["logger"] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// A non-empty password is mandatory: an empty TRAPOS_SERVER_PASSWORD disables auth
|
// A non-empty password is mandatory: an empty TRAPOS_SERVER_PASSWORD disables auth
|
||||||
// on the public gateway (resolveAccount maps any secret to "local"), and an empty
|
// on the public gateway (resolveNetwork maps any secret to "global"), and an empty
|
||||||
// OPENCODE_SERVER_PASSWORD drops the outbound Basic-auth header. Refuse to start
|
// OPENCODE_SERVER_PASSWORD drops the outbound Basic-auth header. Refuse to start
|
||||||
// rather than run silently unauthenticated.
|
// rather than run silently unauthenticated.
|
||||||
function requireSecret(name: string, value: string | undefined): string {
|
function requireSecret(name: string, value: string | undefined): string {
|
||||||
|
|||||||
@ -8,7 +8,7 @@ import type { GatewayRegistry } from "../trapos-cloud-gateway/registry.js";
|
|||||||
|
|
||||||
export type McpDeps = {
|
export type McpDeps = {
|
||||||
registry: GatewayRegistry;
|
registry: GatewayRegistry;
|
||||||
accountId: string;
|
networkId: string;
|
||||||
// Default deadline for exec-lua / run-file / write-file when the caller omits timeoutMs.
|
// Default deadline for exec-lua / run-file / write-file when the caller omits timeoutMs.
|
||||||
defaultTimeoutMs: number;
|
defaultTimeoutMs: number;
|
||||||
// Hard ceiling applied to any caller-supplied timeoutMs.
|
// Hard ceiling applied to any caller-supplied timeoutMs.
|
||||||
@ -153,7 +153,7 @@ async function handleSingle(request: JsonRpcRequest, deps: McpDeps): Promise<unk
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function probeComputers(deps: McpDeps): Promise<string> {
|
async function probeComputers(deps: McpDeps): Promise<string> {
|
||||||
const traposIds = deps.registry.list(deps.accountId);
|
const traposIds = deps.registry.list(deps.networkId);
|
||||||
if (traposIds.length === 0) {
|
if (traposIds.length === 0) {
|
||||||
return "No computers connected.";
|
return "No computers connected.";
|
||||||
}
|
}
|
||||||
@ -161,7 +161,7 @@ async function probeComputers(deps: McpDeps): Promise<string> {
|
|||||||
const lines = await Promise.all(
|
const lines = await Promise.all(
|
||||||
traposIds.map(async (traposId) => {
|
traposIds.map(async (traposId) => {
|
||||||
try {
|
try {
|
||||||
await deps.registry.request(deps.accountId, traposId, "ping", {}, deps.probeTimeoutMs);
|
await deps.registry.request(deps.networkId, traposId, "ping", {}, deps.probeTimeoutMs);
|
||||||
return `ok from ${traposId}`;
|
return `ok from ${traposId}`;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
return `error from ${traposId}: ${errorText(err)}`;
|
return `error from ${traposId}: ${errorText(err)}`;
|
||||||
@ -174,7 +174,7 @@ async function probeComputers(deps: McpDeps): Promise<string> {
|
|||||||
async function execLua(deps: McpDeps, traposId: string, code: string, timeoutMs: number): Promise<string> {
|
async function execLua(deps: McpDeps, traposId: string, code: string, timeoutMs: number): Promise<string> {
|
||||||
let payload: Record<string, unknown>;
|
let payload: Record<string, unknown>;
|
||||||
try {
|
try {
|
||||||
payload = await deps.registry.request(deps.accountId, traposId, "exec-lua", { code }, timeoutMs);
|
payload = await deps.registry.request(deps.networkId, traposId, "exec-lua", { code }, timeoutMs);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
return transportError(traposId, err);
|
return transportError(traposId, err);
|
||||||
}
|
}
|
||||||
@ -201,7 +201,7 @@ function formatExecutionResult(traposId: string, payload: Record<string, unknown
|
|||||||
async function runFile(deps: McpDeps, traposId: string, path: string, args: string[], timeoutMs: number): Promise<string> {
|
async function runFile(deps: McpDeps, traposId: string, path: string, args: string[], timeoutMs: number): Promise<string> {
|
||||||
let payload: Record<string, unknown>;
|
let payload: Record<string, unknown>;
|
||||||
try {
|
try {
|
||||||
payload = await deps.registry.request(deps.accountId, traposId, "run-file", { path, args }, timeoutMs);
|
payload = await deps.registry.request(deps.networkId, traposId, "run-file", { path, args }, timeoutMs);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
return transportError(traposId, err);
|
return transportError(traposId, err);
|
||||||
}
|
}
|
||||||
@ -218,7 +218,7 @@ async function writeFile(
|
|||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
let payload: Record<string, unknown>;
|
let payload: Record<string, unknown>;
|
||||||
try {
|
try {
|
||||||
payload = await deps.registry.request(deps.accountId, traposId, "write-file", { path, content }, timeoutMs);
|
payload = await deps.registry.request(deps.networkId, traposId, "write-file", { path, content }, timeoutMs);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
return transportError(traposId, err);
|
return transportError(traposId, err);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -8,13 +8,13 @@ import {
|
|||||||
} from "./protocol.js";
|
} from "./protocol.js";
|
||||||
import type { GatewayRegistry, GatewaySocket, Logger } from "./registry.js";
|
import type { GatewayRegistry, GatewaySocket, Logger } from "./registry.js";
|
||||||
import type { OpencodeProbe } from "../../opencode.js";
|
import type { OpencodeProbe } from "../../opencode.js";
|
||||||
import type { AccountId } from "../../auth.js";
|
import type { NetworkId } from "../../auth.js";
|
||||||
|
|
||||||
export type ConnectionDeps = {
|
export type ConnectionDeps = {
|
||||||
socket: GatewaySocket;
|
socket: GatewaySocket;
|
||||||
registry: GatewayRegistry;
|
registry: GatewayRegistry;
|
||||||
probe: OpencodeProbe;
|
probe: OpencodeProbe;
|
||||||
resolveAccount: (secret: string | undefined) => AccountId | null;
|
resolveNetwork: (secret: string | undefined) => NetworkId | null;
|
||||||
log: Logger;
|
log: Logger;
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -23,7 +23,7 @@ export type Connection = {
|
|||||||
onClose(code?: number, reason?: string): void;
|
onClose(code?: number, reason?: string): void;
|
||||||
};
|
};
|
||||||
|
|
||||||
type State = { accountId: AccountId; traposId: string };
|
type State = { networkId: NetworkId; traposId: string };
|
||||||
|
|
||||||
function toText(raw: unknown): string {
|
function toText(raw: unknown): string {
|
||||||
if (typeof raw === "string") return raw;
|
if (typeof raw === "string") return raw;
|
||||||
@ -37,7 +37,7 @@ function toText(raw: unknown): string {
|
|||||||
// event that ever earns an error reply is `computer_request`; everything the gateway
|
// event that ever earns an error reply is `computer_request`; everything the gateway
|
||||||
// shouldn't receive is dropped (with a debug log). `unauthorized` is the only close.
|
// shouldn't receive is dropped (with a debug log). `unauthorized` is the only close.
|
||||||
export function createConnection(deps: ConnectionDeps): Connection {
|
export function createConnection(deps: ConnectionDeps): Connection {
|
||||||
const { socket, registry, probe, resolveAccount, log } = deps;
|
const { socket, registry, probe, resolveNetwork, log } = deps;
|
||||||
let state: State | null = null;
|
let state: State | null = null;
|
||||||
|
|
||||||
function reply(
|
function reply(
|
||||||
@ -67,22 +67,22 @@ export function createConnection(deps: ConnectionDeps): Connection {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const { traposId, secret } = parsed.data;
|
const { traposId, secret } = parsed.data;
|
||||||
const accountId = resolveAccount(secret);
|
const networkId = resolveNetwork(secret);
|
||||||
if (accountId === null) {
|
if (networkId === null) {
|
||||||
log.warn({ traposId, reason: "unauthorized" }, "gateway hello rejected");
|
log.warn({ traposId, reason: "unauthorized" }, "gateway hello rejected");
|
||||||
reply(req, false, undefined, { code: "unauthorized", message: "invalid secret" });
|
reply(req, false, undefined, { code: "unauthorized", message: "invalid secret" });
|
||||||
socket.close();
|
socket.close();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
registry.register(accountId, traposId, socket);
|
registry.register(networkId, traposId, socket);
|
||||||
state = { accountId, traposId };
|
state = { networkId, traposId };
|
||||||
log.info({ accountId, traposId }, "gateway hello accepted");
|
log.info({ networkId, traposId }, "gateway hello accepted");
|
||||||
reply(req, true, { accountId });
|
reply(req, true, { networkId });
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleProbeGateway(req: ComputerRequest): Promise<void> {
|
async function handleProbeGateway(req: ComputerRequest): Promise<void> {
|
||||||
log.debug({ accountId: state?.accountId, traposId: state?.traposId, messageId: req.messageId }, "probing gateway dependencies");
|
log.debug({ networkId: state?.networkId, traposId: state?.traposId, messageId: req.messageId }, "probing gateway dependencies");
|
||||||
const result = await probe.probe();
|
const result = await probe.probe();
|
||||||
const payload: Record<string, unknown> = { serverOk: true, opencodeOk: result.ok };
|
const payload: Record<string, unknown> = { serverOk: true, opencodeOk: result.ok };
|
||||||
if (!result.ok && result.detail !== undefined) {
|
if (!result.ok && result.detail !== undefined) {
|
||||||
@ -108,20 +108,20 @@ export function createConnection(deps: ConnectionDeps): Connection {
|
|||||||
async function dispatchPostHello(frame: Frame, current: State): Promise<void> {
|
async function dispatchPostHello(frame: Frame, current: State): Promise<void> {
|
||||||
if (frame.event === "computer_request") {
|
if (frame.event === "computer_request") {
|
||||||
if (frame.type === "hello") {
|
if (frame.type === "hello") {
|
||||||
log.debug({ accountId: current.accountId, traposId: current.traposId, reason: "duplicate_hello" }, "ignoring duplicate hello on live connection");
|
log.debug({ networkId: current.networkId, traposId: current.traposId, reason: "duplicate_hello" }, "ignoring duplicate hello on live connection");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (frame.type === "probe_server") {
|
if (frame.type === "probe_server") {
|
||||||
await handleProbeGateway(frame);
|
await handleProbeGateway(frame);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
log.debug({ accountId: current.accountId, traposId: current.traposId, messageId: frame.messageId, type: frame.type, reason: "unknown_type" }, "rejecting unknown computer_request type");
|
log.debug({ networkId: current.networkId, traposId: current.traposId, messageId: frame.messageId, type: frame.type, reason: "unknown_type" }, "rejecting unknown computer_request type");
|
||||||
reply(frame, false, undefined, { code: "unknown_type", message: `unknown type: ${frame.type}` });
|
reply(frame, false, undefined, { code: "unknown_type", message: `unknown type: ${frame.type}` });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (frame.event === "computer_response") {
|
if (frame.event === "computer_response") {
|
||||||
// resolve a pending server_request
|
// resolve a pending server_request
|
||||||
registry.resolveResponse(socket, current.accountId, current.traposId, frame);
|
registry.resolveResponse(socket, current.networkId, current.traposId, frame);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -149,8 +149,8 @@ export function createConnection(deps: ConnectionDeps): Connection {
|
|||||||
|
|
||||||
function onClose(code?: number, reason?: string): void {
|
function onClose(code?: number, reason?: string): void {
|
||||||
if (state) {
|
if (state) {
|
||||||
log.info({ accountId: state.accountId, traposId: state.traposId, closeCode: code, closeReason: reason }, "gateway connection closed");
|
log.info({ networkId: state.networkId, traposId: state.traposId, closeCode: code, closeReason: reason }, "gateway connection closed");
|
||||||
registry.unregister(state.accountId, state.traposId, socket);
|
registry.unregister(state.networkId, state.traposId, socket);
|
||||||
state = null;
|
state = null;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,7 +3,7 @@ import type { WebSocket } from "@fastify/websocket";
|
|||||||
import { createConnection } from "./connection.js";
|
import { createConnection } from "./connection.js";
|
||||||
import type { GatewayRegistry, GatewaySocket } from "./registry.js";
|
import type { GatewayRegistry, GatewaySocket } from "./registry.js";
|
||||||
import type { OpencodeProbe } from "../../opencode.js";
|
import type { OpencodeProbe } from "../../opencode.js";
|
||||||
import type { AccountId } from "../../auth.js";
|
import type { NetworkId } from "../../auth.js";
|
||||||
|
|
||||||
let nextConnectionId = 1;
|
let nextConnectionId = 1;
|
||||||
|
|
||||||
@ -18,7 +18,7 @@ const HEARTBEAT_INTERVAL_MS = 20_000;
|
|||||||
export type GatewayModuleOptions = {
|
export type GatewayModuleOptions = {
|
||||||
registry: GatewayRegistry;
|
registry: GatewayRegistry;
|
||||||
probe: OpencodeProbe;
|
probe: OpencodeProbe;
|
||||||
resolveAccount: (secret: string | undefined) => AccountId | null;
|
resolveNetwork: (secret: string | undefined) => NetworkId | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Adapter: the only place that touches the real `ws` socket. Bridges socket events
|
// Adapter: the only place that touches the real `ws` socket. Bridges socket events
|
||||||
@ -42,7 +42,7 @@ const gatewayModule: FastifyPluginAsync<GatewayModuleOptions> = (fastify, opts)
|
|||||||
socket: gatewaySocket,
|
socket: gatewaySocket,
|
||||||
registry: opts.registry,
|
registry: opts.registry,
|
||||||
probe: opts.probe,
|
probe: opts.probe,
|
||||||
resolveAccount: opts.resolveAccount,
|
resolveNetwork: opts.resolveNetwork,
|
||||||
log,
|
log,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -34,7 +34,7 @@ function isErrorCode(code: string): code is ErrorCode {
|
|||||||
return (ERROR_CODES as readonly string[]).includes(code);
|
return (ERROR_CODES as readonly string[]).includes(code);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Registry of live computer connections keyed by (accountId, traposId), newest-wins.
|
// Registry of live computer connections keyed by (networkId, traposId), newest-wins.
|
||||||
// Each entry owns its own pending map so eviction / close rejects only that socket's
|
// Each entry owns its own pending map so eviction / close rejects only that socket's
|
||||||
// in-flight requests.
|
// in-flight requests.
|
||||||
export class GatewayRegistry {
|
export class GatewayRegistry {
|
||||||
@ -45,16 +45,16 @@ export class GatewayRegistry {
|
|||||||
private readonly defaultTimeoutMs: number = DEFAULT_REQUEST_TIMEOUT_MS,
|
private readonly defaultTimeoutMs: number = DEFAULT_REQUEST_TIMEOUT_MS,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
private key(accountId: string, traposId: string): string {
|
private key(networkId: string, traposId: string): string {
|
||||||
return `${accountId}\u0000${traposId}`;
|
return `${networkId}\u0000${traposId}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
register(accountId: string, traposId: string, socket: GatewaySocket): void {
|
register(networkId: string, traposId: string, socket: GatewaySocket): void {
|
||||||
const k = this.key(accountId, traposId);
|
const k = this.key(networkId, traposId);
|
||||||
const existing = this.entries.get(k);
|
const existing = this.entries.get(k);
|
||||||
if (existing) {
|
if (existing) {
|
||||||
this.log.info(
|
this.log.info(
|
||||||
{ accountId, traposId, reason: "duplicate_hello" },
|
{ networkId, traposId, reason: "duplicate_hello" },
|
||||||
"evicting previous connection (newest-wins)",
|
"evicting previous connection (newest-wins)",
|
||||||
);
|
);
|
||||||
this.rejectAll(existing, new GatewayError("not_connected", "connection displaced by newer registration"));
|
this.rejectAll(existing, new GatewayError("not_connected", "connection displaced by newer registration"));
|
||||||
@ -71,8 +71,8 @@ export class GatewayRegistry {
|
|||||||
|
|
||||||
// Identity-checked: a late close from an evicted socket must not remove the socket
|
// Identity-checked: a late close from an evicted socket must not remove the socket
|
||||||
// that replaced it.
|
// that replaced it.
|
||||||
unregister(accountId: string, traposId: string, socket: GatewaySocket): void {
|
unregister(networkId: string, traposId: string, socket: GatewaySocket): void {
|
||||||
const k = this.key(accountId, traposId);
|
const k = this.key(networkId, traposId);
|
||||||
const entry = this.entries.get(k);
|
const entry = this.entries.get(k);
|
||||||
if (!entry || entry.socket !== socket) {
|
if (!entry || entry.socket !== socket) {
|
||||||
return;
|
return;
|
||||||
@ -81,18 +81,18 @@ export class GatewayRegistry {
|
|||||||
this.rejectAll(entry, new GatewayError("not_connected", "connection closed"));
|
this.rejectAll(entry, new GatewayError("not_connected", "connection closed"));
|
||||||
}
|
}
|
||||||
|
|
||||||
has(accountId: string, traposId: string): boolean {
|
has(networkId: string, traposId: string): boolean {
|
||||||
return this.entries.has(this.key(accountId, traposId));
|
return this.entries.has(this.key(networkId, traposId));
|
||||||
}
|
}
|
||||||
|
|
||||||
count(): number {
|
count(): number {
|
||||||
return this.entries.size;
|
return this.entries.size;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Registered traposIds for an account (newest-wins, so at most one per traposId).
|
// Registered traposIds for a network (newest-wins, so at most one per traposId).
|
||||||
// Used by the MCP `probe-computers` tool to enumerate addressable computers.
|
// Used by the MCP `probe-computers` tool to enumerate addressable computers.
|
||||||
list(accountId: string): string[] {
|
list(networkId: string): string[] {
|
||||||
const prefix = this.key(accountId, "");
|
const prefix = this.key(networkId, "");
|
||||||
const out: string[] = [];
|
const out: string[] = [];
|
||||||
for (const key of this.entries.keys()) {
|
for (const key of this.entries.keys()) {
|
||||||
if (key.startsWith(prefix)) {
|
if (key.startsWith(prefix)) {
|
||||||
@ -106,13 +106,13 @@ export class GatewayRegistry {
|
|||||||
// not_connected / timeout / response ok:false; resolves with the response payload.
|
// not_connected / timeout / response ok:false; resolves with the response payload.
|
||||||
// The deadline is the caller's concern; defaultTimeoutMs is only a safety ceiling.
|
// The deadline is the caller's concern; defaultTimeoutMs is only a safety ceiling.
|
||||||
request(
|
request(
|
||||||
accountId: string,
|
networkId: string,
|
||||||
traposId: string,
|
traposId: string,
|
||||||
type: string,
|
type: string,
|
||||||
payload: Record<string, unknown> = {},
|
payload: Record<string, unknown> = {},
|
||||||
timeoutMs: number = this.defaultTimeoutMs,
|
timeoutMs: number = this.defaultTimeoutMs,
|
||||||
): Promise<Record<string, unknown>> {
|
): Promise<Record<string, unknown>> {
|
||||||
const entry = this.entries.get(this.key(accountId, traposId));
|
const entry = this.entries.get(this.key(networkId, traposId));
|
||||||
if (!entry) {
|
if (!entry) {
|
||||||
return Promise.reject(
|
return Promise.reject(
|
||||||
new GatewayError("not_connected", `no computer registered for ${traposId}`),
|
new GatewayError("not_connected", `no computer registered for ${traposId}`),
|
||||||
@ -132,7 +132,7 @@ export class GatewayRegistry {
|
|||||||
const timer = setTimeout(() => {
|
const timer = setTimeout(() => {
|
||||||
entry.pending.delete(messageId);
|
entry.pending.delete(messageId);
|
||||||
this.log.warn(
|
this.log.warn(
|
||||||
{ accountId, traposId, messageId, type, timeoutMs, durationMs: Date.now() - startedAt, pendingCount: entry.pending.size },
|
{ networkId, traposId, messageId, type, timeoutMs, durationMs: Date.now() - startedAt, pendingCount: entry.pending.size },
|
||||||
"gateway request timed out",
|
"gateway request timed out",
|
||||||
);
|
);
|
||||||
reject(new GatewayError("timeout", `request "${type}" timed out after ${timeoutMs}ms`));
|
reject(new GatewayError("timeout", `request "${type}" timed out after ${timeoutMs}ms`));
|
||||||
@ -144,14 +144,14 @@ export class GatewayRegistry {
|
|||||||
try {
|
try {
|
||||||
entry.socket.send(JSON.stringify(frame));
|
entry.socket.send(JSON.stringify(frame));
|
||||||
this.log.debug(
|
this.log.debug(
|
||||||
{ accountId, traposId, messageId, type, timeoutMs, pendingCount: entry.pending.size },
|
{ networkId, traposId, messageId, type, timeoutMs, pendingCount: entry.pending.size },
|
||||||
"gateway request sent",
|
"gateway request sent",
|
||||||
);
|
);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
clearTimeout(timer);
|
clearTimeout(timer);
|
||||||
entry.pending.delete(messageId);
|
entry.pending.delete(messageId);
|
||||||
this.log.warn(
|
this.log.warn(
|
||||||
{ accountId, traposId, messageId, type, pendingCount: entry.pending.size, err },
|
{ networkId, traposId, messageId, type, pendingCount: entry.pending.size, err },
|
||||||
"gateway request send failed",
|
"gateway request send failed",
|
||||||
);
|
);
|
||||||
reject(new GatewayError("internal", err instanceof Error ? err.message : String(err)));
|
reject(new GatewayError("internal", err instanceof Error ? err.message : String(err)));
|
||||||
@ -161,8 +161,8 @@ export class GatewayRegistry {
|
|||||||
|
|
||||||
// Resolve a pending server_request from a matching computer_response. Silently
|
// Resolve a pending server_request from a matching computer_response. Silently
|
||||||
// dropped (with a debug log) when there is no matching pending.
|
// dropped (with a debug log) when there is no matching pending.
|
||||||
resolveResponse(socket: GatewaySocket, accountId: string, traposId: string, frame: ComputerResponse): void {
|
resolveResponse(socket: GatewaySocket, networkId: string, traposId: string, frame: ComputerResponse): void {
|
||||||
const entry = this.entries.get(this.key(accountId, traposId));
|
const entry = this.entries.get(this.key(networkId, traposId));
|
||||||
if (!entry || entry.socket !== socket) {
|
if (!entry || entry.socket !== socket) {
|
||||||
this.log.debug({ messageId: frame.messageId, reason: "no_pending" }, "no entry for computer_response");
|
this.log.debug({ messageId: frame.messageId, reason: "no_pending" }, "no entry for computer_response");
|
||||||
return;
|
return;
|
||||||
@ -176,7 +176,7 @@ export class GatewayRegistry {
|
|||||||
clearTimeout(pending.timer);
|
clearTimeout(pending.timer);
|
||||||
this.log.debug(
|
this.log.debug(
|
||||||
{
|
{
|
||||||
accountId,
|
networkId,
|
||||||
traposId,
|
traposId,
|
||||||
messageId: frame.messageId,
|
messageId: frame.messageId,
|
||||||
type: pending.type,
|
type: pending.type,
|
||||||
|
|||||||
@ -27,7 +27,7 @@ test("displaced daemon yields instead of reconnect-looping", async () => {
|
|||||||
let second: ReturnType<typeof startCraftos> | undefined;
|
let second: ReturnType<typeof startCraftos> | undefined;
|
||||||
try {
|
try {
|
||||||
await waitFor(() => first.snapshot().includes("__CLOUD_READY__"), 12_000, "first connected");
|
await waitFor(() => first.snapshot().includes("__CLOUD_READY__"), 12_000, "first connected");
|
||||||
await waitFor(() => server.registry.has("local", TRAPOS_ID), 2_000, "first registered server-side");
|
await waitFor(() => server.registry.has("global", TRAPOS_ID), 2_000, "first registered server-side");
|
||||||
|
|
||||||
const secondHandle = startCraftos("gateway-client.lua", {
|
const secondHandle = startCraftos("gateway-client.lua", {
|
||||||
computerId: 9,
|
computerId: 9,
|
||||||
@ -54,7 +54,7 @@ test("displaced daemon yields instead of reconnect-looping", async () => {
|
|||||||
assert.equal(server.registry.count(), 1, "exactly one registration survives");
|
assert.equal(server.registry.count(), 1, "exactly one registration survives");
|
||||||
|
|
||||||
// The surviving (newest) daemon still answers a gateway->computer ping.
|
// The surviving (newest) daemon still answers a gateway->computer ping.
|
||||||
const payload = await server.registry.request("local", TRAPOS_ID, "ping", {}, 2_000);
|
const payload = await server.registry.request("global", TRAPOS_ID, "ping", {}, 2_000);
|
||||||
assert.deepEqual(payload, { traposId: TRAPOS_ID }, "survivor answers reverse ping");
|
assert.deepEqual(payload, { traposId: TRAPOS_ID }, "survivor answers reverse ping");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
|
|||||||
@ -23,7 +23,7 @@ test("duplicate traposId is resolved newest-wins", async () => {
|
|||||||
let second: ReturnType<typeof startCraftos> | undefined;
|
let second: ReturnType<typeof startCraftos> | undefined;
|
||||||
try {
|
try {
|
||||||
await waitFor(() => first.snapshot().includes("__CLOUD_READY__"), 12_000, "first connected");
|
await waitFor(() => first.snapshot().includes("__CLOUD_READY__"), 12_000, "first connected");
|
||||||
await waitFor(() => server.registry.has("local", TRAPOS_ID), 2_000, "first registered server-side");
|
await waitFor(() => server.registry.has("global", TRAPOS_ID), 2_000, "first registered server-side");
|
||||||
|
|
||||||
const secondHandle = startCraftos("gateway-client.lua", {
|
const secondHandle = startCraftos("gateway-client.lua", {
|
||||||
computerId: 9,
|
computerId: 9,
|
||||||
@ -34,11 +34,11 @@ test("duplicate traposId is resolved newest-wins", async () => {
|
|||||||
await waitFor(() => secondHandle.snapshot().includes("__CLOUD_READY__"), 12_000, "second connected");
|
await waitFor(() => secondHandle.snapshot().includes("__CLOUD_READY__"), 12_000, "second connected");
|
||||||
// Newest-wins: still exactly one registration for the shared id.
|
// Newest-wins: still exactly one registration for the shared id.
|
||||||
assert.equal(server.registry.count(), 1, "exactly one registration after the duplicate");
|
assert.equal(server.registry.count(), 1, "exactly one registration after the duplicate");
|
||||||
assert.ok(server.registry.has("local", TRAPOS_ID));
|
assert.ok(server.registry.has("global", TRAPOS_ID));
|
||||||
|
|
||||||
first.abort();
|
first.abort();
|
||||||
await first.done.catch(() => undefined);
|
await first.done.catch(() => undefined);
|
||||||
const payload = await server.registry.request("local", TRAPOS_ID, "ping", {}, 2_000);
|
const payload = await server.registry.request("global", TRAPOS_ID, "ping", {}, 2_000);
|
||||||
assert.deepEqual(payload, { traposId: TRAPOS_ID }, "newest stayed registered after first exited");
|
assert.deepEqual(payload, { traposId: TRAPOS_ID }, "newest stayed registered after first exited");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
|
|||||||
@ -26,7 +26,7 @@ test("MCP exec-lua / run-file / write-file / probe round-trip over the gateway",
|
|||||||
shellArgs: [server.baseUrl, "-"],
|
shellArgs: [server.baseUrl, "-"],
|
||||||
});
|
});
|
||||||
try {
|
try {
|
||||||
await waitFor(() => server.registry.has("local", "7:base"), 12_000, "computer registered");
|
await waitFor(() => server.registry.has("global", "7:base"), 12_000, "computer registered");
|
||||||
|
|
||||||
assert.equal(await callTool(server, "probe-computers"), "ok from 7:base");
|
assert.equal(await callTool(server, "probe-computers"), "ok from 7:base");
|
||||||
|
|
||||||
|
|||||||
@ -12,8 +12,8 @@ test("gateway->computer reverse ping is answered by the client", async () => {
|
|||||||
shellArgs: [server.baseUrl, "-", "idle"],
|
shellArgs: [server.baseUrl, "-", "idle"],
|
||||||
});
|
});
|
||||||
try {
|
try {
|
||||||
await waitFor(() => server.registry.has("local", "7:ping"), 12_000, "computer registered");
|
await waitFor(() => server.registry.has("global", "7:ping"), 12_000, "computer registered");
|
||||||
const payload = await server.registry.request("local", "7:ping", "ping");
|
const payload = await server.registry.request("global", "7:ping", "ping");
|
||||||
assert.deepEqual(payload, { traposId: "7:ping" });
|
assert.deepEqual(payload, { traposId: "7:ping" });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
|
|||||||
@ -1,22 +1,22 @@
|
|||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
import { test } from "node:test";
|
import { test } from "node:test";
|
||||||
import { resolveAccount } from "../src/auth.js";
|
import { resolveNetwork } from "../src/auth.js";
|
||||||
|
|
||||||
test("auth disabled when password unset/empty -> always local", () => {
|
test("auth disabled when password unset/empty -> always local", () => {
|
||||||
assert.equal(resolveAccount(undefined, undefined), "local");
|
assert.equal(resolveNetwork(undefined, undefined), "global");
|
||||||
assert.equal(resolveAccount("anything", undefined), "local");
|
assert.equal(resolveNetwork("anything", undefined), "global");
|
||||||
assert.equal(resolveAccount(undefined, ""), "local");
|
assert.equal(resolveNetwork(undefined, ""), "global");
|
||||||
assert.equal(resolveAccount("anything", ""), "local");
|
assert.equal(resolveNetwork("anything", ""), "global");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("password set: exact match -> local", () => {
|
test("password set: exact match -> local", () => {
|
||||||
assert.equal(resolveAccount("s3cr3t", "s3cr3t"), "local");
|
assert.equal(resolveNetwork("s3cr3t", "s3cr3t"), "global");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("password set: mismatch -> null", () => {
|
test("password set: mismatch -> null", () => {
|
||||||
assert.equal(resolveAccount("wrong", "s3cr3t"), null);
|
assert.equal(resolveNetwork("wrong", "s3cr3t"), null);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("password set: missing secret -> null", () => {
|
test("password set: missing secret -> null", () => {
|
||||||
assert.equal(resolveAccount(undefined, "s3cr3t"), null);
|
assert.equal(resolveNetwork(undefined, "s3cr3t"), null);
|
||||||
});
|
});
|
||||||
|
|||||||
@ -17,7 +17,7 @@ function setup(opts?: { secret?: string; probe?: ProbeResult }) {
|
|||||||
socket,
|
socket,
|
||||||
registry,
|
registry,
|
||||||
probe: fakeProbe(opts?.probe ?? { ok: true }),
|
probe: fakeProbe(opts?.probe ?? { ok: true }),
|
||||||
resolveAccount: (secret) => (expectedSecret === undefined || secret === expectedSecret ? "local" : null),
|
resolveNetwork: (secret) => (expectedSecret === undefined || secret === expectedSecret ? "global" : null),
|
||||||
log: silentLog,
|
log: silentLog,
|
||||||
});
|
});
|
||||||
return { socket, registry, connection };
|
return { socket, registry, connection };
|
||||||
@ -73,15 +73,15 @@ test("hello bad secret -> unauthorized and close", async () => {
|
|||||||
assert.equal(registry.count(), 0);
|
assert.equal(registry.count(), 0);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("hello ok -> registers and replies with accountId", async () => {
|
test("hello ok -> registers and replies with networkId", async () => {
|
||||||
const { socket, registry, connection } = setup();
|
const { socket, registry, connection } = setup();
|
||||||
await connection.onMessage(helloFrame("7:", "anything"));
|
await connection.onMessage(helloFrame("7:", "anything"));
|
||||||
const frame = socket.lastFrame();
|
const frame = socket.lastFrame();
|
||||||
assert.equal(frame.event, "server_response");
|
assert.equal(frame.event, "server_response");
|
||||||
assert.equal(frame.type, "hello");
|
assert.equal(frame.type, "hello");
|
||||||
assert.equal(frame.ok, true);
|
assert.equal(frame.ok, true);
|
||||||
assert.deepEqual(frame.payload, { accountId: "local" });
|
assert.deepEqual(frame.payload, { networkId: "global" });
|
||||||
assert.equal(registry.has("local", "7:"), true);
|
assert.equal(registry.has("global", "7:"), true);
|
||||||
assert.equal(registry.count(), 1);
|
assert.equal(registry.count(), 1);
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -117,8 +117,8 @@ test("post-hello duplicate hello is ignored", async () => {
|
|||||||
const afterFirst = socket.sent.length;
|
const afterFirst = socket.sent.length;
|
||||||
await connection.onMessage(helloFrame("9:"));
|
await connection.onMessage(helloFrame("9:"));
|
||||||
assert.equal(socket.sent.length, afterFirst); // no reply
|
assert.equal(socket.sent.length, afterFirst); // no reply
|
||||||
assert.equal(registry.has("local", "7:"), true);
|
assert.equal(registry.has("global", "7:"), true);
|
||||||
assert.equal(registry.has("local", "9:"), false);
|
assert.equal(registry.has("global", "9:"), false);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("post-hello wrong-direction frame is dropped", async () => {
|
test("post-hello wrong-direction frame is dropped", async () => {
|
||||||
@ -135,7 +135,7 @@ test("reverse direction: gateway request resolved by computer_response", async (
|
|||||||
const { socket, registry, connection } = setup();
|
const { socket, registry, connection } = setup();
|
||||||
await connection.onMessage(helloFrame("7:"));
|
await connection.onMessage(helloFrame("7:"));
|
||||||
|
|
||||||
const promise = registry.request("local", "7:", "ping", {});
|
const promise = registry.request("global", "7:", "ping", {});
|
||||||
const sent = socket.lastFrame();
|
const sent = socket.lastFrame();
|
||||||
assert.equal(sent.event, "server_request");
|
assert.equal(sent.event, "server_request");
|
||||||
assert.equal(sent.type, "ping");
|
assert.equal(sent.type, "ping");
|
||||||
|
|||||||
@ -5,10 +5,10 @@ import type { ComputerResponse } from "../src/modules/trapos-cloud-gateway/proto
|
|||||||
import { handleMcpRequest, type McpDeps } from "../src/modules/mcp/tools.js";
|
import { handleMcpRequest, type McpDeps } from "../src/modules/mcp/tools.js";
|
||||||
import { FakeSocket, silentLog } from "./helpers.js";
|
import { FakeSocket, silentLog } from "./helpers.js";
|
||||||
|
|
||||||
const ACCOUNT = "local";
|
const NETWORK = "global";
|
||||||
|
|
||||||
function makeDeps(registry: GatewayRegistry): McpDeps {
|
function makeDeps(registry: GatewayRegistry): McpDeps {
|
||||||
return { registry, accountId: ACCOUNT, defaultTimeoutMs: 5_000, maxTimeoutMs: 30_000, probeTimeoutMs: 5_000 };
|
return { registry, networkId: NETWORK, defaultTimeoutMs: 5_000, maxTimeoutMs: 30_000, probeTimeoutMs: 5_000 };
|
||||||
}
|
}
|
||||||
|
|
||||||
function rpc(method: string, params?: unknown, id: unknown = 1): Record<string, unknown> {
|
function rpc(method: string, params?: unknown, id: unknown = 1): Record<string, unknown> {
|
||||||
@ -38,7 +38,7 @@ async function callAndAnswer(
|
|||||||
// registry.request sends synchronously; a tick lets the async chain reach the await.
|
// registry.request sends synchronously; a tick lets the async chain reach the await.
|
||||||
await Promise.resolve();
|
await Promise.resolve();
|
||||||
const frame = socket.lastFrame();
|
const frame = socket.lastFrame();
|
||||||
deps.registry.resolveResponse(socket, ACCOUNT, traposId, response(traposId, frame.messageId as string, frame.type as string, ok, payload));
|
deps.registry.resolveResponse(socket, NETWORK, traposId, response(traposId, frame.messageId as string, frame.type as string, ok, payload));
|
||||||
return resultText(await pending);
|
return resultText(await pending);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -72,13 +72,13 @@ test("probe-computers pings each connected computer by traposId", async () => {
|
|||||||
const reg = new GatewayRegistry(silentLog);
|
const reg = new GatewayRegistry(silentLog);
|
||||||
const deps = makeDeps(reg);
|
const deps = makeDeps(reg);
|
||||||
const a = new FakeSocket();
|
const a = new FakeSocket();
|
||||||
reg.register(ACCOUNT, "7:base", a);
|
reg.register(NETWORK, "7:base", a);
|
||||||
|
|
||||||
const pending = handleMcpRequest(rpc("tools/call", { name: "probe-computers" }), deps);
|
const pending = handleMcpRequest(rpc("tools/call", { name: "probe-computers" }), deps);
|
||||||
await Promise.resolve();
|
await Promise.resolve();
|
||||||
const frame = a.lastFrame();
|
const frame = a.lastFrame();
|
||||||
assert.equal(frame.type, "ping");
|
assert.equal(frame.type, "ping");
|
||||||
reg.resolveResponse(a, ACCOUNT, "7:base", response("7:base", frame.messageId as string, "ping", true, { traposId: "7:base" }));
|
reg.resolveResponse(a, NETWORK, "7:base", response("7:base", frame.messageId as string, "ping", true, { traposId: "7:base" }));
|
||||||
assert.equal(resultText(await pending), "ok from 7:base");
|
assert.equal(resultText(await pending), "ok from 7:base");
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -86,7 +86,7 @@ test("exec-lua formats returns and trimmed output on success", async () => {
|
|||||||
const reg = new GatewayRegistry(silentLog);
|
const reg = new GatewayRegistry(silentLog);
|
||||||
const deps = makeDeps(reg);
|
const deps = makeDeps(reg);
|
||||||
const a = new FakeSocket();
|
const a = new FakeSocket();
|
||||||
reg.register(ACCOUNT, "7:base", a);
|
reg.register(NETWORK, "7:base", a);
|
||||||
|
|
||||||
const text = await callAndAnswer(deps, a, "7:base", "exec-lua", { traposId: "7:base", code: "print('hi')" }, true, {
|
const text = await callAndAnswer(deps, a, "7:base", "exec-lua", { traposId: "7:base", code: "print('hi')" }, true, {
|
||||||
ok: true,
|
ok: true,
|
||||||
@ -103,7 +103,7 @@ test("exec-lua surfaces an ok:false result with its captured output", async () =
|
|||||||
const reg = new GatewayRegistry(silentLog);
|
const reg = new GatewayRegistry(silentLog);
|
||||||
const deps = makeDeps(reg);
|
const deps = makeDeps(reg);
|
||||||
const a = new FakeSocket();
|
const a = new FakeSocket();
|
||||||
reg.register(ACCOUNT, "7:base", a);
|
reg.register(NETWORK, "7:base", a);
|
||||||
|
|
||||||
const text = await callAndAnswer(deps, a, "7:base", "exec-lua", { traposId: "7:base", code: "error('boom')" }, true, {
|
const text = await callAndAnswer(deps, a, "7:base", "exec-lua", { traposId: "7:base", code: "error('boom')" }, true, {
|
||||||
ok: false,
|
ok: false,
|
||||||
@ -129,7 +129,7 @@ test("run-file sends the path and formats execution output", async () => {
|
|||||||
const reg = new GatewayRegistry(silentLog);
|
const reg = new GatewayRegistry(silentLog);
|
||||||
const deps = makeDeps(reg);
|
const deps = makeDeps(reg);
|
||||||
const a = new FakeSocket();
|
const a = new FakeSocket();
|
||||||
reg.register(ACCOUNT, "7:base", a);
|
reg.register(NETWORK, "7:base", a);
|
||||||
|
|
||||||
const text = await callAndAnswer(
|
const text = await callAndAnswer(
|
||||||
deps,
|
deps,
|
||||||
@ -154,7 +154,7 @@ test("run-file defaults args to an empty array", async () => {
|
|||||||
const reg = new GatewayRegistry(silentLog);
|
const reg = new GatewayRegistry(silentLog);
|
||||||
const deps = makeDeps(reg);
|
const deps = makeDeps(reg);
|
||||||
const a = new FakeSocket();
|
const a = new FakeSocket();
|
||||||
reg.register(ACCOUNT, "7:base", a);
|
reg.register(NETWORK, "7:base", a);
|
||||||
|
|
||||||
await callAndAnswer(deps, a, "7:base", "run-file", { traposId: "7:base", path: "/hello.lua" }, true, {
|
await callAndAnswer(deps, a, "7:base", "run-file", { traposId: "7:base", path: "/hello.lua" }, true, {
|
||||||
ok: true,
|
ok: true,
|
||||||
@ -179,7 +179,7 @@ test("write-file reports the written path and byte count", async () => {
|
|||||||
const reg = new GatewayRegistry(silentLog);
|
const reg = new GatewayRegistry(silentLog);
|
||||||
const deps = makeDeps(reg);
|
const deps = makeDeps(reg);
|
||||||
const a = new FakeSocket();
|
const a = new FakeSocket();
|
||||||
reg.register(ACCOUNT, "7:base", a);
|
reg.register(NETWORK, "7:base", a);
|
||||||
|
|
||||||
const text = await callAndAnswer(
|
const text = await callAndAnswer(
|
||||||
deps,
|
deps,
|
||||||
@ -205,11 +205,11 @@ test("exec-lua rejects a missing traposId with an invalid-params error", async (
|
|||||||
assert.match(error?.message ?? "", /traposId/);
|
assert.match(error?.message ?? "", /traposId/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("registry.list returns the traposIds registered for an account", () => {
|
test("registry.list returns the traposIds registered for a network", () => {
|
||||||
const reg = new GatewayRegistry(silentLog);
|
const reg = new GatewayRegistry(silentLog);
|
||||||
reg.register(ACCOUNT, "7:base", new FakeSocket());
|
reg.register(NETWORK, "7:base", new FakeSocket());
|
||||||
reg.register(ACCOUNT, "8:turtle", new FakeSocket());
|
reg.register(NETWORK, "8:turtle", new FakeSocket());
|
||||||
reg.register("other", "9:base", new FakeSocket());
|
reg.register("other", "9:base", new FakeSocket());
|
||||||
assert.deepEqual(reg.list(ACCOUNT).sort(), ["7:base", "8:turtle"]);
|
assert.deepEqual(reg.list(NETWORK).sort(), ["7:base", "8:turtle"]);
|
||||||
assert.deepEqual(reg.list("other"), ["9:base"]);
|
assert.deepEqual(reg.list("other"), ["9:base"]);
|
||||||
});
|
});
|
||||||
|
|||||||
@ -15,11 +15,11 @@ function sentMessageId(socket: FakeSocket): string {
|
|||||||
test("register / has / count / unregister", () => {
|
test("register / has / count / unregister", () => {
|
||||||
const reg = new GatewayRegistry(silentLog);
|
const reg = new GatewayRegistry(silentLog);
|
||||||
const a = new FakeSocket();
|
const a = new FakeSocket();
|
||||||
reg.register("local", "7:", a);
|
reg.register("global", "7:", a);
|
||||||
assert.equal(reg.has("local", "7:"), true);
|
assert.equal(reg.has("global", "7:"), true);
|
||||||
assert.equal(reg.count(), 1);
|
assert.equal(reg.count(), 1);
|
||||||
reg.unregister("local", "7:", a);
|
reg.unregister("global", "7:", a);
|
||||||
assert.equal(reg.has("local", "7:"), false);
|
assert.equal(reg.has("global", "7:"), false);
|
||||||
assert.equal(reg.count(), 0);
|
assert.equal(reg.count(), 0);
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -27,8 +27,8 @@ test("newest-wins evicts previous socket for same key", () => {
|
|||||||
const reg = new GatewayRegistry(silentLog);
|
const reg = new GatewayRegistry(silentLog);
|
||||||
const a = new FakeSocket();
|
const a = new FakeSocket();
|
||||||
const b = new FakeSocket();
|
const b = new FakeSocket();
|
||||||
reg.register("local", "7:", a);
|
reg.register("global", "7:", a);
|
||||||
reg.register("local", "7:", b);
|
reg.register("global", "7:", b);
|
||||||
assert.equal(a.closed, true);
|
assert.equal(a.closed, true);
|
||||||
// Distinct close code lets the displaced Lua client yield instead of reconnect-looping.
|
// Distinct close code lets the displaced Lua client yield instead of reconnect-looping.
|
||||||
assert.equal(a.closeCode, DISPLACED_CLOSE_CODE);
|
assert.equal(a.closeCode, DISPLACED_CLOSE_CODE);
|
||||||
@ -36,9 +36,9 @@ test("newest-wins evicts previous socket for same key", () => {
|
|||||||
assert.equal(reg.count(), 1);
|
assert.equal(reg.count(), 1);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("same traposId under different accounts coexist", () => {
|
test("same traposId under different networks coexist", () => {
|
||||||
const reg = new GatewayRegistry(silentLog);
|
const reg = new GatewayRegistry(silentLog);
|
||||||
reg.register("local", "7:", new FakeSocket());
|
reg.register("global", "7:", new FakeSocket());
|
||||||
reg.register("other", "7:", new FakeSocket());
|
reg.register("other", "7:", new FakeSocket());
|
||||||
assert.equal(reg.count(), 2);
|
assert.equal(reg.count(), 2);
|
||||||
});
|
});
|
||||||
@ -46,25 +46,25 @@ test("same traposId under different accounts coexist", () => {
|
|||||||
test("request sends a server_request frame and resolves on matching ok response", async () => {
|
test("request sends a server_request frame and resolves on matching ok response", async () => {
|
||||||
const reg = new GatewayRegistry(silentLog);
|
const reg = new GatewayRegistry(silentLog);
|
||||||
const a = new FakeSocket();
|
const a = new FakeSocket();
|
||||||
reg.register("local", "7:", a);
|
reg.register("global", "7:", a);
|
||||||
|
|
||||||
const promise = reg.request("local", "7:", "ping", {});
|
const promise = reg.request("global", "7:", "ping", {});
|
||||||
const frame = a.lastFrame();
|
const frame = a.lastFrame();
|
||||||
assert.equal(frame.event, "server_request");
|
assert.equal(frame.event, "server_request");
|
||||||
assert.equal(frame.type, "ping");
|
assert.equal(frame.type, "ping");
|
||||||
assert.equal(frame.traposReceiverId, "7:");
|
assert.equal(frame.traposReceiverId, "7:");
|
||||||
|
|
||||||
reg.resolveResponse(a, "local", "7:", response(frame.messageId as string, true, { traposId: "7:" }));
|
reg.resolveResponse(a, "global", "7:", response(frame.messageId as string, true, { traposId: "7:" }));
|
||||||
assert.deepEqual(await promise, { traposId: "7:" });
|
assert.deepEqual(await promise, { traposId: "7:" });
|
||||||
});
|
});
|
||||||
|
|
||||||
test("request rejects with the response error code on ok:false", async () => {
|
test("request rejects with the response error code on ok:false", async () => {
|
||||||
const reg = new GatewayRegistry(silentLog);
|
const reg = new GatewayRegistry(silentLog);
|
||||||
const a = new FakeSocket();
|
const a = new FakeSocket();
|
||||||
reg.register("local", "7:", a);
|
reg.register("global", "7:", a);
|
||||||
|
|
||||||
const promise = reg.request("local", "7:", "ping", {});
|
const promise = reg.request("global", "7:", "ping", {});
|
||||||
reg.resolveResponse(a, "local", "7:", response(sentMessageId(a), false, {}, { code: "unknown_type", message: "nope" }));
|
reg.resolveResponse(a, "global", "7:", response(sentMessageId(a), false, {}, { code: "unknown_type", message: "nope" }));
|
||||||
|
|
||||||
await assert.rejects(promise, (err: unknown) => err instanceof GatewayError && err.code === "unknown_type");
|
await assert.rejects(promise, (err: unknown) => err instanceof GatewayError && err.code === "unknown_type");
|
||||||
});
|
});
|
||||||
@ -72,16 +72,16 @@ test("request rejects with the response error code on ok:false", async () => {
|
|||||||
test("request rejects not_connected when no socket registered", async () => {
|
test("request rejects not_connected when no socket registered", async () => {
|
||||||
const reg = new GatewayRegistry(silentLog);
|
const reg = new GatewayRegistry(silentLog);
|
||||||
await assert.rejects(
|
await assert.rejects(
|
||||||
reg.request("local", "nobody", "ping", {}),
|
reg.request("global", "nobody", "ping", {}),
|
||||||
(err: unknown) => err instanceof GatewayError && err.code === "not_connected",
|
(err: unknown) => err instanceof GatewayError && err.code === "not_connected",
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("request rejects timeout after the deadline", async () => {
|
test("request rejects timeout after the deadline", async () => {
|
||||||
const reg = new GatewayRegistry(silentLog);
|
const reg = new GatewayRegistry(silentLog);
|
||||||
reg.register("local", "7:", new FakeSocket());
|
reg.register("global", "7:", new FakeSocket());
|
||||||
await assert.rejects(
|
await assert.rejects(
|
||||||
reg.request("local", "7:", "ping", {}, 10),
|
reg.request("global", "7:", "ping", {}, 10),
|
||||||
(err: unknown) => err instanceof GatewayError && err.code === "timeout",
|
(err: unknown) => err instanceof GatewayError && err.code === "timeout",
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@ -89,17 +89,17 @@ test("request rejects timeout after the deadline", async () => {
|
|||||||
test("messageId correlation: concurrent requests resolve independently", async () => {
|
test("messageId correlation: concurrent requests resolve independently", async () => {
|
||||||
const reg = new GatewayRegistry(silentLog);
|
const reg = new GatewayRegistry(silentLog);
|
||||||
const a = new FakeSocket();
|
const a = new FakeSocket();
|
||||||
reg.register("local", "7:", a);
|
reg.register("global", "7:", a);
|
||||||
|
|
||||||
const p1 = reg.request("local", "7:", "ping", { n: 1 });
|
const p1 = reg.request("global", "7:", "ping", { n: 1 });
|
||||||
const id1 = a.lastFrame().messageId as string;
|
const id1 = a.lastFrame().messageId as string;
|
||||||
const p2 = reg.request("local", "7:", "ping", { n: 2 });
|
const p2 = reg.request("global", "7:", "ping", { n: 2 });
|
||||||
const id2 = a.lastFrame().messageId as string;
|
const id2 = a.lastFrame().messageId as string;
|
||||||
assert.notEqual(id1, id2);
|
assert.notEqual(id1, id2);
|
||||||
|
|
||||||
// resolve out of order
|
// resolve out of order
|
||||||
reg.resolveResponse(a, "local", "7:", response(id2, true, { n: 2 }));
|
reg.resolveResponse(a, "global", "7:", response(id2, true, { n: 2 }));
|
||||||
reg.resolveResponse(a, "local", "7:", response(id1, true, { n: 1 }));
|
reg.resolveResponse(a, "global", "7:", response(id1, true, { n: 1 }));
|
||||||
assert.deepEqual(await p1, { n: 1 });
|
assert.deepEqual(await p1, { n: 1 });
|
||||||
assert.deepEqual(await p2, { n: 2 });
|
assert.deepEqual(await p2, { n: 2 });
|
||||||
});
|
});
|
||||||
@ -107,9 +107,9 @@ test("messageId correlation: concurrent requests resolve independently", async (
|
|||||||
test("pending rejected on unregister (close)", async () => {
|
test("pending rejected on unregister (close)", async () => {
|
||||||
const reg = new GatewayRegistry(silentLog);
|
const reg = new GatewayRegistry(silentLog);
|
||||||
const a = new FakeSocket();
|
const a = new FakeSocket();
|
||||||
reg.register("local", "7:", a);
|
reg.register("global", "7:", a);
|
||||||
const promise = reg.request("local", "7:", "ping", {});
|
const promise = reg.request("global", "7:", "ping", {});
|
||||||
reg.unregister("local", "7:", a);
|
reg.unregister("global", "7:", a);
|
||||||
await assert.rejects(promise, (err: unknown) => err instanceof GatewayError && err.code === "not_connected");
|
await assert.rejects(promise, (err: unknown) => err instanceof GatewayError && err.code === "not_connected");
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -117,21 +117,21 @@ test("eviction: old socket's pending rejected and late unregister(A) does not ev
|
|||||||
const reg = new GatewayRegistry(silentLog);
|
const reg = new GatewayRegistry(silentLog);
|
||||||
const a = new FakeSocket();
|
const a = new FakeSocket();
|
||||||
const b = new FakeSocket();
|
const b = new FakeSocket();
|
||||||
reg.register("local", "7:", a);
|
reg.register("global", "7:", a);
|
||||||
const promise = reg.request("local", "7:", "ping", {});
|
const promise = reg.request("global", "7:", "ping", {});
|
||||||
|
|
||||||
reg.register("local", "7:", b); // evict A
|
reg.register("global", "7:", b); // evict A
|
||||||
await assert.rejects(promise, (err: unknown) => err instanceof GatewayError && err.code === "not_connected");
|
await assert.rejects(promise, (err: unknown) => err instanceof GatewayError && err.code === "not_connected");
|
||||||
|
|
||||||
reg.unregister("local", "7:", a); // late close from evicted A — must be a no-op
|
reg.unregister("global", "7:", a); // late close from evicted A — must be a no-op
|
||||||
assert.equal(reg.has("local", "7:"), true);
|
assert.equal(reg.has("global", "7:"), true);
|
||||||
assert.equal(reg.count(), 1);
|
assert.equal(reg.count(), 1);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("resolveResponse with no matching pending is a silent no-op", () => {
|
test("resolveResponse with no matching pending is a silent no-op", () => {
|
||||||
const reg = new GatewayRegistry(silentLog);
|
const reg = new GatewayRegistry(silentLog);
|
||||||
const a = new FakeSocket();
|
const a = new FakeSocket();
|
||||||
reg.register("local", "7:", a);
|
reg.register("global", "7:", a);
|
||||||
// should not throw
|
// should not throw
|
||||||
reg.resolveResponse(a, "local", "7:", response("unknown-id", true, {}));
|
reg.resolveResponse(a, "global", "7:", response("unknown-id", true, {}));
|
||||||
});
|
});
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user