Skip to content

feat(ocapn-simulator): browser playground for op:flush experiments#3250

Draft
kumavis wants to merge 3 commits into
claude/implement-ocapn-flush-iisn9from
claude/add-ocapn-package-TgC29
Draft

feat(ocapn-simulator): browser playground for op:flush experiments#3250
kumavis wants to merge 3 commits into
claude/implement-ocapn-flush-iisn9from
claude/add-ocapn-package-TgC29

Conversation

@kumavis

@kumavis kumavis commented May 9, 2026

Copy link
Copy Markdown
Member

Summary

Adds a private workspace package @endo/ocapn-simulator: a Vite-built
browser playground that runs N OCapN clients in web workers, brokered
by a custom MessagePort netlayer with simulated latency. The intent
is to make the op:flush / op:flush-done proposal concrete enough
to poke at by hand — kick off forwarder chains, observe the export
tables fill with desc:import-promise slots, and trigger flushes that
mirror what an automatic shortening implementation would do as part of
a 3PH.

This was originally prototyped on
kumavis/ocapn#7, where the
simulator vendored the upstream package source and patched it at fetch
time. This PR brings the simulator into the monorepo so it imports
@endo/ocapn (workspace:^) directly. Two of the three upstream
patches are folded into the package itself; the third (node:crypto
→ WebCrypto) is handled in the simulator's Vite config without
modifying source.

What's in the simulator

packages/ocapn-simulator/
  index.html
  vite.config.js          aliases node:crypto → src/shims/node-crypto.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: nodes, session edges, flow pulses
    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.randomBytes

Each worker hosts one @endo/ocapn client and exposes a single sturdy
ref forward. forward(N) sleeps a random fraction of a second,
picks a random peer, sleeps again, calls that peer's forward(N - 1),
and returns harden([answerPromise]). The array wrap is the same
trick used in flush.test.js — a bare returned promise gets awaited
and serialized as the settled value, collapsing the chain. Wrapping it
forces the marshal layer to emit it as desc:import-promise so the
chain stays observable.

Wire protocol with the worker

worker → main : { type: 'sim/connect', toDesignator }
main   → worker A : { type: 'sim/outgoing-port', toDesignator, port }
main   → worker 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.

Interactive UI features (current)

Controls

  • Clients (2–12): how many web-worker-hosted OCapN clients to spawn.
  • Latency (ms): per-write delay applied symmetrically by both
    ends of every connection.
  • Restart: tear down all workers and bring up a fresh ring.
  • Forward chain length (1–20): N for the forward(N) kickoff.
  • Kick off random chain: pick a random worker and call its local
    forward(<length>). The drainer walks the [innerPromise] wrap up
    to N+4 levels deep, so you eventually see the leaf string
    done@<short-designator>.
  • Flush a random imported promise: pick a random worker, scan
    positions p-0..p-63 of every active session's table for
    desc:import-promise slots, and call the debug flushExport() on
    one. The exporter swaps in a fresh local promise at the same slot
    (preserving refcount), replies with op:flush-done, and subsequent
    deliveries queue on the new promise so per-reference FIFO order is
    preserved.

Visualization

  • One labelled circle per worker, arranged on a ring.
  • Solid line = active OCapN session (handshake complete).
  • Dashed line = idle session; solid blue = recent forward traffic on
    that edge.
  • Blue ring on a node = currently busy serving or initiating a
    forward(N).
  • Animated dots travel sender → receiver each time a forward call
    is dispatched.

Logs

  • Event log: kickoff/flush results, errors, and per-worker
    console.log reports.
  • Sessions log: snapshot (every 500 ms) of which peers each worker
    currently has live sessions with.

Why this is useful for op:flush

The flush primitive is the receive-side mechanism that promise
shortening would use as part of a third-party handoff: when the
exporter learns a chain has resolved and wants to drop the import
table entry, it sends op:flush for the slot, mints a fresh local
promise as the new export at that position, and routes any in-flight
deliveries through the replacement so per-slot order is preserved.

Today the simulator exposes that primitive manually so you can:

  • Build up a multi-hop forwarder chain with the Kick off button.
  • Watch import-promise slots accumulate in the visualization.
  • Hit Flush to verify the table-replacement protocol works without
    losing in-flight messages or breaking refcounts.

Suggested improvements / future additions

These would make the simulator a much richer testbed for op:flush —
not in this PR; calling them out as ideas the next iteration could
pick up.

Observability of the export/import tables

  • Per-node hover or click to open a side panel that lists every
    active export and import slot, with refcounts and the value's
    pass-style. Right now flushExport probes p-0..p-63 because
    OcapnTable doesn't expose iteration; an iteration API on the
    debug surface would let the simulator render real-time table
    contents instead of approximating from session activity.
  • Color-code import slots by age, or by "could have been shortened"
    — i.e., the answer-promise has already resolved on the exporter
    but the importer hasn't been told yet.
  • Per-edge counters: op:flush issued, op:flush-done received,
    messages re-routed onto the replacement promise.

Manual chain construction

  • Drag-to-build mode: instead of only random forwarder rings, let
    the user pick A → B → C → D explicitly so you can reason about
    a specific 3PH scenario.
  • Per-hop "pause": hold a forward call at a chosen worker so you
    can inspect the table state before letting it continue.
  • Ability to retain a strong reference to the head of the chain
    from the page so it doesn't get GC'd between kickoffs.

