Skip to main content

Module abi

Module abi 

Source
Expand description

§The low-level contract

This page is the reference for the contract between a plugin component and its host: the exports each world expects, the memory rules, the surface buffer protocol and the error types. The Rust SDK implements all of it, so a plugin written with the SDK needs none of this page. Read it to debug what the SDK sends, or to read the protocol itself.

Sources, in order of authority:

  • The WIT package standard:plugin@2.0.0, wit/plugin.wit in this crate, is the contract.
  • Where the WIT is silent, this page describes what the SDK does and cites the SDK file and item. A component that behaves the same way works with the same hosts.
  • Where neither states a rule, this page says it is unspecified. Do not depend on unspecified behaviour.

§Worlds and exports

A plugin is a component that targets one of two worlds.

WorldManifest kindExports
ui-pluginuiactivate, frame, event, deactivate
daemon-plugindaemon, companionactivate, event, drive, deactivate, the interface daemon-events (handle-event), the interface companion (handle-call)

standard-plugin check refuses a component that lacks an export its world requires, and warns about an export no host calls (src/check.rs in standard-plugin-cli, check_component).

§When the UI exports are called

ExportWhen (from plugin.wit)
activate(config)Once after instantiation, with the configuration document (JSON text).
frame(now-ms, interval-ms, power) -> option<u64>When a paced frame is due and at least one of the plugin’s surfaces is visible. now-ms is the viewer’s monotonic clock; interval-ms is the paced frame interval.
event(e)For everything else: resizes, visibility, input, account changes, call results (the event variant in event-types).
deactivate()Before the instance is dropped.

frame returns the now-ms at which the plugin next wants a frame: now-ms itself for the next paced frame, a later instant for a clock, and none to wait for an event. Nothing else wakes an idle viewer. From any export, view.request-frame asks for the next paced frame and view.wake-at(at-ms) asks for a frame at an instant; the earliest request wins. The SDK’s Frame::request_frame is wake_at(now_ms), and the earliest of the plugin’s requests is frame’s return value (src/ui_runtime.rs, Frame).

A commit made in event is not a frame request. The viewer samples it on the next frame that something else causes. A plugin that repaints in event also calls view.request-frame or view.wake-at.

§When the daemon exports are called

ExportWhen (from plugin.wit)
activate(config)Once after instantiation, with the configuration document.
event(e)Account events: value changes, live messages, plugin events, account changes, call results.
daemon-events.handle-event(e)Machine events, in order: file changes, failed watches, process output and exits, pane changes.
companion.handle-call(method, payload, from)A call from another runtime of the plugin (from.kind is ui or daemon). Returns the answer, or invalid for an unknown method.
drive(now-ms) -> option<u64>After activate, after event, handle-event and handle-call, and at the instant the previous call asked for. Returns the next instant, or none to wait for an event.
deactivate()The WIT does not say when a host calls it.

The export is named drive because a daemon plugin may link wasi-libc, which defines a poll symbol (plugin.wit, daemon-plugin).

Rules a daemon plugin can rely on:

  • drive runs after the calls listed above. The WIT does not promise one drive per call, so do all pending work in each drive.
  • A host may stop an instance without calling deactivate. Do not put work that must happen in deactivate.
  • The host delivers the plugin event system.plugin.interest once when the plugin starts and again when the set of shown surfaces changes (plugin.wit, events). The SDK hears it only when it differs from the last one it received (src/daemon/runtime.rs, DaemonRuntime::event).
  • WebSocket activity arrives as the plugin event system.net.websocket (plugin.wit, daemon-net).

The SDK’s daemon runtime runs the plugin’s async tasks on a small executor inside drive and returns the earliest timer the tasks wait for (src/daemon/runtime.rs, DaemonRuntime::drive).

§Threading

A component instance runs on one thread, and the host calls one export at a time. The SDK’s runtimes and instance-local state depend on this (src/local.rs, instance_local!; src/ui_runtime.rs, the Sync impl of UiRuntime). Host calls go out from inside an export.

§Gestures

Some calls work only while the plugin handles a user gesture: a key, a paste or a pointer down on one of its surfaces, or one of its commands. surface.open and view.focus-pane need a gesture. url.open needs a gesture or the second after one, once per input. Each answers invalid outside that window (plugin.wit, surface, view, url).

§Memory

  • The component exports its linear memory and the canonical ABI realloc function. The host uses the realloc function to place strings and lists in the plugin’s memory: the activate configuration, event payloads and the results of host calls. The plugin owns those allocations and frees them. Bindings that wit-bindgen generates do this.
  • The SDK’s components use UTF-8 for strings (the wit-bindgen default). Other string encodings are unspecified.
  • The shared buffer is little-endian. The SDK refuses to compile for a big-endian target (src/lib.rs, the compile_error! for target_endian = "big").
  • A UI plugin has no libc. The SDK’s runtime feature supplies what a no_std component needs: a global allocator that reuses freed blocks, a panic handler that traps, cabi_realloc, and the C symbols memcmp, bcmp, fmodf and fmod (src/runtime.rs). The allocator reuses memory because every resize allocates a new region and frees the old one; an allocator that never reuses memory reaches the memory limit after a few resizes.
  • memoryMib in the manifest limits linear memory; the host caps it (manifest). The contract states no other allocation rule.

