Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .changeset/ocapn-op-flush.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
'@endo/ocapn': minor
---

- Add the OCapN `op:flush` and `op:flush-done` operations and wire them into the message dispatcher.
- On receiving `op:flush` for an exported promise position, the receiver mints a fresh local promise, swaps it in at the same export position (preserving the slot's refcount) and replies with `op:flush-done`. Subsequent deliveries that target the position queue on the new promise so per-reference FIFO order is preserved during promise shortening.
- Expose `_debug.flushExport(remoteValue)` to send `op:flush` and obtain a promise that resolves when `op:flush-done` is received.
- Add `replaceExportValue` on the pairwise table to support in-place value replacement under flush.
8 changes: 8 additions & 0 deletions .changeset/ocapn-simulator-deposit-gift-promise.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
'@endo/ocapn': minor
'@endo/ocapn-simulator': minor
---

- Add a new private `@endo/ocapn-simulator` package: a browser-based playground that runs N OCapN clients in web workers, brokered by a `MessagePort`-based netlayer with simulated latency. Useful for hands-on experimentation with the `op:flush` / `op:flush-done` proposal.
- Re-export `encodeSwissnum` and `locationToLocationId` from `@endo/ocapn` so external clients (e.g. the simulator) can build sturdyrefs and refer to peer sessions without reaching into internal subpaths.
- Allow `bootstrap.deposit-gift` to accept a promise as the gift value, in addition to a remotable. This unblocks third-party promise handoffs of unresolved answer-promises across the network — the natural shape of forwarder chains that return `[answerPromise]` to keep the chain visible as a `desc:import-promise`. Previously such gifts rejected with "Gift must be remotable" the first time a hop tried to ship its answer to a third party.
16 changes: 16 additions & 0 deletions packages/ocapn-simulator/.eslintrc.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
// The simulator is a browser/worker-only experimental package; the
// strict SES library lint preset doesn't apply. We use a minimal config
// that knows about browser and worker globals.
module.exports = {
root: true,
env: {
browser: true,
worker: true,
es2022: true,
},
parserOptions: {
ecmaVersion: 2022,
sourceType: 'module',
},
rules: {},
};
113 changes: 113 additions & 0 deletions packages/ocapn-simulator/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
# @endo/ocapn-simulator

A browser-based playground for experimenting with promise-shortening
scenarios on top of `@endo/ocapn`. Each "client" runs in its own web
worker, hosting a real `@endo/ocapn` client wired to a custom
`MessagePort`-based netlayer with simulated latency.

This package is **private** to the workspace and is not published to
npm. It exists to make the `op:flush` / `op:flush-done` proposal
concrete: you can watch the export tables, kick off forwarder chains,
and manually trigger flushes that mirror what an automatic shortening
implementation would do.

## Quick start

From the workspace root:

```sh
yarn install
yarn workspace @endo/ocapn-simulator dev
```

Vite will print a local URL (typically `http://localhost:5173`).

To produce a production bundle:

```sh
yarn workspace @endo/ocapn-simulator build:bundle
```

The package's plain `build` script is intentionally a no-op (Vite 7
requires Node 20.19+; the workspace-wide `yarn build` in CI runs on
Node 18 too).

## Layout

```
packages/ocapn-simulator/
index.html
package.json
vite.config.js
src/
main.js page bootstrap
sim-controller.js worker pool, viz state, UI wiring
bridge.js brokers MessagePort pairs between workers
visualization.js SVG rendering of clients/sessions/flow
sim-netlayer.js custom netlayer (transport: 'simworker')
worker.js per-worker entry: SES, ocapn client, Forwarder
styles.css
shims/
node-crypto.js browser shim for `node:crypto`
```

## Wire protocol with the worker

Each worker hosts one `@endo/ocapn` client. The main thread brokers
`MessagePort` pairs in response to the worker's outgoing connections:

```
worker → main : { type: 'sim/connect', toDesignator }
main → worker (peer A) : { type: 'sim/outgoing-port', toDesignator, port }
main → worker (peer B) : { type: 'sim/incoming-port', peerDesignator, port }
```

After that, the two ports carry application bytes
(`{ kind: 'data', bytes }`) directly, with each side's netlayer
applying the user-configured per-write latency.

