1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
|
#!/usr/bin/env python3
"""Conway's Game of Life whose entire user interface is the pane's TAG.
python3 stdlib, nothing else.
WHAT IT DEMONSTRATES
This is the acme trick that makes the filesystem worth having: a script can
define its own commands without the editor knowing anything about them.
1. Write words into the pane's `tag`. They are now just text.
2. Open the pane's `event` file. While it is open, button-2 (Exec) and
button-3 (Look) on that pane are REPORTED to us and NOT performed by the
editor. (Chorded Cut/Paste keep working, so the tag stays editable.)
3. A middle click on `Run` therefore arrives here as an `x` record naming
that text, and "Run" means whatever this script decides it means.
Words we do not recognise are WRITTEN BACK to the event file unchanged, which
makes the editor perform the action as though the event file had never been
open. So the pane's own tag entries -- Del, Put, whatever the editor puts
there -- still work while we are attached. A script that swallowed them would
be a black hole; passing them through is the whole etiquette of the protocol.
A button-3 click in the BODY (an `L` record) toggles the cell under the click.
The body is rendered as exactly H lines of W cells plus a newline each, so the
click offset q0 maps to a cell by plain division -- no coordinate lookup, no
round trip. Anything decorative goes BELOW the grid, where it cannot disturb
that arithmetic.
FILES TOUCHED
new/ctl create the pane, read its id back
<id>/ctl `name /+life`, `clean`
<id>/tag the command words -- a tag write appends to the editable tail
<id>/event O_RDWR: blocking reads for records, writes to pass records on
<id>/addr `,` (whole body) before each generation
<id>/data the generation itself
USAGE
life.py [mountdir] default: $PARDES_FS (set in every pane shell)
Step one generation Clear empty the grid
Run animate Random fill the grid at random
Stop stop animating button 3 in the grid: toggle that cell
Ctrl-C removes the pane and exits. So does the pane being deleted: the next
write fails, or the event reader hits end of file, and either is a clean end.
WHY A THREAD
Event reads BLOCK -- the server holds the request until a record exists -- and
a FUSE-backed regular file always polls readable, so select() cannot be used to
wait on one. Life also has to advance on a timer. So one daemon thread does
nothing but blocking reads and hands records to a Queue, and the main loop
waits on the Queue with a deadline. That keeps the blocking read where it
belongs and leaves the main loop free of spin.
"""
import os
import queue
import random
import sys
import threading
import time
W, H = 40, 20
TICK = 0.15
LIVE, DEAD = "#", "."
COMMANDS = ("Step", "Run", "Stop", "Clear", "Random")
class Records:
"""Counted event records off a blocking fd, kept in step byte-exactly.
The record is two characters (origin, type), then four blank separated
decimal numbers -- q0, q1, flag, text length -- then the text.
Two layouts exist in the wild: plan9 acme puts the text before the
record's terminating newline, while the pardes design note writes the
newline after the four numbers and the counted bytes after it. Both are
accepted here. Guessing wrong would not mangle one record, it would
desynchronise the stream forever, so this reader takes whatever the line
still holds as text and only goes back to the fd for bytes the count says
are missing. Blank lines are skipped, which absorbs either layout's
record terminator.
"""
def __init__(self, fd):
self.fd = fd
self.buf = b""
def _fill(self):
chunk = os.read(self.fd, 4096) # blocks in the server until a record
if not chunk:
raise EOFError("event file closed")
self.buf += chunk
def _line(self):
while True:
nl = self.buf.find(b"\n")
if nl >= 0:
line, self.buf = self.buf[:nl], self.buf[nl + 1 :]
if line:
return line
continue
self._fill()
def _take(self, n):
while len(self.buf) < n:
self._fill()
out, self.buf = self.buf[:n], self.buf[n:]
return out
def next(self):
line = self._line()
while len(line) < 2:
line = self._line()
origin, typ = chr(line[0]), chr(line[1])
rest, nums, i = line[2:], [], 0
for _ in range(4):
while i < len(rest) and rest[i : i + 1] == b" ":
i += 1
j = i
while j < len(rest) and rest[j : j + 1].isdigit():
j += 1
nums.append(int(rest[i:j]) if j > i else 0)
i = j
q0, q1, flag, count = nums
tail = rest[i + 1 :] if rest[i : i + 1] == b" " else rest[i:]
if count == 0:
# Text of 256 bytes or more is elided: count 0 and no bytes. The
# reader is meant to fetch it from `data` if it cares; we do not.
text = tail
elif tail:
text = tail
if len(text) < count:
text += b"\n" # the newline we stopped on belongs to the text
text += self._take(count - len(text))
else:
text = self._take(count)
return (origin, typ, q0, q1, flag, text.decode("utf-8", "replace"))
def write_all(fd, data):
view = memoryview(data)
while view:
view = view[os.write(fd, view) :]
class Life:
def __init__(self, mount):
with open(os.path.join(mount, "new", "ctl"), "rb") as f:
fields = f.read(256).split()
if not fields or not fields[0].isdigit():
raise OSError("unexpected new/ctl contents: %r" % fields[:1])
self.pane = fields[0].decode()
d = os.path.join(mount, self.pane)
self.ctl = os.open(os.path.join(d, "ctl"), os.O_WRONLY)
write_all(self.ctl, b"name /+life\nclean\n")
self.tagfd = os.open(os.path.join(d, "tag"), os.O_RDWR)
# O_RDWR on one fd: reading records and writing them back are the two
# halves of one conversation, and the editor's "someone is listening"
# state follows the open, so a second open would be a second listener.
self.event = os.open(os.path.join(d, "event"), os.O_RDWR)
self.addr = os.open(os.path.join(d, "addr"), os.O_WRONLY)
self.data = os.open(os.path.join(d, "data"), os.O_WRONLY)
write_all(self.tagfd, (" " + " ".join(COMMANDS)).encode())
self.tagtext = None
self.passed = None
self.cells = set()
self.gen = 0
self.running = False
def close(self):
for fd in (self.tagfd, self.event, self.addr, self.data):
try:
os.close(fd)
except OSError:
pass
try:
write_all(self.ctl, b"clean\ndel\n")
except OSError:
pass
try:
os.close(self.ctl)
except OSError:
pass
# --- the fs side -----------------------------------------------------
def render(self):
rows = []
for y in range(H):
rows.append("".join(LIVE if (x, y) in self.cells else DEAD for x in range(W)))
rows.append("")
rows.append(
"generation %d %s %d alive button 3 in the grid toggles a cell"
% (self.gen, "running" if self.running else "stopped", len(self.cells))
)
write_all(self.addr, b",")
write_all(self.data, ("\n".join(rows) + "\n").encode())
write_all(self.ctl, b"clean\n")
def tag_slice(self, q0, q1):
"""The text of a tag click, for the case where the record carried none.
Read once and cached: the tag only changes when we or the user change
it, and a wrong guess here costs an ignored click, not a corruption."""
if self.tagtext is None:
self.tagtext = os.pread(self.tagfd, 8192, 0).decode("utf-8", "replace")
return self.tagtext[q0:q1]
def passthrough(self, origin, typ, q0, q1):
"""Hand a record we do not implement back to the editor, which then
performs it exactly as if nobody had been listening. Flag, count and
text are omitted: the two characters and two numbers are the whole
identity of the action.
The coordinates are remembered so that a record coming straight back
at us can be recognised. A correct server does not re-report an action
it was asked to perform -- acme marks the write-back path `external`
precisely to skip its own reporting branch -- but if one ever did, a
passthrough of a passthrough is an infinite loop, and this is a cheaper
insurance policy than finding that out in someone's editor."""
self.passed = (typ, q0, q1)
write_all(self.event, ("%c%c%d %d\n" % (origin, typ, q0, q1)).encode())
# --- the game side ---------------------------------------------------
def step(self):
counts = {}
for (x, y) in self.cells:
for dx in (-1, 0, 1):
for dy in (-1, 0, 1):
if dx or dy:
n = ((x + dx) % W, (y + dy) % H)
counts[n] = counts.get(n, 0) + 1
self.cells = {c for c, n in counts.items() if n == 3 or (n == 2 and c in self.cells)}
self.gen += 1
def command(self, word):
if word == "Step":
self.step()
elif word == "Run":
self.running = True
elif word == "Stop":
self.running = False
elif word == "Clear":
self.cells.clear()
self.gen = 0
elif word == "Random":
self.cells = {
(x, y) for x in range(W) for y in range(H) if random.random() < 0.28
}
self.gen = 0
else:
return False
return True
def toggle(self, q0):
"""Body offset -> cell. Rows are W cells plus a newline, so the row is
the quotient and the column the remainder; a click on the newline, or
anywhere in the status line below the grid, lands outside and is
ignored."""
row, col = divmod(q0, W + 1)
if row >= H or col >= W:
return
cell = (col, row)
self.cells.symmetric_difference_update({cell})
def handle(self, rec):
origin, typ, q0, q1, _flag, text = rec
if not text and (typ, q0, q1) == self.passed:
return False # a record we passed on, coming back: see passthrough
if typ in "xX":
word = (text or self.tag_slice(q0, q1)).strip()
if not self.command(word):
self.passthrough(origin, typ, q0, q1)
return False
elif typ == "L":
self.toggle(q0)
elif typ == "l":
self.passthrough(origin, typ, q0, q1)
return False
else:
return False # I/i/D/d: our own writes echoing back
return True
def reader(records, q):
try:
while True:
q.put(records.next())
except (OSError, EOFError, ValueError):
pass
q.put(None) # the pane or the mount is gone
def main(argv):
mount = argv[1] if len(argv) > 1 else os.environ.get("PARDES_FS", "")
if not mount:
sys.stderr.write(
"life.py: no mount point. Pass one, or run inside a pardes pane\n"
" shell where $PARDES_FS is set (start pardes with --fs).\n"
)
return 1
if not os.path.isdir(mount):
sys.stderr.write("life.py: %s is not a directory\n" % mount)
return 1
try:
game = Life(mount)
except OSError as e:
sys.stderr.write("life.py: cannot set up a pane: %s\n" % e)
return 1
q = queue.Queue()
threading.Thread(target=reader, args=(Records(game.event), q), daemon=True).start()
try:
game.command("Random")
game.render()
deadline = time.monotonic() + TICK
while True:
if game.running:
wait = deadline - time.monotonic()
if wait <= 0:
game.step()
game.render()
deadline = time.monotonic() + TICK
wait = TICK
else:
wait = 0.25 # a bounded wait, not a spin: clicks stay prompt
try:
rec = q.get(timeout=wait)
except queue.Empty:
continue
if rec is None:
break
if game.handle(rec):
game.render()
deadline = time.monotonic() + TICK
except KeyboardInterrupt:
pass
except OSError:
return 0 # the pane went away mid-write; nothing to report
finally:
game.close()
return 0
if __name__ == "__main__":
sys.exit(main(sys.argv))
|