machine.gno
8.04 Kb · 287 lines
1package bf
2
3import (
4 "errors"
5
6 "gno.land/p/moul/x/vm/vmkit/v0"
7)
8
9// VMName is the identifier this machine registers under in a
10// [vmkit.Instance].
11const VMName = "bf"
12
13// snapMagic and snapVersion tag a snapshot so a machine refuses bytes that
14// were not written by this machine at this version, instead of decoding them
15// into a plausible-looking wrong state.
16const (
17 snapMagic uint32 = 0x62660001 // "bf" 0001
18 snapVersion byte = 1
19)
20
21// ErrNoProgram is returned when a [Machine] is stepped without a program.
22var ErrNoProgram = errors.New("bf: machine has no program")
23
24// Machine is a compiled Brainfuck program, mid-execution: a
25// [vmkit.Machine] with a fixed tape, a fuel-metered step loop, input and
26// output through the host, and a snapshot small enough that pausing is
27// cheaper than starting over.
28//
29// The tape is a fixed-size array rather than a slice: that is rung 4 of the
30// ladder, and the README reports what it actually bought.
31type Machine struct {
32 prog *Program
33
34 tape [TapeSize]byte
35 ptr int
36 pc int
37 inPos int // read cursor into Host.Input, carried across slices
38
39 status vmkit.Status
40 trap string
41
42 // hi is the highest tape index written so far, so a snapshot can stop
43 // there instead of carrying 30,000 bytes of zeros.
44 hi int
45}
46
47// NewMachine returns a machine ready to run prog from the start.
48func NewMachine(prog *Program) *Machine {
49 return &Machine{prog: prog, status: vmkit.Running}
50}
51
52// Load compiles src at [Default] and returns a machine for it.
53func Load(src string) (*Machine, error) {
54 prog, err := CompileDefault(src)
55 if err != nil {
56 return nil, err
57 }
58 return NewMachine(prog), nil
59}
60
61// Program returns the program the machine is running.
62func (m *Machine) Program() *Program { return m.prog }
63
64// Status returns the machine's current status.
65func (m *Machine) Status() vmkit.Status { return m.status }
66
67// Trap returns the reason the machine trapped, or "". It makes Machine a
68// [vmkit.Trapper].
69func (m *Machine) Trap() string { return m.trap }
70
71// Pointer returns the tape pointer.
72func (m *Machine) Pointer() int { return m.ptr }
73
74// PC returns the index of the next op to execute.
75func (m *Machine) PC() int { return m.pc }
76
77// Cell returns the tape byte at i, wrapped into range.
78func (m *Machine) Cell(i int) byte { return m.tape[wrap(i)] }
79
80// Touched returns how many tape cells the program has reached, which is the
81// length a snapshot has to carry.
82func (m *Machine) Touched() int { return m.hi + 1 }
83
84// Step runs until the program halts, traps, or spends `fuel` units, charging
85// one unit per op executed and one per cell a scan walks over. Pass
86// [vmkit.Unmetered] to run to completion.
87//
88// Fuel is charged before the op runs, so a machine that stops for lack of
89// fuel has not half-executed anything: it is exactly at the op it could not
90// pay for, and resuming re-executes that op and nothing else.
91//
92// Three things in here are written the way they are because the alternative
93// was measured and lost. The README has the table.
94//
95// - The fuel counter is two locals, not a [vmkit.Meter]. Calling
96// Meter.Charge once per instruction costs 86% on top of the whole
97// dispatch loop and triples the allocation count. Meter stays the type
98// at the API boundary, where it is called once; it is not a hot-path
99// type, and no guest VM should treat it as one.
100// - The program counter and the tape pointer are locals, written back once
101// by the deferred closure. Reaching through m. for them on every
102// instruction costs 16%.
103// - The pc range check happens once, before the loop, not per op. Every
104// jump target is produced by [Compile] and every restored pc is
105// validated by [Machine.Restore], so per-op checking buys nothing.
106func (m *Machine) Step(h vmkit.Host, fuel int64) (int64, vmkit.Status) {
107 if m.prog == nil {
108 m.status, m.trap = vmkit.Trapped, "no program"
109 return 0, m.status
110 }
111 if m.status != vmkit.Running {
112 return 0, m.status
113 }
114
115 ops := m.prog.ops
116 pc, ptr, hi := m.pc, m.ptr, m.hi
117 // One write-back, on every return path, including a panic.
118 defer func() { m.pc, m.ptr, m.hi = pc, ptr, hi }()
119
120 if pc < 0 || pc >= len(ops) {
121 m.status, m.trap = vmkit.Trapped, "pc out of range"
122 return 0, m.status
123 }
124
125 // budget < 0 is vmkit.Unmetered.
126 budget := fuel
127 if budget < 0 {
128 budget = vmkit.Unmetered
129 }
130 var used int64
131
132 for {
133 if budget != vmkit.Unmetered && used >= budget {
134 return used, vmkit.Running
135 }
136 used++
137
138 switch ops[pc].code {
139 case opAdd:
140 m.tape[ptr] += byte(ops[pc].arg)
141 case opMove:
142 ptr = wrap(ptr + ops[pc].arg)
143 if ptr > hi {
144 hi = ptr
145 }
146 case opSet:
147 m.tape[ptr] = byte(ops[pc].arg)
148 case opAddMul:
149 if v := m.tape[ptr]; v != 0 {
150 t := wrap(ptr + ops[pc].off)
151 m.tape[t] += v * byte(ops[pc].arg)
152 if t > hi {
153 hi = t
154 }
155 }
156 case opScan:
157 // A scan is charged per cell, so it cannot be a way to
158 // buy unbounded work for one unit of fuel. Stopping
159 // mid-scan is safe: the pointer has moved, the pc has
160 // not, so resuming continues the same walk.
161 for m.tape[ptr] != 0 {
162 if budget != vmkit.Unmetered && used >= budget {
163 return used, vmkit.Running
164 }
165 used++
166 ptr = wrap(ptr + ops[pc].arg)
167 if ptr > hi {
168 hi = ptr
169 }
170 }
171 case opOut:
172 h.Output([]byte{m.tape[ptr]})
173 case opIn:
174 in := h.Input()
175 if m.inPos < len(in) {
176 m.tape[ptr] = in[m.inPos]
177 m.inPos++
178 } else {
179 // End of input is a zero cell, the most common
180 // of the three conventions the language never
181 // settled.
182 m.tape[ptr] = 0
183 }
184 case opJmpZ:
185 if m.tape[ptr] == 0 {
186 pc = ops[pc].arg
187 }
188 case opJmpNZ:
189 if m.tape[ptr] != 0 {
190 pc = ops[pc].arg
191 }
192 case opHalt:
193 m.status = vmkit.Halted
194 return used, m.status
195 default:
196 m.status, m.trap = vmkit.Trapped, "unknown opcode"
197 return used, m.status
198 }
199 pc++
200 }
201}
202
203// Snapshot serializes the machine. The tape is truncated at the highest cell
204// the program has reached, which is what makes pausing affordable: a hello
205// world that touched six cells snapshots six bytes of tape, not 30,000.
206func (m *Machine) Snapshot() []byte {
207 w := vmkit.NewWriter(m.hi + 48)
208 w.Uint32(snapMagic)
209 w.Byte(snapVersion)
210 w.Int(int64(m.pc))
211 w.Int(int64(m.ptr))
212 w.Int(int64(m.inPos))
213 w.Byte(byte(m.status))
214 w.String(m.trap)
215
216 n := m.hi + 1
217 if n > TapeSize {
218 n = TapeSize
219 }
220 w.Bytes(m.tape[:n])
221 return w.Out()
222}
223
224// Restore loads a snapshot into a machine that already carries the program
225// the snapshot was taken from. The program is not in the snapshot: a realm
226// stores it once, beside the instance, instead of once per pause.
227func (m *Machine) Restore(b []byte) error {
228 r := vmkit.NewReader(b)
229 if r.Uint32() != snapMagic {
230 return vmkit.ErrBadSnapshot
231 }
232 if r.Byte() != snapVersion {
233 return vmkit.ErrBadSnapshot
234 }
235 pc := int(r.Int())
236 ptr := int(r.Int())
237 inPos := int(r.Int())
238 status := vmkit.Status(r.Byte())
239 trap := r.String()
240 tape := r.Bytes()
241 if err := r.Err(); err != nil {
242 return err
243 }
244 if len(tape) > TapeSize || ptr < 0 || ptr >= TapeSize || inPos < 0 {
245 return vmkit.ErrBadSnapshot
246 }
247 if m.prog != nil && (pc < 0 || pc > len(m.prog.ops)) {
248 return vmkit.ErrBadSnapshot
249 }
250
251 m.pc, m.ptr, m.inPos = pc, ptr, inPos
252 m.status, m.trap = status, trap
253 // Zero only what this machine could have written: hi bounds every
254 // write it has made, and a 30,000 element composite literal is not
255 // something to put in a restore path.
256 for i := 0; i <= m.hi && i < TapeSize; i++ {
257 m.tape[i] = 0
258 }
259 copy(m.tape[:], tape)
260 m.hi = len(tape) - 1
261 if m.hi < 0 {
262 m.hi = 0
263 }
264 return nil
265}
266
267// Run compiles src at [Default] and runs it to completion against h, with no
268// fuel bound. It is the one-shot path: convenient for a test or a small
269// program, and exactly the thing a realm should not do with untrusted input.
270func Run(src string, h vmkit.Host) (vmkit.Status, error) {
271 m, err := Load(src)
272 if err != nil {
273 return vmkit.Trapped, err
274 }
275 _, status := m.Step(h, vmkit.Unmetered)
276 return status, nil
277}
278
279// wrap folds a tape index into range, the way the naive interpreter does one
280// step at a time.
281func wrap(i int) int {
282 i %= TapeSize
283 if i < 0 {
284 i += TapeSize
285 }
286 return i
287}