## Visualization

- One circle per worker, labelled with the first hex bytes of its
designator.
- A solid edge between two circles means an active OCapN session
(the workers have completed handshake; the export tables on each
side hold at least the bootstrap plus one additional reference once
any forward traffic begins).
- Dashed = idle session, solid blue = recent forward traffic.
- The blue ring on a node = currently busy serving or initiating
a `forward(N)`.
- Small dots animate from sender to receiver each time a `forward`
call is dispatched.

## Controls

- **Restart** — tear down all workers and bring up a fresh ring.
- **Kick off random chain** — pick a random worker and call its
local `forward(<length>)`.
- **Flush a random imported promise** — pick a random worker that
has at least one `desc:import-promise` open and invoke the debug
`flushExport()` on it. The exporter swaps in a fresh local promise
at that slot and replies with `op:flush-done`. This is the same
primitive that a future shortening implementation would invoke
automatically as part of a 3PH; here it's exposed manually so you
can watch the table replacement happen.

## Browser compatibility

`@endo/ocapn`'s cryptography module imports `randomBytes` from
`node:crypto`. The simulator aliases that to `src/shims/node-crypto.js`
through Vite's `resolve.alias` so the package can run unmodified in the
browser.

## Known limitations

- The `flushExport` API is part of the prototype branch's debug
surface; the simulator probes positions `p-0` through `p-63` to find
candidates rather than iterating the export table, since the public
`OcapnTable` doesn't expose iteration.
- The "exports beyond bootstrap" indicator is approximated from the
active-session reports a worker emits on a 500 ms timer; visually
it lags real-time table changes.
- This is a prototype to make the proposal concrete; it is not a
conformance test for the spec.
108 changes: 108 additions & 0 deletions packages/ocapn-simulator/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>OCapN promise-shortening simulator</title>
<link rel="stylesheet" href="/src/styles.css" />
</head>
<body>
<header>
<h1>OCapN promise-shortening simulator</h1>
<p class="subtitle">
Browser-only experiment. N web workers each host an
<code>@endo/ocapn</code> client. They reach each other through a
custom MessagePort netlayer with simulated latency.
</p>
</header>

<div class="app-main">
<section class="controls">
<fieldset>
<legend>Topology</legend>
<label>
Clients
<input id="control-client-count" type="number" min="2" max="12" value="8" />
</label>
<label>
Latency (ms)
<input id="control-latency" type="number" min="0" max="2000" value="500" />
</label>
<label class="toggle-inline">
<input id="control-enable-flush" type="checkbox" checked />
Enable op:flush
</label>
</fieldset>
<fieldset>
<legend>Forward chain</legend>
<label>
Length
<input id="control-chain-length" type="number" min="1" max="20" value="4" />
</label>
<label class="toggle-inline">
<input id="control-chain-unique-in-path" type="checkbox" checked />
No repeat in chain (each node at most once)
</label>
</fieldset>
<div class="controls-run">
<button id="control-restart" type="button">Restart</button>
</div>
</section>

<section class="viz">
<fieldset class="graph-toggles">
<legend>Graph messages</legend>
<label class="toggle-inline viz-key-forward">
<input type="checkbox" id="toggle-viz-forward" checked />
Forward
</label>
<label class="toggle-inline viz-key-noop">
<input type="checkbox" id="toggle-viz-noop" checked />
Noops
</label>
<label
class="toggle-inline viz-key-flush"
title="op:flush / op:flush-done"
>
<input type="checkbox" id="toggle-viz-flush" checked />
Flush
</label>
<label
class="toggle-inline viz-key-handoff"
title="CapTP gift handoff: deposit-gift, withdraw-gift"
>
<input type="checkbox" id="toggle-viz-handoff" checked />
Handoff
</label>
<label
class="toggle-inline viz-key-handshake"
title="Session setup only: op:start-session (not gift handoff)"
>
<input type="checkbox" id="toggle-viz-handshake" checked />
Handshake
</label>
<label class="toggle-inline viz-key-abort" title="op:abort">
<input type="checkbox" id="toggle-viz-abort" checked />
Abort
</label>
</fieldset>
<svg id="viz-svg" viewBox="-300 -300 600 600" preserveAspectRatio="xMidYMid meet"></svg>
</section>

