Logo

What Ruby-on-ICP can do, run live against the backend canister. Most panels are a ruby_wasm_builder challenge: the Ruby it runs, the result the builder expects, and a button that calls the canister and compares. Panels marked showcase are small apps written for this demo that use the same capability, and panels marked web are the canister answering the IC’s HTTP gateway itself, as a browser would see it.

Ruby runtime

Float to String since 0.1.0

`0.1 + 0.2` printed as its shortest round-trip form, the same digits CRuby gives you everywhere. CRuby computes this with its own dtoa rather than the C library, so it holds on wasm too.

Ruby source
# Float to String
#
# `0.1 + 0.2` printed as its shortest round-trip form, the same digits CRuby
# gives you everywhere. CRuby computes this with its own dtoa rather than the C
# library, so it holds on wasm too.
#
# Ported from ruby_wasm_builder challenges/float (introduced 0.1.0, ported at 0.6.0).
def float_main
  (0.1 + 0.2).to_s
end

Expected: 0.30000000000000004

External enumerators since 0.1.0

`Enumerator#next` runs the iteration in a Fiber and switches back and forth with it. Fibers switch stacks, which on wasm means unwinding and rewinding the call stack, and this shows that it works inside a canister.

Ruby source
# External enumerators
#
# `Enumerator#next` runs the iteration in a Fiber and switches back and forth
# with it. Fibers switch stacks, which on wasm means unwinding and rewinding the
# call stack, and this shows that it works inside a canister.
#
# Ported from ruby_wasm_builder challenges/enumerator (introduced 0.1.0, ported at 0.6.0).
def enumerator_main
  e = [10, 20, 30].each
  [e.next, e.next, e.next].sum.to_s
end

Expected: 60

Raise and rescue since 0.1.0

A `raise` caught by an ordinary Ruby `rescue`. Exceptions unwind through the wasm call stack and land in the handler, just as on a desktop Ruby.

Ruby source
# Raise and rescue
#
# A `raise` caught by an ordinary Ruby `rescue`. Exceptions unwind through the
# wasm call stack and land in the handler, just as on a desktop Ruby.
#
# Ported from ruby_wasm_builder challenges/raise (introduced 0.1.0, ported at 0.6.0).
module RaisePort
  class ChallengeError < StandardError; end
end

def raise_main
  raise RaisePort::ChallengeError, "raised"
rescue RaisePort::ChallengeError => e
  "rescued #{e.class.name.split('::').last}: #{e.message}"
end

Expected: rescued ChallengeError: raised

Deep recursion since 0.2.0

Three hundred frames deep, each one through a C method (`map` calls the block from C), so every level is a C frame as well as a Ruby one. 300 is a floor that holds with room to spare: past roughly 650 levels a call traps rather than raising SystemStackError, and the IC rolls the call back.

Ruby source
# Deep recursion
#
# Three hundred frames deep, each one through a C method (`map` calls the block
# from C), so every level is a C frame as well as a Ruby one. 300 is a floor
# that holds with room to spare: past roughly 650 levels a call traps rather
# than raising SystemStackError, and the IC rolls the call back.
#
# Ported from ruby_wasm_builder challenges/recursion (introduced 0.2.0, ported at 0.6.0).
def recursion_depth(n) = n.zero? ? 0 : [n - 1].map { |m| recursion_depth(m) }.first + 1

def recursion_main
  recursion_depth(300).to_s
end

Expected: 300

Memory

Garbage collection since 0.1.0

An allocation heavy enough to force a collection, with live objects that must survive it intact. A broken collector would free or move a live object, and it would then no longer read back correctly.

Ruby source
# Garbage collection
#
# An allocation heavy enough to force a collection, with live objects that must
# survive it intact. A broken collector would free or move a live object, and
# it would then no longer read back correctly.
#
# Ported from ruby_wasm_builder challenges/gc (introduced 0.1.0, ported at 0.6.0).
def gc_main
  before = GC.count
  kept = Array.new(1_000) { |i| "kept-#{i}" }
  200_000.times { |i| "garbage-#{i}" }
  intact = kept.each_with_index.all? { |s, i| s == "kept-#{i}" }
  "collected: #{GC.count > before}, survivors intact: #{intact}"
end

Expected: collected: true, survivors intact: true

Objects that clean themselves up showcase, since 0.1.0

One call builds a 50,000-string object five times in a row, dropping each one before building the next, and reports the live-object count after every round. If nothing were reclaimed the count would climb by about 50,000 each round; it stays flat instead. Nothing here frees memory or asks for a collection: the Ruby runtime collects on its own. (A Rust canister frees an object when its owner goes out of scope, by rule. Ruby does it by collecting.)

What did not work: showing a single object appear and then vanish. On this build the most recent large object is still counted after it is dropped, even after more collections, a forced GC, a deep call to scrub the stack, or a separate call. Earlier objects are reclaimed, which is why the rounds are flat. What holds the last one is not identified. The counts are also approximate, since they include whatever else the VM holds.

Ruby source
# Objects that clean themselves up
#
# One call builds a 50,000-string object five times in a row, dropping each one
# before building the next, and reports the live-object count after every round.
# If nothing were reclaimed the count would climb by about 50,000 each round; it
# stays flat instead. Nothing here frees memory or asks for a collection: the
# Ruby runtime collects on its own. (A Rust canister frees an object when its
# owner goes out of scope, by rule. Ruby does it by collecting.)
#
# What did not work: showing a single object appear and then vanish. On this
# build the most recent large object is still counted after it is dropped, even
# after more collections, a forced GC, a deep call to scrub the stack, or a
# separate call. Earlier objects are reclaimed, which is why the rounds are flat.
# What holds the last one is not identified. The counts are also approximate,
# since they include whatever else the VM holds.
#
# Showcase for ruby_wasm_builder challenges/gc_state (introduced 0.1.0, written for 0.6.0).
GC_LIFECYCLE_ROUNDS = 5

def gc_lifecycle_live = GC.stat(:heap_live_slots)

# Built inside its own method so nothing in the caller refers to it afterwards.
def gc_lifecycle_build = Array.new(50_000) { |i| "object-#{i}" }.size

def gc_lifecycle
  before = gc_lifecycle_live
  collections = GC.count
  rounds = Array.new(GC_LIFECYCLE_ROUNDS) do
    gc_lifecycle_build
    gc_lifecycle_live
  end
  "live objects: #{before} before; after each round: #{rounds.join(', ')}; " \
    "the runtime ran #{GC.count - collections} collection(s) by itself"
end

Objects kept across messages and collections since 0.1.0

Each call adds a thousand objects to a global and forces a collection, and every object from the earlier calls must still read back intact. A call is a fresh entry into the VM, so this checks the collector still finds what previous messages left behind.

Ruby source
# Objects kept across messages and collections
#
# Each call adds a thousand objects to a global and forces a collection, and
# every object from the earlier calls must still read back intact. A call is a
# fresh entry into the VM, so this checks the collector still finds what
# previous messages left behind.
# Stateful: yes
#
# Ported from ruby_wasm_builder challenges/gc_state (introduced 0.1.0, ported at 0.6.0).
$gc_state_kept = []

def gc_state_main
  1_000.times { $gc_state_kept << "kept-#{$gc_state_kept.size}" }
  GC.start
  intact = $gc_state_kept.each_with_index.all? { |s, i| s == "kept-#{i}" }
  "#{$gc_state_kept.size} kept, intact: #{intact}"
end

On a fresh canister:

1000 kept, intact: true
2000 kept, intact: true
3000 kept, intact: true

    Standard library and files

    The standard library since 0.2.0

    `Set` is loaded from the Ruby standard library on first use. The base module ships the standard library, so a canister can `require` it without carrying its own copy.

    Ruby source
    # The standard library
    #
    # `Set` is loaded from the Ruby standard library on first use. The base module
    # ships the standard library, so a canister can `require` it without carrying
    # its own copy.
    #
    # Ported from ruby_wasm_builder challenges/stdlib (introduced 0.2.0, ported at 0.6.0).
    def stdlib_main
      Set.new([1, 2, 2, 3]).size.to_s
    end
    

    Expected: 3

    An application of more than one file since 0.1.0

    One Ruby file loads a second one beside it with `require_relative`. A canister is a directory of Ruby, not a single script.

    Ruby source
    # An application of more than one file
    #
    # One Ruby file loads a second one beside it with `require_relative`. A canister
    # is a directory of Ruby, not a single script.
    #
    # Ported from ruby_wasm_builder challenges/require_relative (introduced 0.1.0, ported at 0.6.0).
    require_relative "require_relative/greeting"
    
    def require_relative_main
      Greeting.hello
    end
    

    Expected: hello from greeting.rb

    Files read as text since 0.9.0

    A file read with no encoding is text: `Encoding.default_external` is UTF-8, the locale's, as `ruby` sets it under a UTF-8 locale, and so are the `locale` and `filesystem` pseudo-encodings. `write` stores a line with accents, an em dash and kanji as raw bytes; `read` gets it back as UTF-8 text, so its length counts 25 characters (not 32 bytes), it equals the literal, and a UTF-8 Regexp finds "José" at character 9. Reading the same file as bytes is still ASCII-8BIT, as a Candid blob is. Up to 0.8.0 it was ASCII-8BIT: `env` raised and `read` failed with Encoding::CompatibilityError. The setting is made at every boot, so the command below shows it again after an upgrade.

    Ruby source
    # Files read as text
    #
    # A file read with no encoding is text: `Encoding.default_external` is UTF-8,
    # the locale's, as `ruby` sets it under a UTF-8 locale, and so are the
    # `locale` and `filesystem` pseudo-encodings. `write` stores a line with
    # accents, an em dash and kanji as raw bytes; `read` gets it back as UTF-8 text,
    # so its length counts 25 characters (not 32 bytes), it equals the literal, and
    # a UTF-8 Regexp finds "José" at character 9. Reading the same file as bytes is
    # still ASCII-8BIT, as a Candid blob is. Up to 0.8.0 it was ASCII-8BIT: `env`
    # raised and `read` failed with Encoding::CompatibilityError. The setting is
    # made at every boot, so the command below shows it again after an upgrade.
    # Methods: env, write, read, binread
    # Cli: yes
    #
    # Ported from ruby_wasm_builder challenges/default_external (introduced 0.9.0, ported at 0.10.0).
    DEFAULT_EXTERNAL_TEXT = "Aklatan: José Rizal — 日本\n"
    DEFAULT_EXTERNAL_PATH = File.join(IC::DATA_DIR, "default_external_aklatan.txt")
    
    def default_external_env
      "external=#{Encoding.default_external.name} internal=#{Encoding.default_internal&.name || 'nil'} " \
        "locale=#{Encoding.find('locale').name} filesystem=#{Encoding.find('filesystem').name}"
    end
    
    def default_external_write
      File.binwrite(DEFAULT_EXTERNAL_PATH, DEFAULT_EXTERNAL_TEXT.b)
      "wrote #{File.size(DEFAULT_EXTERNAL_PATH)} bytes"
    end
    
    def default_external_read
      s = File.read(DEFAULT_EXTERNAL_PATH)
      "#{s.encoding.name} #{s.length} #{s == DEFAULT_EXTERNAL_TEXT} #{s =~ /José/}"
    end
    
    def default_external_binread = File.binread(DEFAULT_EXTERNAL_PATH).encoding.name
    

    Expected: external=UTF-8 internal=nil locale=UTF-8 filesystem=UTF-8

    Then, in a terminal (the local network from rake network:start):

    1. Write the file as bytes
      icp canister call backend default_external_write '()' -e local

      Expected: wrote 32 bytes

    2. Upgrade the canister with the same module
      icp deploy backend -e local

      Then: default_external is set again on the upgrade's boot

    3. The setting after the upgrade
      icp canister call backend default_external_env '()' -e local

      Expected: external=UTF-8 internal=nil locale=UTF-8 filesystem=UTF-8

    4. The file still reads as text
      icp canister call backend default_external_read '()' -e local

      Expected: UTF-8 25 true 9

    State across calls

    State kept across messages since 0.1.0

    Each call is one update message to the same canister, and the Ruby heap carries on between them, so the count goes up by one every time. State in a Ruby global is shared by every visitor and is lost when the canister is upgraded.

    Ruby source
    # State kept across messages
    #
    # Each call is one update message to the same canister, and the Ruby heap
    # carries on between them, so the count goes up by one every time. State in a
    # Ruby global is shared by every visitor and is lost when the canister is
    # upgraded.
    # Stateful: yes
    #
    # Ported from ruby_wasm_builder challenges/counter (introduced 0.1.0, ported at 0.6.0).
    $counter_count = 0
    
    def counter_main
      ($counter_count += 1).to_s
    end
    

    On a fresh canister:

    1
    2
    3

      Declared methods since 0.3.0

      Two updates, each served by the top-level method of its name and sharing one VM. `bump` counts; `total` reads the count and replies in a different shape. The shared count shows one VM, and the different replies show one method per export.

      Ruby source
      # Declared methods
      #
      # Two updates, each served by the top-level method of its name and sharing one
      # VM. `bump` counts; `total` reads the count and replies in a different shape.
      # The shared count shows one VM, and the different replies show one method per
      # export.
      # Stateful: yes
      # Methods: bump, total
      #
      # Ported from ruby_wasm_builder challenges/declared_methods (introduced 0.3.0, ported at 0.6.0).
      $declared_methods_n = 0
      
      def declared_methods_bump = ($declared_methods_n += 1).to_s
      
      def declared_methods_total = "total=#{$declared_methods_n}"
      

      On a fresh canister:

      bump => 1
      bump => 2
      total => total=2

        Calls, errors and rollback

        A bounded refusal since 0.3.0

        A refusal's text is cut to at most 1,024 bytes, on a character boundary, and ends with " [truncated]". Here the reason is 1,001 `a`s followed by ten emoji, so the cut lands inside the first emoji and backs up to keep it whole or leave it out. A call afterwards still works: the refusal rolled back cleanly.

        Ruby source
        # A bounded refusal
        #
        # A refusal's text is cut to at most 1,024 bytes, on a character boundary, and
        # ends with " [truncated]". Here the reason is 1,001 `a`s followed by ten
        # emoji, so the cut lands inside the first emoji and backs up to keep it whole
        # or leave it out. A call afterwards still works: the refusal rolled back
        # cleanly.
        # Outcome: refusal
        # Methods: long_refusal, read
        #
        # Ported from ruby_wasm_builder challenges/trap_text_bound (introduced 0.3.0, ported at 0.6.0).
        def trap_text_bound_long_refusal = IC.refuse("a" * 1001 + "\u{1F600}" * 10)
        
        def trap_text_bound_read = "ok"
        

        Expected: a refusal of 1013 characters, starting aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa...

        A fault undoes the whole call since 0.3.0

        `seed` commits a file and a value in the Ruby heap. Then a call overwrites the file, creates a second one, changes the heap, and raises an ordinary Ruby error that nobody rescues. The call ends as a fault, and everything it did is rolled back: the next `read` still sees the seeded file, no second file, and the seeded heap value.

        Ruby source
        # A fault undoes the whole call
        #
        # `seed` commits a file and a value in the Ruby heap. Then a call overwrites the
        # file, creates a second one, changes the heap, and raises an ordinary Ruby
        # error that nobody rescues. The call ends as a fault, and everything it did is
        # rolled back: the next `read` still sees the seeded file, no second file, and
        # the seeded heap value.
        # Script: yes
        #
        # Ported from ruby_wasm_builder challenges/fault_undoes (introduced 0.3.0, ported at 0.6.0).
        FAULT_UNDOES_KEPT = File.join(IC::DATA_DIR, "fault_undoes_kept.txt")
        FAULT_UNDOES_NEW  = File.join(IC::DATA_DIR, "fault_undoes_new.txt")
        
        $fault_undoes_heap = 0
        
        def fault_undoes_seed
          File.write(FAULT_UNDOES_KEPT, "before")
          $fault_undoes_heap = 1
          "seeded"
        end
        
        def fault_undoes_write_then_raise
          File.write(FAULT_UNDOES_KEPT, "after")
          File.write(FAULT_UNDOES_NEW, "made")
          $fault_undoes_heap = 2
          raise ArgumentError, "x"
        end
        
        def fault_undoes_read
          "kept=#{File.read(FAULT_UNDOES_KEPT)} new=#{File.exist?(FAULT_UNDOES_NEW) ? File.read(FAULT_UNDOES_NEW) : 'absent'} heap=#{$fault_undoes_heap}"
        end
        

        A refusal undoes the whole call since 0.3.0

        The same shape as a fault, with a deliberate refusal in place of an error: `seed` commits a file and a heap value, then a call overwrites the file, creates another, changes the heap, and refuses with `IC.refuse`. The canister rolls all three back, and `read` shows what is left: the seeded values only.

        Ruby source
        # A refusal undoes the whole call
        #
        # The same shape as a fault, with a deliberate refusal in place of an error:
        # `seed` commits a file and a heap value, then a call overwrites the file,
        # creates another, changes the heap, and refuses with `IC.refuse`. The canister
        # rolls all three back, and `read` shows what is left: the seeded values only.
        # Script: yes
        #
        # Ported from ruby_wasm_builder challenges/refusal_undoes (introduced 0.3.0, ported at 0.6.0).
        REFUSAL_UNDOES_KEPT = File.join(IC::DATA_DIR, "refusal_undoes_kept.txt")
        REFUSAL_UNDOES_NEW  = File.join(IC::DATA_DIR, "refusal_undoes_new.txt")
        
        $refusal_undoes_heap = 0
        
        def refusal_undoes_seed
          File.write(REFUSAL_UNDOES_KEPT, "before")
          $refusal_undoes_heap = 1
          "seeded"
        end
        
        def refusal_undoes_write_then_refuse
          File.write(REFUSAL_UNDOES_KEPT, "after")
          File.write(REFUSAL_UNDOES_NEW, "made")
          $refusal_undoes_heap = 2
          IC.refuse("no")
        end
        
        def refusal_undoes_read
          "kept=#{File.read(REFUSAL_UNDOES_KEPT)} new=#{File.exist?(REFUSAL_UNDOES_NEW) ? File.read(REFUSAL_UNDOES_NEW) : 'absent'} heap=#{$refusal_undoes_heap}"
        end
        

        A query keeps nothing since 0.5.0

        `scribble` is a query. It writes a byte to a file and counts in the heap, then reports what it sees, so two in a row each see exactly one: whatever a query writes is thrown away. The update `look` then sees none, even after a refusal in a query. `scribble` also reads the canister's clock against the time `look` kept, and `who` returns the caller. (The builder's version also moves the clock on an hour and calls `who` as a second caller; a live network can do neither, so `hours` reads 0 here.)

        Ruby source
        # A query keeps nothing
        #
        # `scribble` is a query. It writes a byte to a file and counts in the heap, then
        # reports what it sees, so two in a row each see exactly one: whatever a query
        # writes is thrown away. The update `look` then sees none, even after a refusal
        # in a query. `scribble` also reads the canister's clock against the time `look`
        # kept, and `who` returns the caller. (The builder's version also moves the
        # clock on an hour and calls `who` as a second caller; a live network can do
        # neither, so `hours` reads 0 here.)
        # Script: yes
        #
        # Ported from ruby_wasm_builder challenges/query_discards (introduced 0.5.0, ported at 0.6.0).
        QUERY_DISCARDS_NOTES = File.join(IC::DATA_DIR, "query_discards_notes")
        QUERY_DISCARDS_HOUR  = 3600 * 1_000_000_000
        
        $query_discards_heap = 0
        $query_discards_looked_at = nil
        
        def query_discards_notes_size = File.exist?(QUERY_DISCARDS_NOTES) ? File.size(QUERY_DISCARDS_NOTES) : 0
        
        def query_discards_look
          $query_discards_looked_at = IC.time
          "file=#{query_discards_notes_size} heap=#{$query_discards_heap}"
        end
        
        def query_discards_scribble
          File.write(QUERY_DISCARDS_NOTES, "q", mode: "a")
          $query_discards_heap += 1
          "file=#{query_discards_notes_size} heap=#{$query_discards_heap} hours=#{(IC.time - $query_discards_looked_at) / QUERY_DISCARDS_HOUR}"
        end
        
        def query_discards_refuse_in_query
          File.write(QUERY_DISCARDS_NOTES, "q", mode: "a")
          IC.refuse("a query keeps nothing")
        end
        
        def query_discards_who = IC.caller.to_s
        

        Candid

        Every Candid primitive round-trips since 0.6.0

        Each `echo_*` method returns its argument unchanged, so a value that comes back different would show a decoding or encoding fault. The values sit at the edges: each sized integer's limits, numbers past 2^30 and 2^70, -0.0 and 1e308, a float32 that has to round, multibyte text with a NUL, every byte in a blob, empty and long vecs, a nested record, and a variant with and without a payload. `describe` also reports which Ruby type each field arrives as, which an echo cannot see.

        Ruby source
        # Every Candid primitive round-trips
        #
        # Each `echo_*` method returns its argument unchanged, so a value that comes back
        # different would show a decoding or encoding fault. The values sit at the edges:
        # each sized integer's limits, numbers past 2^30 and 2^70, -0.0 and 1e308, a
        # float32 that has to round, multibyte text with a NUL, every byte in a blob,
        # empty and long vecs, a nested record, and a variant with and without a payload.
        # `describe` also reports which Ruby type each field arrives as, which an echo
        # cannot see.
        # Cases: yes
        #
        # Ported from ruby_wasm_builder challenges/candid_primitives (introduced 0.6.0, ported at 0.6.0).
        %i[nat nat8 nat16 nat32 nat64 int int8 int16 int32 int64 float32 float64 bool text null reserved
           principal blob opt vec record variant].each do |t|
          Object.send(:define_method, :"candid_primitives_echo_#{t}") { |v| v }
          Object.send(:private, :"candid_primitives_echo_#{t}")
        end
        
        def candid_primitives_take_empty(_) = "unreachable"
        
        def candid_primitives_kind_of_value(v)
          case v
          when nil then "nil"
          when String then "String/#{v.encoding}"
          when Hash then "Hash/#{v.keys.map { |k| k.class.name }.uniq.join('+')}"
          else v.class.name
          end
        end
        
        def candid_primitives_describe(e) = e.map { |k, v| "#{k}=#{candid_primitives_kind_of_value(v)}" }.join(" ")
        

        A reply that doesn't fit its declared type is a fault since 0.6.0

        Each method returns a value that breaks one rule of Candid's strict encoding: a number out of range, a Float or String for an Integer, an extra or missing record field, an unknown variant case, bytes that aren't UTF-8. The call then faults with a message naming the method, the path and the type. Two things are allowed: an Integer where a float is declared, and an absent optional field. The reply is encoded after the handler has returned, so a file the handler wrote is still undone when the encoding faults.

        Ruby source
        # A reply that doesn't fit its declared type is a fault
        #
        # Each method returns a value that breaks one rule of Candid's strict encoding:
        # a number out of range, a Float or String for an Integer, an extra or missing
        # record field, an unknown variant case, bytes that aren't UTF-8. The call then
        # faults with a message naming the method, the path and the type. Two things are
        # allowed: an Integer where a float is declared, and an absent optional field.
        # The reply is encoded after the handler has returned, so a file the handler
        # wrote is still undone when the encoding faults.
        # Cases: yes
        #
        # Ported from ruby_wasm_builder challenges/candid_strict_reply (introduced 0.6.0, ported at 0.6.0).
        CANDID_STRICT_REPLY_FILE = File.join(IC::DATA_DIR, "candid_strict_reply")
        
        def candid_strict_reply_as_nat8 = 300
        
        def candid_strict_reply_as_nat(which) = { "float" => 3.0, "string" => "3" }.fetch(which)
        
        def candid_strict_reply_as_record(which)
          { "extra" => { a: 1, b: nil, c: 2 }, "string_key" => { "a" => 1 }, "missing" => { b: "x" },
            "absent_opt" => { a: 1 } }.fetch(which)
        end
        
        def candid_strict_reply_as_variant(which) = { "unknown" => { nope: 1 }, "bare" => :ok }.fetch(which)
        
        def candid_strict_reply_as_text = "\xff".b
        
        def candid_strict_reply_as_principal = IC.caller.to_s
        
        def candid_strict_reply_as_empty = nil
        
        def candid_strict_reply_as_float = 3
        
        def candid_strict_reply_report = { items: [{ amount: 1 }, { amount: 2 }, { amount: 300 }] }
        
        def candid_strict_reply_write_then_bad
          File.write(CANDID_STRICT_REPLY_FILE, "written")
          300
        end
        
        def candid_strict_reply_written = File.exist?(CANDID_STRICT_REPLY_FILE)
        

        Candid subtyping on the way in command line, since 0.6.0

        A canister decodes what it receives by the types it declared, and Candid's subtyping decides what is accepted. An extra record field is skipped, a missing optional field is null, a `nat` is accepted where an `int` is declared, a known variant case is accepted, and an `opt` whose content doesn't fit becomes null. What the rules refuse is refused with a reason: a missing required field, an `int` where a `nat` is declared, an unknown variant case. Each rule has a line it accepts and a line it refuses. Each method echoes what it decoded.

        A browser can only send values that fit the declared types, so these are run with icp canister call against your local network (rake network:start && rake deploy).

        Ruby source
        # Candid subtyping on the way in
        #
        # A canister decodes what it receives by the types it declared, and Candid's
        # subtyping decides what is accepted. An extra record field is skipped, a missing
        # optional field is null, a `nat` is accepted where an `int` is declared, a known
        # variant case is accepted, and an `opt` whose content doesn't fit becomes null.
        # What the rules refuse is refused with a reason: a missing required field, an
        # `int` where a `nat` is declared, an unknown variant case. Each rule has a line
        # it accepts and a line it refuses. Each method echoes what it decoded.
        # Cli: only
        #
        # Ported from ruby_wasm_builder challenges/candid_subtyping (introduced 0.6.0, ported at 0.6.0).
        def candid_subtyping_pair(p) = p
        def candid_subtyping_full(f) = f
        def candid_subtyping_widen(i) = i
        def candid_subtyping_narrow(n) = n
        def candid_subtyping_choose(c) = c
        def candid_subtyping_maybe(o) = o
        def candid_subtyping_two(first, second) = { first: first, second: second }
        
        1. An extra record field is checked and skipped sends record { a = 1; b = opt "x"; c = true } : record { a : nat; b : opt text; c : bool }
          icp canister call backend candid_subtyping_pair --args-format hex '4449444c026e716c03617d6200637e01010101017801' -e local

          Expected: (record { a = 1 : nat; b = opt "x" })

        2. A missing opt field is null sends record { a = 2 } : record { a : nat }
          icp canister call backend candid_subtyping_pair --args-format hex '4449444c016c01617d010002' -e local

          Expected: (record { a = 2 : nat; b = null })

        3. A missing field that is not opt is refused sends record { a = 3 } : record { a : nat }
          icp canister call backend candid_subtyping_full --args-format hex '4449444c016c01617d010003' -e local

          Expected: refused: cannot decode candid_subtyping_full's argument 1 at .b (text): missing

        4. A nat where an int is declared sends 5 : nat
          icp canister call backend candid_subtyping_widen --args-format hex '4449444c00017d05' -e local

          Expected: (5 : int)

        5. An int where a nat is declared is refused sends 5 : int
          icp canister call backend candid_subtyping_narrow --args-format hex '4449444c00017c05' -e local

          Expected: refused: cannot decode candid_subtyping_narrow's argument 1 (nat): the caller sent int

        6. A known variant case sends variant { y = "ok" } : variant { y : text }
          icp canister call backend candid_subtyping_choose --args-format hex '4449444c016b017971010000026f6b' -e local

          Expected: (variant { y = "ok" })

        7. An unknown variant case is refused sends variant { z = 1 } : variant { z : nat }
          icp canister call backend candid_subtyping_choose --args-format hex '4449444c016b017a7d01000001' -e local

          Expected: refused: cannot decode candid_subtyping_choose's argument 1 (CandidSubtypingChoice): the case with hash 122 is not one of its cases

        8. An opt whose content does not fit is null sends opt -1 : opt int
          icp canister call backend candid_subtyping_maybe --args-format hex '4449444c016e7c0100017f' -e local

          Expected: (null)

        9. A bare value that fits is the opt sends 5 : nat
          icp canister call backend candid_subtyping_maybe --args-format hex '4449444c00017d05' -e local

          Expected: (opt (5 : nat))

        10. An extra argument is skipped sends 1 : nat, 2 : nat, 3 : nat
          icp canister call backend candid_subtyping_two --args-format hex '4449444c00037d7d7d010203' -e local

          Expected: (record { first = 1 : nat; second = opt (2 : nat) })

        11. A missing opt argument is null sends 1 : nat
          icp canister call backend candid_subtyping_two --args-format hex '4449444c00017d01' -e local

          Expected: (record { first = 1 : nat; second = null })

        12. Any other missing argument is refused sends (no arguments)
          icp canister call backend candid_subtyping_two --args-format hex '4449444c0000' -e local

          Expected: refused: cannot decode candid_subtyping_two's argument 1 (nat): missing

        Hostile Candid is refused before it runs command line, since 0.6.0

        A caller can send anything. A message that isn't Candid, that ends early, that claims a text of 4 GB, that nests types 33 deep or loops, or that puts a value where none can fit, ends in a refusal with an exact reason, before anything is allocated and before the handler runs. Every handler counts the calls it handled, so a final `count` shows no hostile call reached one and the canister still answers.

        A browser can only send values that fit the declared types, so these are run with icp canister call against your local network (rake network:start && rake deploy). The counts assume a fresh canister.

        Ruby source
        # Hostile Candid is refused before it runs
        #
        # A caller can send anything. A message that isn't Candid, that ends early, that
        # claims a text of 4 GB, that nests types 33 deep or loops, or that puts a value
        # where none can fit, ends in a refusal with an exact reason, before anything is
        # allocated and before the handler runs. Every handler counts the calls it
        # handled, so a final `count` shows no hostile call reached one and the canister
        # still answers.
        # Stateful: yes
        # Cli: only
        #
        # Ported from ruby_wasm_builder challenges/candid_refusals (introduced 0.6.0, ported at 0.6.0).
        $candid_refusals_handled = 0
        
        def candid_refusals_text(_) = ($candid_refusals_handled += 1)
        def candid_refusals_nums(_) = ($candid_refusals_handled += 1)
        def candid_refusals_small(_) = ($candid_refusals_handled += 1)
        def candid_refusals_void(_) = ($candid_refusals_handled += 1)
        def candid_refusals_count = $candid_refusals_handled
        
        1. A valid call first
          icp canister call backend candid_refusals_text '("first")' -e local

          Expected: (1 : nat)

        2. Not Candid at all
          icp canister call backend candid_refusals_text --args-format hex '5844494c0000' -e local

          Expected: refused: cannot decode candid_refusals_text's arguments: not a Candid message: it does not start with DIDL

        3. Cut short
          icp canister call backend candid_refusals_text --args-format hex '4449444c000171' -e local

          Expected: refused: cannot decode candid_refusals_text's argument 1: the message ends early, at byte 7

        4. Bytes after the last value
          icp canister call backend candid_refusals_text --args-format hex '4449444c0001710178ff' -e local

          Expected: refused: cannot decode candid_refusals_text's arguments: 1 byte(s) after the last value

        5. A text that lies about its length
          icp canister call backend candid_refusals_text --args-format hex '4449444c0001710578' -e local

          Expected: refused: cannot decode candid_refusals_text's argument 1: a text claims 5 bytes, more than the 1 byte(s) left

        6. A vec that claims 4 billion elements
          icp canister call backend candid_refusals_nums --args-format hex '4449444c016d7d0100ffffffff0f' -e local

          Expected: refused: cannot decode candid_refusals_nums's argument 1: a vec claims 4294967295 elements, more than the 0 byte(s) left

        7. Zero-size elements past 10,000
          icp canister call backend candid_refusals_nums --args-format hex '4449444c026d7d6d7f02000100914e' -e local

          Expected: refused: cannot decode candid_refusals_nums's argument 2: a vec of zero-size elements claims more than 10000

        8. A number past 10 LEB128 bytes
          icp canister call backend candid_refusals_nums --args-format hex '4449444c016d7d0100018080808080808080808001' -e local

          Expected: refused: cannot decode candid_refusals_nums's argument 1: a LEB128 number runs past 10 bytes

        9. A nat for a nat8
          icp canister call backend candid_refusals_small --args-format hex '4449444c00017dac02' -e local

          Expected: refused: cannot decode candid_refusals_small's argument 1 (nat8): the caller sent nat

        10. A text that is not UTF-8
          icp canister call backend candid_refusals_text --args-format hex '4449444c00017101ff' -e local

          Expected: refused: cannot decode candid_refusals_text's argument 1 (text): its bytes are not valid UTF-8

        11. A value for empty
          icp canister call backend candid_refusals_void --args-format hex '4449444c00016f' -e local

          Expected: refused: cannot decode candid_refusals_void's argument 1 (empty): no value fits empty

        12. A cyclic type table
          icp canister call backend candid_refusals_nums --args-format hex '4449444c016e00010000' -e local

          Expected: refused: cannot decode candid_refusals_nums's arguments: the type table has a cycle, and recursive types are not accepted

        13. A cyclic type table, only in an extra argument that would be skipped
          icp canister call backend candid_refusals_text --args-format hex '4449444c016e00027100017800' -e local

          Expected: refused: cannot decode candid_refusals_text's arguments: the type table has a cycle, and recursive types are not accepted

        14. A type table nested 33 deep
          icp canister call backend candid_refusals_nums --args-format hex '4449444c216d016d026d036d046d056d066d076d086d096d0a6d0b6d0c6d0d6d0e6d0f6d106d116d126d136d146d156d166d176d186d196d1a6d1b6d1c6d1d6d1e6d1f6d206d7d010000' -e local

          Expected: refused: cannot decode candid_refusals_nums's arguments: the type table nests deeper than 32

        15. A type table nested 33 deep, only in a skipped extra argument
          icp canister call backend candid_refusals_text --args-format hex '4449444c216d016d026d036d046d056d066d076d086d096d0a6d0b6d0c6d0d6d0e6d0f6d106d116d126d136d146d156d166d176d186d196d1a6d1b6d1c6d1d6d1e6d1f6d206d7d027100017800' -e local

          Expected: refused: cannot decode candid_refusals_text's arguments: the type table nests deeper than 32

        16. Still answering
          icp canister call backend candid_refusals_nums '(vec { 1; 2 })' -e local

          Expected: (2 : nat)

        17. No hostile call reached a handler: two were handled
          icp canister call backend candid_refusals_count '()' -e local

          Expected: (2 : nat)

        Who is calling

        Who is calling since 0.5.0

        `whoami` replies with the caller's principal as text. `remember` keeps one caller's principal as bytes, and `compare` reads it back and says how the current caller relates to it: equal (`==`), usable as the same Hash key, anonymous or not, and whether its text form round-trips. A different authenticated caller must say `same=false`; if every caller arrived as one principal it would say `true`.

        Ruby source
        # Who is calling
        #
        # `whoami` replies with the caller's principal as text. `remember` keeps one
        # caller's principal as bytes, and `compare` reads it back and says how the
        # current caller relates to it: equal (`==`), usable as the same Hash key,
        # anonymous or not, and whether its text form round-trips. A different
        # authenticated caller must say `same=false`; if every caller arrived as one
        # principal it would say `true`.
        # Script: yes
        #
        # Ported from ruby_wasm_builder challenges/caller (introduced 0.5.0, ported at 0.6.0).
        CALLER_REMEMBERED = File.join(IC::DATA_DIR, "caller_remembered")
        
        def caller_whoami = IC.caller.to_s
        
        def caller_remember
          File.binwrite(CALLER_REMEMBERED, IC.caller.bytes)
          "remembered"
        end
        
        def caller_compare
          caller = IC.caller
          stored = IC::Principal.new(File.binread(CALLER_REMEMBERED))
          "same=#{caller == stored} key=#{{ stored => 1 }.key?(caller)} anonymous=#{caller.anonymous?} " \
            "roundtrip=#{IC::Principal.from_text(caller.to_s) == caller}"
        end
        

        Who controls the canister since 0.5.0

        A controller is an identity that may manage the canister: here, whoever deployed it. `is_controller` says whether the caller is one, and `guarded` is a guard that refuses everyone else. `booted_by` is the principal that installed (or last upgraded) the canister, recorded each time the VM starts. A browser never holds your deployer key, so the browser steps show the not-a-controller side and the commands below show the controller side.

        Ruby source
        # Who controls the canister
        #
        # A controller is an identity that may manage the canister: here, whoever
        # deployed it. `is_controller` says whether the caller is one, and `guarded` is
        # a guard that refuses everyone else. `booted_by` is the principal that
        # installed (or last upgraded) the canister, recorded each time the VM starts.
        # A browser never holds your deployer key, so the browser steps show the
        # not-a-controller side and the commands below show the controller side.
        # Script: yes
        # Cli: yes
        #
        # Ported from ruby_wasm_builder challenges/controller (introduced 0.5.0, ported at 0.6.0).
        $controller_booted_by = IC.caller
        
        def controller_booted_by = $controller_booted_by.to_s
        
        def controller_is_controller = "controller=#{IC.caller.controller?}"
        
        def controller_guarded
          IC.caller.controller? || IC.refuse("only a controller may do this")
          "allowed"
        end
        

        The controller is whoever deployed the canister, which a browser can't be, so run these with icp canister call against your local network (rake network:start && rake deploy); the default identity is the controller.

        1. The controller may pass the guard
          icp canister call backend controller_guarded '()' -e local

          Expected: ("allowed")

        2. You are a controller
          icp canister call backend controller_is_controller '()' -e local

          Expected: ("controller=true")

        3. Anonymous is refused by the same guard
          icp canister call backend controller_guarded '()' -e local --identity anonymous

          Expected: refused: only a controller may do this

        4. Who installed the canister
          icp canister call backend controller_booted_by '()' -e local

          Then: your own principal; compare with `icp identity principal`

        State and time

        What survives an upgrade since 0.3.0

        Each call counts twice: in a Ruby global (the heap) and in a file. Upgrade the canister yourself with the command below and call again. The heap is discarded by an upgrade, so it starts again at 1; the file lives in stable memory, so its count carries on. State you want to keep belongs in a file.

        Ruby source
        # What survives an upgrade
        #
        # Each call counts twice: in a Ruby global (the heap) and in a file. Upgrade the
        # canister yourself with the command below and call again. The heap is discarded
        # by an upgrade, so it starts again at 1; the file lives in stable memory, so
        # its count carries on. State you want to keep belongs in a file.
        # Stateful: yes
        # Cli: yes
        #
        # Ported from ruby_wasm_builder challenges/upgrade (introduced 0.3.0, ported at 0.6.0).
        UPGRADE_FILE = File.join(IC::DATA_DIR, "upgrade_n")
        
        $upgrade_heap = 0
        
        def upgrade_main
          $upgrade_heap += 1
          n = (File.exist?(UPGRADE_FILE) ? File.read(UPGRADE_FILE).to_i : 0) + 1
          File.write(UPGRADE_FILE, n.to_s)
          "n=#{n} heap=#{$upgrade_heap}"
        end
        

          Then, in a terminal (the local network from rake network:start):

          1. Upgrade the canister with the same module
            icp deploy backend -e local

            Then: the heap count starts again at 1, and the file count carries on

          Demo-scale state survives an upgrade since 0.4.0

          1,000 files of 1 KiB and a 10,000-line log are written in batches, then the canister is upgraded. Every boot, including the upgrade's, rebuilds an index of all of them, and `check` reports how many files and lines are intact and how many each index found at the last boot: 0 before an upgrade, 1000 and 10000 after. Updating one file and appending to a log are single cheap calls. Load 10 batches of each (×10 does that for you), then upgrade with the command below and `check` again. The log stops at 10,100 lines so it can't grow forever.

          Ruby source
          # Demo-scale state survives an upgrade
          #
          # 1,000 files of 1 KiB and a 10,000-line log are written in batches, then the
          # canister is upgraded. Every boot, including the upgrade's, rebuilds an index
          # of all of them, and `check` reports how many files and lines are intact and
          # how many each index found at the last boot: 0 before an upgrade, 1000 and
          # 10000 after. Updating one file and appending to a log are single cheap calls.
          # Load 10 batches of each (×10 does that for you), then upgrade with the command
          # below and `check` again. The log stops at 10,100 lines so it can't grow forever.
          # Stateful: yes
          # Methods: load_units, append_lines, check, rewrite_one, append_one
          # Repeat: load_units, append_lines
          # Cli: yes
          #
          # Ported from ruby_wasm_builder challenges/persist_both (introduced 0.4.0, ported at 0.6.0).
          PERSIST_BOTH_UNITS      = File.join(IC::DATA_DIR, "persist_both_units")
          PERSIST_BOTH_LOG        = File.join(IC::DATA_DIR, "persist_both_log.txt")
          PERSIST_BOTH_PATTERN    = "abcdefghijklmnopqrstuvwxyz0123456789"
          PERSIST_BOTH_FILES      = 1000
          PERSIST_BOTH_UNIT_BATCH = 100
          PERSIST_BOTH_LINES      = 10_000
          PERSIST_BOTH_LINES_MAX  = 10_100
          PERSIST_BOTH_LINE_BATCH = 1_000
          PERSIST_BOTH_WIDTH      = 256
          
          def persist_both_rotated(i)
            r = i % PERSIST_BOTH_PATTERN.size
            PERSIST_BOTH_PATTERN[r..] + PERSIST_BOTH_PATTERN[0, r]
          end
          
          def persist_both_unit_name(i) = format("unit-%04d.dat", i)
          def persist_both_unit_bytes(i) = format("%04d:", i) + (persist_both_rotated(i) * 29)[0, 1019]
          def persist_both_unit_names = Dir.exist?(PERSIST_BOTH_UNITS) ? Dir.children(PERSIST_BOTH_UNITS) : []
          
          def persist_both_log_line(i) = format("%05d ", i) + (persist_both_rotated(i) * 7)[0, 249] + "\n"
          def persist_both_lines_written = File.exist?(PERSIST_BOTH_LOG) ? File.size(PERSIST_BOTH_LOG) / PERSIST_BOTH_WIDTH : 0
          
          # Both indexes on every boot; no call changes either.
          $persist_both_unit_index = {}
          persist_both_unit_names.each { |n| $persist_both_unit_index[n] = File.read(File.join(PERSIST_BOTH_UNITS, n))[0, 4] }
          $persist_both_line_index = {}
          if File.exist?(PERSIST_BOTH_LOG)
            File.foreach(PERSIST_BOTH_LOG).with_index { |line, n| $persist_both_line_index[line[0, 5]] = n }
          end
          
          def persist_both_load_units
            Dir.mkdir(PERSIST_BOTH_UNITS) unless Dir.exist?(PERSIST_BOTH_UNITS)
            from = persist_both_unit_names.size
            (from...[from + PERSIST_BOTH_UNIT_BATCH, PERSIST_BOTH_FILES].min).each do |i|
              File.write(File.join(PERSIST_BOTH_UNITS, persist_both_unit_name(i)), persist_both_unit_bytes(i))
            end
            "files=#{persist_both_unit_names.size}"
          end
          
          def persist_both_append_lines
            from = persist_both_lines_written
            File.open(PERSIST_BOTH_LOG, "a") do |f|
              f.write((from...[from + PERSIST_BOTH_LINE_BATCH, PERSIST_BOTH_LINES].min).map { |i| persist_both_log_line(i) }.join)
            end
            "lines=#{persist_both_lines_written}"
          end
          
          def persist_both_check
            files = (0...PERSIST_BOTH_FILES).count do |i|
              path = File.join(PERSIST_BOTH_UNITS, persist_both_unit_name(i))
              File.exist?(path) && File.read(path) == persist_both_unit_bytes(i)
            end
            lines = File.exist?(PERSIST_BOTH_LOG) ? File.read(PERSIST_BOTH_LOG).lines : []
            intact = lines.each_with_index.count { |line, i| line == persist_both_log_line(i) }
            "files=#{persist_both_unit_names.size} intact=#{files} lines=#{lines.size} intact=#{intact} " \
              "boot_index=#{$persist_both_unit_index.size}/#{$persist_both_line_index.size}"
          end
          
          def persist_both_rewrite_one
            File.write(File.join(PERSIST_BOTH_UNITS, persist_both_unit_name(500)), persist_both_unit_bytes(500))
            "rewrote unit-0500"
          end
          
          def persist_both_append_one
            IC.refuse("the log is full (#{PERSIST_BOTH_LINES_MAX} lines)") if persist_both_lines_written >= PERSIST_BOTH_LINES_MAX
            File.open(PERSIST_BOTH_LOG, "a") { |f| f.write(persist_both_log_line(persist_both_lines_written)) }
            "lines=#{persist_both_lines_written}"
          end
          

            Then, in a terminal (the local network from rake network:start):

            1. Upgrade the canister with the same module (it re-reads every file and line at boot, so allow about ten seconds)
              icp deploy backend -e local

              Then: press check again: boot_index now reads 1000/10000

            The Internet Computer's clock since 0.5.0

            `IC.time` is an Integer of nanoseconds that never goes backwards, and `Time.now` reads the same clock within a message. `date` is the canister's date, which should be today's. `start` then `lapsed` measures a lapse of a day in Ruby: `lapsed` reads false right after `start`, and only true once a real 24 hours have passed. (The builder's version sets and advances the clock; a live network can't, and `lapsed` refuses until `start` has been called, so a call out of order is a clear message and not an error.)

            Ruby source
            # The Internet Computer's clock
            #
            # `IC.time` is an Integer of nanoseconds that never goes backwards, and
            # `Time.now` reads the same clock within a message. `date` is the canister's
            # date, which should be today's. `start` then `lapsed` measures a lapse of a day
            # in Ruby: `lapsed` reads false right after `start`, and only true once a real
            # 24 hours have passed. (The builder's version sets and advances the clock; a
            # live network can't, and `lapsed` refuses until `start` has been called, so a
            # call out of order is a clear message and not an error.)
            # Script: yes
            #
            # Ported from ruby_wasm_builder challenges/time (introduced 0.5.0, ported at 0.6.0).
            TIME_NANOS = 1_000_000_000
            TIME_DAY   = 24 * 3600 * TIME_NANOS
            
            $time_last  = 0
            $time_start = nil
            
            def time_tick
              t = IC.time
              now = Time.now
              forward = t >= $time_last
              $time_last = t
              "integer=#{t.is_a?(Integer)} forward=#{forward} time_now=#{now.to_i * TIME_NANOS + now.nsec == t}"
            end
            
            def time_date = Time.at(IC.time / TIME_NANOS).utc.strftime("%Y-%m-%dT%H:%M:%SZ")
            
            def time_start
              $time_start = IC.time
              "started"
            end
            
            def time_lapsed
              IC.refuse("call start first") unless $time_start
              "lapsed=#{IC.time - $time_start >= TIME_DAY}"
            end
            

            Web apps over HTTP

            Web pages straight from the canister web, since 0.10.0

            The canister answers the IC's HTTP gateway itself, so a browser can open its pages, with no frontend in between. The gateway calls `http_request` as a query. A query keeps nothing it writes, so anything that changes state replies `upgrade: true`, and the gateway calls `http_request_update` instead, through consensus. Headers arrive and leave as [name, value] pairs.

            The whole interface is one line in ic-interface.rb, `http_gateway certification: :skip` (0.10.0). It declares the gateway's types and both methods, and serves every page on the canister's normal domain, unverified: the canister certifies that no reply needs checking. The same app ran in 0.7.0's `http_request` Challenge with those types written out by hand, and in 0.8.0's `http_gateway_word` with the bare word `http_gateway`, which serves only the raw domain. A canister has one declaration, and this one's is :skip. Its routes live under /challenges/http_certified_skip/ here; the rows below call the two methods as the gateway would, and each GET opens through the real gateway. `/visits` counts across every visitor and resets on upgrade.

            Ruby source
            # Web pages straight from the canister
            #
            # The canister answers the IC's HTTP gateway itself, so a browser can open its
            # pages, with no frontend in between. The gateway calls `http_request` as a
            # query. A query keeps nothing it writes, so anything that changes state
            # replies `upgrade: true`, and the gateway calls `http_request_update` instead,
            # through consensus. Headers arrive and leave as [name, value] pairs.
            #
            # The whole interface is one line in ic-interface.rb, `http_gateway
            # certification: :skip` (0.10.0). It declares the gateway's types and both
            # methods, and serves every page on the canister's normal domain, unverified:
            # the canister certifies that no reply needs checking. The same app ran in
            # 0.7.0's `http_request` Challenge with those types written out by hand, and
            # in 0.8.0's `http_gateway_word` with the bare word `http_gateway`, which
            # serves only the raw domain. A canister has one declaration, and this one's is
            # :skip. Its routes live under /challenges/http_certified_skip/ here; the rows
            # below call the two methods as the gateway would, and each GET opens through
            # the real gateway. `/visits` counts across every visitor and resets on upgrade.
            # Stateful: yes
            # Http: yes
            #
            # Ported from ruby_wasm_builder challenges/http_certified_skip (introduced 0.10.0, ported at 0.10.0).
            $http_certified_skip_visits = 0
            
            module HttpCertifiedSkip
              TEXT = [["content-type", "text/plain"]].freeze
            
              module_function
            
              def reply(status, body, headers = TEXT) = { status_code: status, headers: headers, body: body, upgrade: nil }
            
              def header(req, name) = req[:headers].find { |n, _| n.downcase == name }&.last
            
              def query(req)
                path = req[:url].split("?", 2).first
                case [req[:method], path]
                in ["GET", "/hello"] then reply(200, "hello from Ruby")
                in ["GET", "/agent"] then reply(200, "agent=#{header(req, 'user-agent') || '-'}")
                in ["GET", "/visits"] then reply(200, "visits: #{$http_certified_skip_visits}")
                in [_, "/visit" | "/echo"] then { status_code: 200, headers: [], body: "", upgrade: true }
                in ["GET", "/bad-header"] then reply(200, "x", ["content-type: text/plain"])
                else reply(404, "not found: #{path}")
                end
              end
            
              def update(req)
                path = req[:url].split("?", 2).first
                case [req[:method], path]
                in ["POST", "/visit"] then reply(200, "visits: #{$http_certified_skip_visits += 1}")
                in ["POST", "/echo"] then reply(200, "#{req[:body].bytesize} bytes: #{req[:body]}")
                else reply(404, "not found: #{path}")
                end
              end
            end
            

            Rack with no Rack gem web, since 0.7.0

            Any Rack app (anything answering `call(env)` with `[status, headers, body]`) served through the IC's HTTP gateway by about a hundred lines of plain Ruby, `IcRack` below: no gem at all. It builds the env from the gateway's request (method, path, query, server, headers as HTTP_*), gives the body as rack.input through a small reader (StringIO is a C extension the `minimal` build lacks), splits a header with two values into two, and closes a body after reading it. A query can't keep a write, so GET, HEAD and OPTIONS are answered by the query, and anything else is upgraded to an update; a GET that writes asks for the upgrade with an `x-ic-upgrade` header, never sent on. The `ruby_wasm_ic_rack` gem is this adapter, packaged; the Story Page runs on it. Its routes live under /challenges/rack_adapter/ here. `/visits` and `/closed` count across every visitor and reset on upgrade.

            Ruby source
            # Rack with no Rack gem
            #
            # Any Rack app (anything answering `call(env)` with `[status, headers, body]`)
            # served through the IC's HTTP gateway by about a hundred lines of plain Ruby,
            # `IcRack` below: no gem at all. It builds the env from the gateway's request
            # (method, path, query, server, headers as HTTP_*), gives the body as
            # rack.input through a small reader (StringIO is a C extension the `minimal`
            # build lacks), splits a header with two values into two, and closes a body
            # after reading it. A query can't keep a write, so GET, HEAD and OPTIONS are
            # answered by the query, and anything else is upgraded to an update; a GET that
            # writes asks for the upgrade with an `x-ic-upgrade` header, never sent on.
            # The `ruby_wasm_ic_rack` gem is this adapter, packaged; the Story Page runs on
            # it. Its routes live under /challenges/rack_adapter/ here. `/visits` and
            # `/closed` count across every visitor and reset on upgrade.
            # Stateful: yes
            # Http: yes
            #
            # Ported from ruby_wasm_builder challenges/rack_adapter (introduced 0.7.0, ported at 0.10.0).
            $rack_adapter_visits = 0
            $rack_adapter_closed = 0
            
            module RackAdapter
              module IcRack
                SAFE = %w[GET HEAD OPTIONS].freeze
                UPGRADE = "x-ic-upgrade"
            
                # rack.input over the body's bytes: what Rack 3 asks of an input stream.
                class Input
                  def initialize(bytes)
                    @bytes = bytes.b
                    @pos = 0
                  end
            
                  def read(length = nil, buffer = nil)
                    rest = @bytes.byteslice(@pos, @bytes.bytesize - @pos) || "".b
                    chunk = length ? rest.byteslice(0, length) : rest
                    @pos += chunk.bytesize
                    return nil if length && length.positive? && chunk.empty?
            
                    buffer ? buffer.replace(chunk) : chunk
                  end
            
                  def gets
                    return nil if @pos >= @bytes.bytesize
            
                    stop = @bytes.index("\n", @pos)
                    line = @bytes.byteslice(@pos, (stop ? stop + 1 : @bytes.bytesize) - @pos)
                    @pos += line.bytesize
                    line
                  end
            
                  def each
                    while (line = gets)
                      yield line
                    end
                  end
            
                  def rewind = @pos = 0
                  def close = nil
                end
            
                module_function
            
                # rack.input over the body. An Application may redefine it (roda_on_minimal
                # gives a StringIO).
                def input(bytes) = Input.new(bytes)
            
                def env(req)
                  path, query = req[:url].split("?", 2)
                  host = header(req, "host").to_s
                  env = {
                    "REQUEST_METHOD" => req[:method],
                    "SCRIPT_NAME" => "",
                    "PATH_INFO" => path.to_s.empty? ? "/" : path,
                    "QUERY_STRING" => query.to_s,
                    "SERVER_NAME" => host.split(":").first.to_s,
                    "SERVER_PORT" => "443",
                    "SERVER_PROTOCOL" => "HTTP/1.1",
                    "rack.url_scheme" => "https",
                    "rack.input" => input(req[:body]),
                    "rack.errors" => $stderr
                  }
                  req[:headers].each do |name, value|
                    key = name.upcase.tr("-", "_")
                    key = "HTTP_#{key}" unless %w[CONTENT_TYPE CONTENT_LENGTH].include?(key)
                    env[key] = env.key?(key) ? "#{env[key]}, #{value}" : value
                  end
                  env
                end
            
                def header(req, name) = req[:headers].find { |n, _| n.downcase == name }&.last
            
                # The gateway's response record from a Rack response. A header's value may
                # be an Array, or lines joined by "\n" (Rack 3): one header each.
                def response(status, headers, body)
                  pairs = headers.flat_map do |name, value|
                    Array(value).flat_map { |v| v.to_s.split("\n") }.map { |v| [name.to_s.downcase, v] }
                  end
                  bytes = +"".b
                  body.each { |part| bytes << part.b }
                  { status_code: Integer(status), headers: pairs, body: bytes, upgrade: nil }
                ensure
                  body.close if body.respond_to?(:close)
                end
            
                def upgrade = { status_code: 200, headers: [], body: "".b, upgrade: true }
            
                # http_request: the query.
                def query(app, req)
                  return upgrade unless SAFE.include?(req[:method])
            
                  status, headers, body = app.call(env(req))
                  if headers.keys.any? { |k| k.to_s.downcase == UPGRADE }
                    body.close if body.respond_to?(:close)
                    return upgrade
                  end
                  response(status, headers, body)
                end
            
                # http_request_update: the update, after an upgrade.
                def update(app, req)
                  status, headers, body = app.call(env(req))
                  response(status, headers.reject { |k, _| k.to_s.downcase == UPGRADE }, body)
                end
              end
            
              # A body that counts when the adapter closes it.
              class Counted
                def initialize(parts) = @parts = parts
                def each(&) = @parts.each(&)
                def close = $rack_adapter_closed += 1
              end
            
              TEXT = { "content-type" => "text/plain" }.freeze
              ENV_KEYS = %w[REQUEST_METHOD SCRIPT_NAME PATH_INFO QUERY_STRING SERVER_NAME SERVER_PORT rack.url_scheme
                            HTTP_ACCEPT HTTP_X_BOOK CONTENT_TYPE].freeze
            
              APP = lambda do |env|
                case [env["REQUEST_METHOD"], env["PATH_INFO"]]
                in ["GET", "/env"]
                  [200, TEXT, [ENV_KEYS.map { |k| "#{k}=#{env[k]}" }.join(";")]]
                in ["GET", "/cookies"]
                  [200, TEXT.merge("set-cookie" => ["a=1", "b=2"], "x-lines" => "one\ntwo"), ["two cookies"]]
                in ["GET", "/closed"]
                  [200, TEXT, Counted.new(["closed=#{$rack_adapter_closed}"])]
                in ["GET", "/visits"]
                  [200, TEXT, ["visits=#{$rack_adapter_visits}"]]
                in ["GET", "/visit"]
                  [200, TEXT.merge("x-ic-upgrade" => "1"), ["visits=#{$rack_adapter_visits += 1}"]]
                in ["POST", "/books"]
                  input = env["rack.input"]
                  first = input.gets
                  rest = input.read
                  input.rewind
                  [201, TEXT, ["#{env['CONTENT_TYPE']} first=#{first.inspect} rest=#{rest.inspect} again=#{input.read(5)}"]]
                else
                  [404, TEXT, ["no route for #{env['REQUEST_METHOD']} #{env['PATH_INFO']}"]]
                end
              end
            
              def self.query(req) = IcRack.query(APP, req)
              def self.update(req) = IcRack.update(APP, req)
            end
            

            Roda in a canister web, since 0.7.0

            A Roda app, Books, served through the IC's HTTP gateway: routing, an Integer segment, query parameters (plain, percent-encoded UTF-8, and nested), a form post read from rack.input, a multipart form, the app's own 422, and the not_found plugin. Rack and Roda are ordinary gems, unmodified, and the `minimal` build's missing pieces (StringIO, StringScanner, Date and the Encoding constants, all C or built-in elsewhere) come from the `ruby_wasm_ic_minimal` gem. A GET is answered by the query; a POST is upgraded to an update, which keeps the new book for every visitor after it.

            The builder's Challenge carries trimmed copies of Rack and Roda and the stand-ins inside the app. Here they are Installed Gems, sent to the canister after it is deployed and loaded at the next upgrade (the same ones the Story Page uses), and the gateway glue is the `ruby_wasm_ic_rack` gem. Its routes live under /challenges/roda_on_minimal/ here. The book list is shared by every visitor and resets on upgrade.

            Ruby source
            # Roda in a canister
            #
            # A Roda app, Books, served through the IC's HTTP gateway: routing, an Integer
            # segment, query parameters (plain, percent-encoded UTF-8, and nested), a form
            # post read from rack.input, a multipart form, the app's own 422, and the
            # not_found plugin. Rack and Roda are ordinary gems, unmodified, and the
            # `minimal` build's missing pieces (StringIO, StringScanner, Date and the
            # Encoding constants, all C or built-in elsewhere) come from the
            # `ruby_wasm_ic_minimal` gem. A GET is answered by the query; a POST is upgraded
            # to an update, which keeps the new book for every visitor after it.
            #
            # The builder's Challenge carries trimmed copies of Rack and Roda and the
            # stand-ins inside the app. Here they are Installed Gems, sent to the canister
            # after it is deployed and loaded at the next upgrade (the same ones the Story
            # Page uses), and the gateway glue is the `ruby_wasm_ic_rack` gem. Its routes
            # live under /challenges/roda_on_minimal/ here. The book list is shared by
            # every visitor and resets on upgrade.
            # Stateful: yes
            # Http: yes
            #
            # Ported from ruby_wasm_builder challenges/roda_on_minimal (introduced 0.7.0, ported at 0.10.0).
            require "stringio" # ruby_wasm_ic_minimal's stand-in in the canister, the real one on MRI
            
            $roda_on_minimal_books = { 1 => { title: "Noli Me Tangere", author: "Rizal" } }
            
            module RodaOnMinimal
              class Books < Roda
                plugin :not_found do
                  response["content-type"] = "text/plain"
                  "no page at #{request.path}"
                end
            
                route do |r|
                  response["content-type"] = "text/plain"
            
                  r.on "books" do
                    r.get Integer do |id|
                      book = $roda_on_minimal_books[id] or next
                      "#{id}: #{book[:title]} (#{book[:author]})"
                    end
            
                    r.get "search" do
                      q = r.params["q"].to_s.downcase
                      hits = $roda_on_minimal_books.select { |_, b| b[:title].downcase.include?(q) }
                      "q=#{r.params['q']} page=#{r.params.fetch('page', '1')} hits=#{hits.keys.join(',')}"
                    end
            
                    r.get "filter" do
                      tags = r.params.fetch("tag", []).join(",")
                      "tags=#{tags} by.name=#{r.params.dig('by', 'name')}"
                    end
            
                    r.is do
                      r.get { $roda_on_minimal_books.map { |id, b| "#{id}: #{b[:title]}" }.join("\n") }
            
                      r.post do
                        title, author = r.params.values_at("title", "author")
                        if title.to_s.empty?
                          response.status = 422
                          next "title is required"
                        end
            
                        id = $roda_on_minimal_books.keys.max + 1
                        $roda_on_minimal_books[id] = { title: title, author: author }
                        response.status = 201
                        "created #{id}: #{title} (#{author}) via #{r.media_type}"
                      end
                    end
                  end
                end
              end
            
              # The Challenge reads rack.input as a StringIO, as under any Rack server; the
              # gem's own reader serves the Story Page, so this port wraps the env itself.
              def self.call_app(kind, req)
                env_app = ->(env) { Books.app.call(env.merge("rack.input" => StringIO.new(req[:body].b))) }
                kind == :query ? RubyWasmIcRack.query(env_app, req) : RubyWasmIcRack.update(env_app, req)
              end
            
              def self.query(req) = call_app(:query, req)
              def self.update(req) = call_app(:update, req)
            end
            

            An ever-growing story showcase, since 0.7.0

            One word at a time, a story that everyone shares, on a page the canister serves itself: a Roda app on Rack, through the IC's HTTP gateway, with no frontend in between and no JavaScript. Reading the story is a query. Adding a word is a form post, which the gateway upgrades to an update, and the page redirects back to the story. A word is 1 to 24 characters with no spaces, or \n to end a paragraph, and each browser may add one every five minutes; a word that breaks a rule comes back with the reason and is not written. The story keeps its last 5,000 words in a file, so it survives an upgrade.

            Ruby source
            # An ever-growing story
            #
            # One word at a time, a story that everyone shares, on a page the canister
            # serves itself: a Roda app on Rack, through the IC's HTTP gateway, with no
            # frontend in between and no JavaScript. Reading the story is a query. Adding a
            # word is a form post, which the gateway upgrades to an update, and the page
            # redirects back to the story. A word is 1 to 24 characters with no spaces, or
            # \n to end a paragraph, and each browser may add one every five minutes; a
            # word that breaks a rule comes back with the reason and is not written. The
            # story keeps its last 5,000 words in a file, so it survives an upgrade.
            # Page: yes
            #
            # Showcase for ruby_wasm_builder challenges/roda_on_minimal (introduced 0.7.0, written for 0.10.0).
            STORY_FILE     = File.join(IC::DATA_DIR, "story")
            STORY_MAX      = 5_000
            STORY_WORD_MAX = 24
            STORY_WINDOW   = 5 * 60 * 1_000_000_000 # nanoseconds
            STORY_BREAK    = "\\n"                  # the two characters backslash, n
            STORY_COOKIE   = "story_visitor"
            
            # Every request through the gateway comes from the anonymous principal, so a
            # Story Visitor is a browser, told apart by a cookie the page sets on its first
            # word. Anyone can clear it: the limit is a courtesy, not security. Both
            # tables live on the heap, so an upgrade forgets them.
            $story_last_added = {} # visitor id => IC.time of its last word
            $story_visitor_seq = 0
            
            module Story
              module_function
            
              # One word per line; a blank line is a paragraph break. Since 0.9.0 a file
              # read with no encoding is UTF-8, so a word's length counts characters.
              def words
                return [] unless File.exist?(STORY_FILE)
            
                File.read(STORY_FILE).split("\n", -1).tap { |w| w.pop if w.last == "" }
              end
            
              def paragraphs = words.chunk_while { |a, b| a != "" && b != "" }.map { |p| p.reject(&:empty?) }.reject(&:empty?)
            
              # Why `word` can't be added, or nil.
              def problem(word)
                return nil if word == STORY_BREAK
                return nil if word.match?(/\A\S{1,#{STORY_WORD_MAX}}\z/)
            
                "a word is 1 to #{STORY_WORD_MAX} characters with no spaces, or \\n to end a paragraph"
              end
            
              # Seconds until `visitor` may add another word, or nil if it may now.
              def wait(visitor, now)
                last = visitor && $story_last_added[visitor]
                return nil unless last && now - last < STORY_WINDOW
            
                ((STORY_WINDOW - (now - last)) / 1_000_000_000.0).ceil
              end
            
              # Only in an update: a query keeps nothing it writes.
              def add(word, visitor, now)
                all = words << (word == STORY_BREAK ? "" : word)
                File.write(STORY_FILE, all.last(STORY_MAX).join("\n") + "\n")
                $story_last_added.delete_if { |_, at| now - at >= STORY_WINDOW }
                $story_last_added[visitor] = now
              end
            
              # Unique within this canister's heap: the update's time, and a count of
              # visitors met since the last upgrade.
              def new_visitor(now) = "v#{now.to_s(36)}#{($story_visitor_seq += 1).to_s(36)}"
            
              def escape(text) = text.to_s.gsub(/[&<>"']/, "&" => "&amp;", "<" => "&lt;", ">" => "&gt;", '"' => "&quot;", "'" => "&#39;")
            end
            
            class StoryPage < Roda
              route do |r|
                visitor = r.cookies[STORY_COOKIE]
            
                r.root { page(visitor, added: r.params["added"]) }
                r.get("story") { page(visitor, added: r.params["added"]) }
            
                r.post "words" do
                  word = r.params["word"].to_s.strip
                  now = IC.time
                  fresh = visitor.nil? || visitor.empty?
                  visitor = Story.new_visitor(now) if fresh
                  if fresh
                    # Secure, except on a local network's plain-http *.localhost, where
                    # some browsers (and curl) would then never send it back.
                    secure = r.host.to_s.match?(/(\A|\.)localhost\z/) ? "" : "; Secure"
                    response["set-cookie"] = "#{STORY_COOKIE}=#{visitor}; Path=/; Max-Age=31536000; SameSite=Lax; HttpOnly#{secure}"
                  end
            
                  reason = Story.problem(word)
                  if reason.nil? && (seconds = Story.wait(visitor, now))
                    reason = "one word per browser every 5 minutes: try again in #{seconds}s"
                  end
                  if reason
                    response.status = 422
                    next page(visitor, error: reason, word: word)
                  end
            
                  Story.add(word, visitor, now)
                  r.redirect("/?added=1", 303)
                end
            
                response.status = 404
                response["content-type"] = "text/plain; charset=utf-8"
                "not found: #{r.path}"
              end
            
              STYLE = <<~CSS
                :root { --bg: #f2f2f2; --fg: #444; --heading: black; --action: #d64045; --muted: #777; }
                body { background: var(--bg); color: var(--fg); margin: 0 auto; padding: 0 16px; max-width: 42rem;
                  font: 108%/1.5 BlinkMacSystemFont, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; }
                h1 { margin: 2rem 0 1rem; text-align: center; font-weight: 900; font-size: 2.2rem; color: var(--heading); line-height: 1.2; }
                .rules, footer { color: var(--muted); font-size: 0.9rem; }
                .story { background: white; border-radius: 6px; padding: 0.5rem 1.25rem; margin: 1.5rem 0;
                  font-family: Georgia, "Times New Roman", serif; font-size: 1.15rem; }
                .story p { overflow-wrap: anywhere; }
                form { display: flex; gap: 0.5rem; flex-wrap: wrap; align-items: center; }
                input[type=text] { flex: 1 1 12rem; font: inherit; padding: 0.4rem 0.6rem; border: 1px solid #bbb; border-radius: 4px; }
                button { font: inherit; padding: 0.4rem 1rem; border: 0; border-radius: 4px; background: var(--action); color: white; cursor: pointer; }
                .error { color: var(--action); font-weight: 600; }
                .note { color: #2a7a3a; font-weight: 600; }
                footer { margin: 3rem 0 2rem; border-top: 1px solid #ddd; padding-top: 1rem; }
                code { font-size: 0.95em; }
              CSS
            
              private
            
              def page(visitor, added: nil, error: nil, word: nil)
                response["content-type"] = "text/html; charset=utf-8"
                paras = Story.paragraphs
                story = if paras.empty?
                          "<p><em>The story has not started. Add the first word.</em></p>"
                        else
                          paras.map { |p| "<p>#{Story.escape(p.join(' '))}</p>" }.join("\n")
                        end
                count = Story.words.count { |w| w != "" }
                wait = Story.wait(visitor, IC.time)
                status = if error then %(<p class="error" role="alert">Not added: #{Story.escape(error)}</p>)
                         elsif added then %(<p class="note" role="status">Your word is in.</p>)
                         elsif wait then %(<p class="rules" role="status">You can add your next word in #{wait}s.</p>)
                         else ""
                         end
                <<~HTML
                  <!doctype html>
                  <html lang="en">
                  <head>
                  <meta charset="utf-8">
                  <meta name="viewport" content="width=device-width, initial-scale=1">
                  <title>An ever-growing story</title>
                  <style>#{STYLE}</style>
                  </head>
                  <body>
                  <h1>An ever-growing story</h1>
                  <p class="rules">Add one word to a story everyone shares: 1 to #{STORY_WORD_MAX} characters with no spaces, or
                  <code>\\n</code> to end a paragraph. One word per browser every five minutes. #{count} #{count == 1 ? 'word' : 'words'} so far.</p>
                  <div class="story">
                  #{story}
                  </div>
                  #{status}
                  <form method="post" action="/words">
                    <label for="word">Your word</label>
                    <input id="word" name="word" type="text" autocomplete="off" required maxlength="#{STORY_WORD_MAX}" value="#{Story.escape(word)}">
                    <button type="submit">Add to the story</button>
                  </form>
                  <footer>
                    <p>This page is a Roda app on Rack, running in a Ruby canister on the Internet Computer and served straight
                    from it through the IC's HTTP gateway (ruby_wasm_builder 0.10.0). Rack and Roda are gems installed into the
                    canister after it was deployed.</p>
                    <p>Pages are served on the canister's normal domain but not certified (<code>certification: :skip</code>):
                    the gateway does not check that a reply came from the canister. This browser is told apart by a cookie, so
                    the five-minute limit is a courtesy, not security.</p>
                    <p>Part of the PhRUG ICP demo.</p>
                  </footer>
                  </body>
                  </html>
                HTML
              end
            end
            

            carried_stdlib, a challenge for apps that bring their own copy of a standard library file, is not shown: it was an intermediate step, and only matters to power users doing sophisticated customisation or optimisation. Nor are persist_files and persist_log: each fills the shared canister with thousands of files or lines and re-reads them all at every boot, which would slow every upgrade this demo does. Demo-scale state survives an upgrade, above, covers both. http_request (0.7.0) and http_gateway_word (0.8.0) run the same app as Web pages straight from the canister and differ only in how they declare the gateway; a canister has one declaration, so only the 0.10.0 one is shown.