§The surface buffer

A UI plugin paints each surface into a region of its own linear memory. The host reads that region directly. The layout comes from plugin.wit (surface, region-layout); the SDK constants are in src/surface/mod.rs (HEADER_LEN, CELL_LEN, PIXEL_LEN).

§Layout

offset 0              header: 8 little-endian u32 words (32 bytes)
offset 32             slot 0, slot-len bytes
offset 32 + slot-len  slot 1, slot-len bytes
total                 len = 32 + 2 * slot-len
Header wordValue
0seq: the sequence number (below)
1model: 0 cells, 1 pixels
2, 3cols, rows
4, 5px-w, px-h
6, 7cell-px-w, cell-px-h

slot-len is cols * rows * 16 in the cells model and px-w * px-h * 4 in the pixels model. Always take len, header-len, slot-len, the model and the geometry from surface.layout(id). Do not compute them yourself.

px-w and px-h are zero while the viewer has no pixel geometry. A pixels surface then has an empty slot and nothing to paint.

The WIT states no alignment for the region. The SDK allocates it as u32 words, so it is 4-byte aligned (src/surface/mod.rs, Binding). Align it the same way.

§The cells model

A cell is 16 bytes, four little-endian u32 words:

BytesFieldEncoding
0..4grapheme0 is empty. Otherwise a Unicode scalar value. Values from 0x8000_0000 up are reserved for a grapheme pool that is not implemented.
4..8fgA colour word (below).
8..12bgA colour word. The default colour is a transparent background.
12..14attrsu16 bits: bold 1, dim 2, italic 4, underline 8, reverse 16, strikethrough 32.
14..16paddingZero.

A colour word’s top byte is its model (src/colour.rs, Colour::encode and Colour::decode):

WordMeaning
0x00______The default: the theme’s text colour, or a transparent background.
0x01RRGGBBAn RGB colour.
0x020000IITerminal palette index II.
0x030000TTTheme slot TT: 0 fg, 1 bg, 2 recede-fg, 3 recede-bg, 4 accent.

Colour::decode reads an unknown model byte as the default colour and ignores bits a model does not use.

The SDK writes cells by these rules (src/surface/cells.rs, char_width, Grid::put). The module documentation says they mirror the viewer:

  • A cell holds one scalar: the first scalar of a grapheme cluster. Combining marks, variation selectors and joined sequences are dropped.
  • A character’s width is its East Asian width, 1 or 2. Control characters (C0, DEL, C1), the bidi controls U+061C, U+200E, U+200F, U+202A to U+202E and U+2066 to U+2069, and zero-width characters are never written.
  • A wide character takes two cells: the character, then a cell whose grapheme is 0 with the same colours and attributes.
  • A wide character that does not fit before the right edge is replaced by one empty cell.
  • A write over the right half of a wide character empties its left half. A write over the left half leaves the right half empty. Either way the neighbour cell joins the dirty rectangle, so the viewer reads it again.

§The pixels model

A pixel is 4 bytes, r, g, b, a, in rows from top to bottom. px-w is cols * cell-px-w. Alpha is straight (unpremultiplied) (rendering).

view.capabilities().pixel-scale is 1, or 2 while the host has halved the plugin’s pixel resolution. The geometry the plugin receives is then the smaller one, and the viewer scales the image up (plugin.wit, view).

§Binding a region

The SDK binds a surface in Inner::bind (src/surface/mod.rs):

  1. Call surface.layout(id). An id the manifest does not declare answers invalid.
  2. If the layout’s model is not the model you paint in, stop. The SDK returns Error::WrongModel.
  3. If cols or rows is 0, the surface has no size yet. Wait for event::resize.
  4. Allocate len bytes, zeroed.
  5. Write header words 1 to 7 from the layout. The SDK writes word 0 with its current sequence number.
  6. Call surface.attach(id, region), where region is the allocated bytes. The canonical ABI passes a list<u8> argument as an address and a length in the plugin’s memory, so the host sees the region itself.

attach refuses a region that is shorter than len or whose header does not describe the current layout (plugin.wit, attach).

After attach returns, the host reads the region at any time until it is detached or replaced. Keep it allocated at the same address. Do not reuse its memory for anything else.

A surface can have a size before the plugin creates it: for example, when an instance is restarted. The SDK’s Surface::new calls layout at once and binds when the surface has a size, else on its first resize (src/surface/mod.rs, Surface::new).

§Painting and committing

The region holds two slots. The plugin paints one slot (the back slot) while the host may read the other one.