<section class="logs-panel" id="logs-panel">
<div class="log-split">
<div class="log-column">
<h2>Event log</h2>
<div id="log-events" class="log-view" aria-live="polite"></div>
</div>
<div class="log-column">
<h2>Active sessions</h2>
<pre id="log-sessions" class="log-view log-view-mono"></pre>
</div>
</div>
</section>
</div>

<script type="module" src="/src/main.js"></script>
</body>
</html>
53 changes: 53 additions & 0 deletions packages/ocapn-simulator/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
{
"name": "@endo/ocapn-simulator",
"version": "0.1.0",
"private": true,
"description": "Browser-based simulator for OCapN promise-shortening experiments using @endo/ocapn from the op:flush branch.",
"keywords": [
"ocapn",
"captp",
"endo",
"simulator",
"promise-shortening"
],
"author": "Endo contributors",
"license": "Apache-2.0",
"homepage": "https://github.com/endojs/endo/tree/master/packages/ocapn-simulator#readme",
"repository": {
"type": "git",
"url": "git+https://github.com/endojs/endo.git",
"directory": "packages/ocapn-simulator"
},
"bugs": {
"url": "https://github.com/endojs/endo/issues"
},
"type": "module",
"scripts": {
"build": "exit 0",
"build:bundle": "vite build",
"dev": "vite",
"start": "vite",
"preview": "vite preview",
"lint": "yarn lint:eslint",
"lint:eslint": "eslint '**/*.js'",
"lint-fix": "yarn lint:eslint --fix",
"test": "exit 0",
"test:c8": "exit 0",
"test:xs": "exit 0"
},
"dependencies": {
"@endo/eventual-send": "workspace:^",
"@endo/harden": "workspace:^",
"@endo/init": "workspace:^",
"@endo/marshal": "workspace:^",
"@endo/ocapn": "workspace:^",
"@endo/pass-style": "workspace:^"
},
"devDependencies": {
"eslint": "catalog:dev",
"vite": "^7.1.0"
},
"publishConfig": {
"access": "public"
}
}
83 changes: 83 additions & 0 deletions packages/ocapn-simulator/src/bridge.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
// Main-thread bridge: brokers MessagePort pairs between workers in
// response to their `sim/connect` requests.
//
// One Bridge instance per simulation. The controller adds workers to
// the bridge with their stable designator; the bridge wires each
// worker's outgoing-message stream to a single dispatch handler.

export class Bridge {
constructor() {
/** @type {Map<string, Worker>} */
this.workers = new Map(); // designator -> Worker
/** @type {(msg: any) => void} */
this.onUnknownMessage = () => {};
}

/**
* @param {string} designator
* @param {Worker} worker
*/
add(designator, worker) {
this.workers.set(designator, worker);
worker.addEventListener('message', ev => {
const msg = ev.data;
if (!msg || typeof msg.type !== 'string') return;
if (msg.type === 'sim/connect') {
this.broker(designator, msg.toDesignator);
} else {
this.onUnknownMessage({ from: designator, msg });
}
});
}

/**
* Create a MessageChannel and hand each end to the appropriate
* worker. The requester gets `sim/outgoing-port`; the responder gets
* `sim/incoming-port`.
*
* @param {string} fromDesignator
* @param {string} toDesignator
*/
broker(fromDesignator, toDesignator) {
const fromWorker = this.workers.get(fromDesignator);
const toWorker = this.workers.get(toDesignator);
if (!fromWorker) return;
if (!toWorker) {
fromWorker.postMessage({
type: 'sim/connect-failed',
toDesignator,
reason: `no such peer ${toDesignator}`,
});
return;
}
const channel = new MessageChannel();
fromWorker.postMessage(
{
type: 'sim/outgoing-port',
toDesignator,
port: channel.port1,
},
[channel.port1],
);
toWorker.postMessage(
{
type: 'sim/incoming-port',
peerDesignator: fromDesignator,
port: channel.port2,
},
[channel.port2],
);
}

shutdown() {
for (const worker of this.workers.values()) {
try {
worker.postMessage({ type: 'sim/shutdown' });
} catch {}
try {
worker.terminate();
} catch {}
}
this.workers.clear();
}
}
Loading
Loading