Host Loop Pseudocode#

Constants:

ACCOUNT_ADDR_SIZE = 20
SLOT_ID_SIZE = 32

Data Encoding Functions:

write_byte_slice(arr):
  write_u32_le len(arr)
  write_bytes arr

read_slice():
  len := read_u32_le
  data := read_bytes(len)
  return data

Multi-Host#

GenVM supports multiple host connections. Each host method is routed to a specific host connection based on a method_hosts mapping in ExecutionData. The mapping is a byte array where each index corresponds to a method ID and the value is the host connection index. When the index is out of bounds or the array is empty, host 0 is used as the default.

The executor accepts multiple --host arguments. Each host connection independently runs the protocol described below.

In the manager deployment, host 0 is the node’s host loop and host 1 is a socketpair to the manager. The manager serves consume_result, run_nested and – by default – resolve_call_contract_executor on host 1.

resolve_call_contract_executor moves to host 0 only when the run request sets hook_cross_contract_calls (see Manager Socket Protocol). Otherwise the manager answers it with a null reply, which keeps every CallContract in-process. A host that does not route calls across major boundaries therefore need not implement that method at all.

A non-null answer is a calldata map naming the line the callee runs on, tagged on kind: {"kind": "major", "major": u32} resolves by the manifest’s rules, falling back to the newest line when none provides that major, while {"kind": "version", "version": str} names a line outright – an executor directory as it stands, or a re:-prefixed regex over manifest version keys, which fails the run if it matches nothing. Only a version can mean one particular line, since every line released so far is semver major 0. Executor lines carry the payload without reading it; only the manager decodes it.

Connection Hello Data#

ExecutionData carries host_hello_data, an array of byte strings indexed by host connection index (the same index space method_hosts values point into). Immediately after connecting host i, before writing the first method byte, the executor writes entry i to that connection verbatim – raw bytes, no length prefix or framing. A missing, short, or empty entry means nothing is written; an empty (or absent) array is byte-identical to a connection without hello data. The bytes are supplied by the host in the run request (see Manager Socket Protocol), so the host knows their length; a host wanting structure embeds its own framing.

Protocol Loop#

The host processes requests in a loop. Each host connection runs independently, handling only the methods routed to it. The loop ends when the executor closes the connection (EOF on the method-id read): on a stream socket all written data precedes the FIN, so every response the host owes has been requested and every result byte has arrived by then. The executor flushes all host connections before exiting. Completion status (exit code, result, clean-finish vs crash) is reported by the manager’s terminal event (Manager Socket Protocol), not by a host method:

loop:
  method_id := read_byte
  match method_id
    json/methods/storage_read:
      read_type := read_byte as json/storage_view
      address := read_bytes(ACCOUNT_ADDR_SIZE)
      slot := read_bytes(SLOT_ID_SIZE)
      offset := read_u32_le
      len := read_u32_le
      data, err := host_storage_read(read_type, address, slot, offset, len)
      if err != json/errors/ok:
        write_byte err
      else:
        write_byte json/errors/ok
        write_bytes data # must be exactly len in size

    json/methods/consume_result:
      host_result := read_slice()
      # this is needed to ensure that genvm doesn't close socket before all data is read
      write_byte 0x00

    json/methods/consume_time_fee_gen_wei:
      time_fee_gen_wei := read_u256_le
      host_consume_time_fee_gen_wei(time_fee_gen_wei)
      # note: this method doesn't send any response

    json/methods/external_call:
      address := read_bytes(ACCOUNT_ADDR_SIZE)
      calldata := read_slice()
      result, err := host_external_call(address, calldata)
      if err != json/errors/ok:
        write_byte err
      else:
        write_byte json/errors/ok
        write_byte_slice result

    json/methods/get_balance_gen_wei:
      address := read_bytes(ACCOUNT_ADDR_SIZE)
      balance, err := host_get_balance_gen_wei(address)
      if err != json/errors/ok:
        write_byte err
      else:
        write_byte json/errors/ok
        write_bytes balance.to_le_bytes(32) # 256-bit integer

    json/methods/resolve_call_contract_executor:
      address := read_bytes(ACCOUNT_ADDR_SIZE)
      state := read_byte as json/storage_view
      advisory_major := read_byte
      payload, err := host_resolve_call_contract_executor(
        address, state, advisory_major)
      if err != json/errors/ok:
        write_byte err
      else:
        write_byte json/errors/ok
        # Calldata optional bytes preserve null vs an empty payload.
        write_byte_slice calldata_encode(payload)

    json/methods/run_nested:
      envelope := calldata_decode(read_slice())
      # The envelope carries no version of its own -- every executor line
      # compiles the one definition. The manager validates the routing
      # payload, resolves the executor at the inherited timestamp, and mints
      # a fresh execution id. It uses the outer deadline/cancellation and no
      # permit.
      child := manager_start_nested_sync(
        envelope,
        storage_host = host_0,
        result_host = host_1)
      reply := {
        result: child.result,
        # The child's sub-VM small hash, not its execution hash: the caller
        # folds this, and it must not depend on the route the host chose.
        small_hash: child.small_hash,
        effect_free: child.has_no_effects
      }
      write_byte_slice calldata_encode(reply)

    json/methods/get_remaining_time_fee_gen_wei:
      time_fee_gen_wei, err := host_get_remaining_time_fee_gen_wei()
      if err != json/errors/ok:
        write_byte err
      else:
        write_byte json/errors/ok
        write_bytes time_fee_gen_wei.to_le_bytes(32) # 256-bit unsigned, little-endian, always 32 bytes

    json/methods/notify_nondet_disagreement:
      call_no := read_u32_le
      host_notify_nondet_disagreement(call_no)
      # note: this method doesn't send any response