The SDK’s rules (src/surface/mod.rs, Inner::commit, Inner::apply_sync, Inner::back):

  1. After a bind, the back slot is slot 0.
  2. Paint only the back slot. Record every rectangle you change, in cells (cells model) or pixels (pixels model).
  3. To commit, increment the sequence number (wrapping at 2^32), write it to header word 0, and call surface.commit(id, slot, dirty) with the back slot and the changed rectangles.
  4. When commit succeeds, the other slot becomes the back slot.
  5. Before the first write to the new back slot, copy into it the rectangles of the commit that just went out, from the slot that was just committed. Each slot then holds a whole frame, and drawing stays incremental.
  6. When commit fails, the SDK keeps the back slot and adds the rectangles back to its dirty set for the next commit.

The contract says that the viewer reads only committed slots, only the dirty rectangles, and only when a frame is due and the surface is visible (plugin.wit, surface). It does not say that the viewer reads every commit. Rule 5 keeps the image correct when the viewer skips one.

Other commit rules:

  • An empty dirty list changes nothing and requests no frame. The SDK makes no host call when nothing changed: Surface::commit returns Ok(false).
  • The first commit after an attach is fully dirty. The SDK sends one rectangle that covers the surface.
  • The SDK clamps rectangles to the surface. It merges rectangles that touch, and past 16 it sends their bounding box (src/surface/dirty.rs, MAX_RECTS). The contract states no limit on the list.
  • slot is 0 or 1.
  • To repaint all of a slot, skip rule 5 and mark the whole surface dirty. The SDK’s clear() and Surface<Pixels>::repaint_all() do this.

The WIT says the header carries “a sequence number so a half-written slot is never sampled”. It does not say how a host uses word 0. Write it as the SDK does.

§Resize and model changes

event::resize(id, geometry) says a surface changed size or model. The plugin then binds a new region: layout, allocate, write the header, attach (plugin.wit, event).

  • The host keeps reading the old region until the first commit at the new size, and never reads it after that commit returns. Free the old region only after that commit (plugin.wit, attach).
  • The SDK keeps one retired region. When several resizes arrive before a commit, it keeps the first old region and frees the later ones at once (src/surface/mod.rs, Inner::bind, the retired field).
  • The new region starts zeroed and the next commit is fully dirty. Repaint everything.
  • The SDK rebinds every surface of the resized id before the plugin’s event sees the resize (src/ui_runtime.rs, UiRuntime::event; src/surface/mod.rs, rebind_surfaces).
  • A surface whose manifest lists both models may switch model. layout then reports the other model. The SDK’s AnySurface detaches the old surface and binds a new one in the new model under the same id (src/surface/mod.rs, AnySurface::sync).
  • An instance surface (<surface>@<owner>) that resizes to zero is gone. The SDK’s Instances drops its surface then (Instances::event).

§Detaching

surface.detach(id) unbinds the region. The host must stop reading a region before its memory is freed, so the SDK detaches when a Surface is dropped (src/surface/mod.rs, impl Drop for Surface).

§Sizing requests

surface.request-size(id, cols, rows) asks for a size in cells instead of the manifest’s height and width. 0 in a dimension is the viewer’s choice, except for the rows of a machine.after, project.after or project.before instance, where 0 rows hides the instance. The viewer clamps the request to what the anchor allows and answers with a resize when the size changes. The request stays in force until the plugin asks again. An instance id may be asked before the viewer has sized it (plugin.wit, request-size; manifest).

The plugin paints at the size the latest resize reports.

§Instances

pane.footer, pane.header, pane.overlay, machine.after, project.after and project.before surfaces have one instance per owner, named <surface-id>@<owner-id>. Each instance arrives as its own resize and visibility, and the plugin binds a region per instance by that id. view.surface-pane, view.surface-machine and view.surface-project name an instance’s owner (plugin.wit, view; manifest).

§Errors

Every fallible host call returns call-error (plugin.wit, types):

VariantMeaning
grant-denied(grant)The manifest does not carry the grant the call needs. The payload names it.
rate-limitedThe per-plugin rate limit for this call kind was reached.
unavailable(reason)The backing service is not reachable now. A calls.call timeout is unavailable("call_timeout").
invalid(reason)The arguments were malformed: an unknown surface id, an oversized value, a key outside the plugin’s namespace, a gesture-only call outside a gesture.

An import outside the plugin’s world fails at instantiation, before any call (plugin.wit, the package comment).

The SDK’s Error (src/error.rs) carries those four variants and adds three of its own. The host never sends them:

SDK variantMeaning
JsonA payload did not serialize or deserialize.
NotAttachedThe surface has no buffer yet: the viewer has not laid it out.
WrongModelThe layout reports the other model.

A panic in an SDK plugin traps (src/runtime.rs, the panic handler). What a host does after a trap is outside the contract; rendering and daemon plugins describe what the plugin sees.

§Unspecified

Neither the WIT nor the SDK states these. Do not depend on them:

  • How a host uses header word 0 (seq).
  • The alignment of a region. The SDK uses 4 bytes.
  • A limit on the number of dirty rectangles in one commit.
  • Whether the viewer reads every commit.
  • When a daemon host calls deactivate, and whether it calls it at all.
  • Whether drive runs once per event or once per group of events.
  • String encodings other than UTF-8.
  • The grapheme pool for scalars from 0x8000_0000 up. It is reserved.