Skip to content

Commit b1dc339

Browse files
FUDCoclaude
andauthored
feat: IOListener with accept(), plus anonymous kernel objects and a run-queue cache fix (#1007)
Extracts the kernel-side work developed on `chip/orchestration-demo` into `main`. No demo code is included — every change here is general-purpose kernel machinery. Each commit is independently meaningful and reviewable in order. ## Why A vat needed to serve a line-delimited JSON-RPC socket to more than one local client at a time. It couldn't: `IOChannel` models exactly one bidirectional stream, so the socket server destroyed every connection after the first. Chasing that surfaced two further bugs, one of them a latent kernel defect with a genuinely nasty failure mode. ## Commits **`fix(ocap-kernel): honor the run-queue length cache's invalid sentinel`** `runQueueLengthCache` uses a negative value to mean "unknown, re-read from the database", but `enqueueRun`/`dequeueRun` adjusted it arithmetically without materializing it first. An enqueue while the cache held its startup value of `-1` produced `0` for a queue that actually held an item — and because `0` isn't negative, it was never re-read. The run loop then saw an empty queue, went to sleep, and stranded everything queued behind it. **No error, no log, no crash: the kernel just silently stops delivering.** The run loop is also now woken by any non-empty queue rather than only the empty→1 transition, so a drifted count can't lose the wakeup either. Latent for a long time — reachable only when something enqueues before the run loop's first length read. Worth reviewing on its own merits regardless of the rest. **`feat(ocap-kernel): anonymous kernel-hosted objects`** `registerAnonymousKernelObject()` / `releaseAnonymousKernelObject()`: allocate a kref and enter the object in the by-kref routing table, but deliberately *not* in the service-name index. The object therefore has no name in the global service namespace and cannot be requested via a cluster config's `services` list — authority comes from holding the reference. Needed by `accept()`, and the naming half is the part we specifically didn't want: a per-session connection should be reachable by reference only. **`feat(ocap-kernel): IOListener with accept(), replacing single-client channels`** The BSD listen/accept split. A cluster config's `io` entry now creates a listener; `accept()` yields one `IOChannel` per peer, each wrapped in its own exo and hosted as an anonymous kernel object, so the vat receives a Presence per connection. Isolation is structural rather than by discipline: sessions are separate objects, so holding one connection conveys no way to reach another. That matters because the names a vat hands across a non-ocap boundary are plain forgeable strings; scoping them per connection is what stops one client naming another's references. `direction` moves to the connection, where the data actually flows. `accept()` resolves `null` once the listener closes, so an accept loop terminates instead of hanging. **`feat(kernel-node-runtime): socket listener with per-connection channels`** `makeSocketIOChannel` → `makeSocketIOListener`. Each connection's buffer, decoder, line queue, and reader queue are local to it, which is precisely why many peers can now be served at once. Connections arriving before `accept()` are queued rather than dropped. Deleted with the single-client design: `currentSocket`, `pendingSessionEnd`, the merged line queue, and the `socket.destroy()` that rejected second connections. The session-boundary latch didn't need replacing — one channel serves one peer, so the end of the socket simply *is* the end of the channel. **`test(kernel-test): io-vat accepts connections; cover two concurrent peers`** The integration test drops its hand-rolled duplicate channel in favour of the real `makeIOListenerFactory`, and adds a case driving two concurrent peers end to end through a real kernel, asserting neither reads the other's data nor receives the other's writes. That case was unrepresentable before — the second connection was destroyed on arrival. **`feat(kernel-utils,service-discovery-types): interface variant for JsonSchema`** `{ type: 'interface', description?, methods }` describes an object whose methods can be invoked, so a method returning an object reference can declare that object's API inline instead of forcing a second round-trip. `methods` is recursive. The variant describes an *interface*; whether the reference is unforgeable is a property of the reference plumbing, not the description, so one schema serves both cases. `service-discovery-types` converts it to a `RemotableSpec`, which means `remotable` is no longer among the kinds `JsonSchema` can't express. ## Renamed API surface Nothing in this repository is left broken — every in-tree consumer is updated in this PR, and the full suite passes. These renames are flagged `**BREAKING:**` in the changelogs because the packages are published and the exported surface changed, so release tooling and any external consumer need the signal: | Was | Now | |---|---| | `Kernel.make({ ioChannelFactory })` | `Kernel.make({ ioListenerFactory })` | | `IOChannelFactory` | `IOListener` / `IOListenerFactory` | | `makeIOChannelFactory()` | `makeIOListenerFactory()` | | `makeSocketIOChannel()` | `makeSocketIOListener()` | The behavioural change behind the renames: a vat that previously read and wrote an `io` endowment directly now calls `accept()` to obtain a connection first. `IOChannel` itself is unchanged and still represents exactly one connection. ## Validation Full monorepo on this exact tree: **30/30 builds, 52/52 test tasks, lint clean.** Files that `main` also changed since the branch point were three-way merged and individually diffed against `main` to confirm nothing of `main`'s was reverted — in particular `main`'s #958 optional-parameter handling in `methodSchemaToMethodSpec` is preserved. The whole stack has also been exercised live: two independent clients holding concurrent connections to one vat, each with its own isolated name table, driving a multi-service workflow end to end. 🤖 Generated with [Claude Code](https://claude.com/claude-code) <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **High Risk** > Touches core kernel IO, service routing, and run-queue delivery with breaking public API renames; incorrect behavior could strand the run loop or mishandle concurrent RPC sessions. > > **Overview** > Replaces single-client Unix-socket IO with a **listen/accept** model so vats can serve many concurrent line-delimited peers. Cluster `io` entries now create **`IOListener`** services; vats call **`accept()`** to get a per-peer **`IOChannel`** (Presence), with **`direction`** enforced on each connection. > > **Breaking renames:** `ioChannelFactory` → `ioListenerFactory`, `makeIOChannelFactory` / `makeSocketIOChannel` → `makeIOListenerFactory` / `makeSocketIOListener`. Node runtime gives each connection its own buffer/decoder/queues; early connects are queued instead of dropped. > > Kernel adds **anonymous kernel objects** (`registerAnonymousKernelObject` / release + init sweep) so accepted connections are routable by kref but not by global service name. **`invokeKernelService`** rejects missing services with **`ENDPOINT_UNREACHABLE`** instead of throwing (avoids killing the run loop on stale IO refs after restart). > > Fixes a **run-queue length cache** bug: enqueue/dequeue now materialize the `-1` sentinel before arithmetic, and the run loop wakes on any non-empty queue. **`JsonSchema`** gains an **`interface`** variant (recursive methods) for inline return-type APIs; service-discovery converts it to **`RemotableSpec`**. > > <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit f7570df. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot).</sup> <!-- /CURSOR_SUMMARY --> --------- Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
1 parent 6cc875c commit b1dc339

35 files changed

Lines changed: 2332 additions & 863 deletions

packages/kernel-node-runtime/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1313

1414
### Changed
1515

16+
- **BREAKING:** `makeIOChannelFactory` is now `makeIOListenerFactory`, and `makeSocketIOChannel` is now `makeSocketIOListener`. The Unix-socket server hands each connection to `accept()` as its own `IOChannel`, whose receive buffer, decoder, line queue, and reader queue are local to that connection, so any number of peers can be served concurrently. Connections arriving before `accept()` is called are queued rather than dropped. Gone with the single-client design: the shared `currentSocket`, the session-boundary latch, the merged line queue, and the `socket.destroy()` that rejected every second connection ([#1007](https://github.com/MetaMask/ocap-kernel/pull/1007))
1617
- **BREAKING:** Drop `platformOptions.fetch` from `makeNodeJsVatSupervisor` ([#942](https://github.com/MetaMask/ocap-kernel/pull/942))
1718
- `fetch` is now a vat endowment; stub `globalThis.fetch` directly if needed
1819

packages/kernel-node-runtime/src/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,4 @@ export { NodejsPlatformServices } from './kernel/PlatformServices.ts';
22
export { makeKernel } from './kernel/make-kernel.ts';
33
export type { MakeKernelResult } from './kernel/make-kernel.ts';
44
export { makeNodeJsVatSupervisor } from './vat/make-supervisor.ts';
5-
export { makeIOChannelFactory, makeSocketIOChannel } from './io/index.ts';
5+
export { makeIOListenerFactory, makeSocketIOListener } from './io/index.ts';

packages/kernel-node-runtime/src/io/index.ts

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,23 @@
1-
import type { IOChannelFactory, IOConfig } from '@metamask/ocap-kernel';
1+
import type { IOListenerFactory, IOConfig } from '@metamask/ocap-kernel';
22

3-
import { makeSocketIOChannel } from './socket-channel.ts';
3+
import { makeSocketIOListener } from './socket-listener.ts';
44

5-
export { makeSocketIOChannel } from './socket-channel.ts';
5+
export { makeSocketIOListener } from './socket-listener.ts';
66

77
/**
8-
* Create an IOChannelFactory for the Node.js environment.
9-
* Dispatches on `config.type` to the appropriate channel implementation.
8+
* Create an IOListenerFactory for the Node.js environment.
9+
* Dispatches on `config.type` to the appropriate listener implementation.
1010
*
11-
* @returns An IOChannelFactory.
11+
* @returns An IOListenerFactory.
1212
*/
13-
export function makeIOChannelFactory(): IOChannelFactory {
13+
export function makeIOListenerFactory(): IOListenerFactory {
1414
return async (name: string, config: IOConfig) => {
1515
switch (config.type) {
1616
case 'socket':
17-
return makeSocketIOChannel(name, config.path);
17+
return makeSocketIOListener(name, config.path);
1818
default:
1919
throw new Error(
20-
`Unsupported IO channel type "${config.type}" for channel "${name}"`,
20+
`Unsupported IO listener type "${config.type}" for listener "${name}"`,
2121
);
2222
}
2323
};

packages/kernel-node-runtime/src/io/socket-channel.test.ts

Lines changed: 0 additions & 301 deletions
This file was deleted.

0 commit comments

Comments
 (0)