Automatic shortening, hooked to op:flush

  • A toggle that, on every newly-observed desc:import-promise,
    schedules a flush-then-3PH attempt once the answer has resolved
    on the exporter side. This is the natural next experiment: the
    simulator already has the primitives, what's missing is the
    policy that decides when to invoke them.
  • Side-by-side runs with and without auto-shortening enabled, so
    you can visualise the chain depth shrinking as flushes land.

Stress and correctness scenarios

  • Variable per-edge latency (asymmetric, jittered) instead of a
    single global slider.
  • Inject packet loss / reorder. The current sim-netlayer is FIFO;
    a controllable reorder window would help test that flush's
    per-slot FIFO guarantee survives churn.
  • Drop a worker mid-chain to exercise the "exporter dies before
    flush-done" path. Today the simulator never kills nodes once
    spawned.
  • Kick off many chains concurrently with overlapping forwarders, so
    multiple slots are in-flight at once and flushes interleave.

Conformance scaffolding

  • A "scenario file" mode: a small DSL (or just JSON) describing
    the topology, the kickoffs, and the expected post-flush table
    shape, runnable headlessly in Playwright. The package already
    has Playwright as a devDep ancestor in the original PR; we
    intentionally dropped it here to keep the dependency footprint
    small, but a CI-runnable scenario harness is the obvious next
    step once the spec text lands.
  • Snapshot the ocapn-pass-style of every value crossing each
    port, so the recorded transcript can be diffed against a fixture
    to detect protocol regressions.

Spec exploration

  • Toggle alternative semantics for op:flush from the UI (e.g.
    refcount-preserving vs. refcount-resetting; promise-only vs.
    any export) so the proposal can be A/B-tested against a live
    network.
  • Surface the replaceExportValue event on the pairwise table
    (added in the flush feature commit) as a viz event, so you can
    literally watch the slot swap.

Test plan

  • yarn install resolves without warnings
  • yarn workspace @endo/ocapn-simulator dev serves the page;
    kickoff produces leaf-string responses end-to-end
  • yarn workspace @endo/ocapn-simulator build succeeds; the
    built worker bundle contains no node:crypto reference (only
    getRandomValues)
  • yarn workspace @endo/ocapn run test — all 288 tests pass
  • yarn workspace @endo/goblin-chat run test — interop tests
    pass (depends on the same @endo/ocapn index.js shape)
  • Eyeball: kick off a length-6 chain, then click Flush a
    random imported promise
    , confirm at least one
    flush done: … line appears in the event log

Notes for reviewers

The two changes to @endo/ocapn are deliberate, not just simulator
plumbing:

  • encodeSwissnum / locationToLocationId are clearly part of the
    external surface any embedder of OCapN needs (mint a sturdyref,
    key on a session). The internal subpath was always an awkward
    workaround.
  • The deposit-gift relaxation is a real protocol question for the
    flush proposal: if 3PH is going to ship answer-promises across
    links as gifts, the exporter has to be willing to receive a
    promise gift. Happy to break this out into its own PR if you'd
    rather merge it independently of the simulator.

https://claude.ai/code/session_011bc6mdcwawQ6cisWED843c


Generated by Claude Code

claude added 2 commits May 8, 2026 23:56
Implement the receiver-initiated flush handshake that preserves per-reference
FIFO order when a peer is about to shorten a promise (typically as part of a
third-party handoff).

When the receiver sends op:flush for an exported promise position, the
exporter mints a fresh local promise, swaps it in at the same export position
(keeping the slot's refcount), and replies with op:flush-done. Further
deliveries the receiver had already sent for that position arrive before the
ack and dispatch on the original promise; deliveries sent after ack are
serialized through the new promise and naturally buffer until the shortened
reference resolves it.

Also exposes _debug.flushExport on Ocapn for tests, and adds replaceExportValue
on the pairwise table to support in-place value replacement at a slot.
Move op:flush / op:flush-done into the existing OcapnMessageUnionCodec
snapshot table so the wire form is captured by the same convention as the
other operations, and update the snapshots. Drop the now-redundant manual
round-trip tests.

The flush behavior tests previously had B return an unresolved promise
directly, which blocks the deliver-fulfill until the promise settles and
prevented the export from being registered. Wrap the promise in an array so
the array (a copyArray) is the immediate fulfillment value and the embedded
promise is serialized as desc:import-promise on the wire.

Also satisfy lint: thread the void/Promise<void> conversion of quietReject
through unknown, and silence no-use-before-define on captures of send,
didUnplug, and quietReject inside the new handler / flushExport.
@changeset-bot

changeset-bot Bot commented May 9, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: d9aa01c

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 3 packages
Name Type
@endo/ocapn Minor
@endo/ocapn-simulator Minor
@endo/goblin-chat Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@kumavis kumavis force-pushed the claude/add-ocapn-package-TgC29 branch from a3a9d1e to accc55f Compare May 9, 2026 07:14
@kumavis kumavis force-pushed the claude/add-ocapn-package-TgC29 branch from accc55f to d9aa01c Compare May 9, 2026 07:15
@kumavis kumavis force-pushed the claude/implement-ocapn-flush-iisn9 branch 3 times, most recently from ee674ad to 19c0bbf Compare May 11, 2026 08:15
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants