OXYGENHUB
◆ v0.9.19 oxygenhub / assets / wasi-worker.js
16.1 KiB · JavaScript · last changed Run a pipeline's WASM jobs in the browser tab (refs andymccall/oxygenflow#80); runner builds shared with the swarm (refs andymccall/oxygenflow#84); 0.9.15 7 days ago source blame history raw
c26267b3 Andy McCall ↶ 1
// OxygenFlow's WASM tier in a browser tab (oxygenflow#80): this worker
2
// unpacks a repository's zip archive into memory and runs a job's WASI
3
// preview 1 modules over it, as `flow` runs them with wasmtime: the files at
4
// /src (and as the current directory), the job's arguments and environment,
5
// stdout and stderr streamed back line by line, and the exit code.
6
//
7
// Messages in: { archive: ArrayBuffer, exec: [module, ...args], env: {k: v} }.
8
// Messages out: { kind: "note"|"out"|"err", text }, then
9
// { kind: "done", code, ms } or { kind: "failed", text }.
10
"use strict";
11
12
const E = { SUCCESS: 0, ACCES: 2, BADF: 8, EXIST: 20, INVAL: 28, ISDIR: 31, NOENT: 44, NOSYS: 52, NOTDIR: 54, NOTEMPTY: 55, NOTSUP: 58 };
13
const FT = { UNKNOWN: 0, CHAR: 2, DIR: 3, FILE: 4 };
14
const O = { CREAT: 1, DIRECTORY: 2, EXCL: 4, TRUNC: 8 };
15
const FD_APPEND = 1;
16
const RIGHTS = 0x1fffffffn;
17
18
class Exit {
19
  constructor(code) { this.code = code; }
20
}
21
22
// --- The archive -----------------------------------------------------------
23
24
// A zip's files by path, the first directory (the archive's prefix) taken
25
// off. Read through the central directory, so sizes written after the data
26
// are known; stored and deflated entries; symbolic links are left out.
27
async function unzip(buf) {
28
  const b = new DataView(buf);
29
  const bytes = new Uint8Array(buf);
30
  let end = -1;
31
  for (let i = buf.byteLength - 22; i >= Math.max(0, buf.byteLength - 65557); i--) {
32
    if (b.getUint32(i, true) === 0x06054b50) { end = i; break; }
33
  }
34
  if (end < 0) throw new Error("the archive is not a zip file");
35
  const count = b.getUint16(end + 10, true);
36
  let at = b.getUint32(end + 16, true);
37
  if (at === 0xffffffff) throw new Error("the archive is too large for this page (zip64)");
38
  const files = new Map();
39
  const dec = new TextDecoder();
40
  for (let n = 0; n < count; n++) {
41
    if (b.getUint32(at, true) !== 0x02014b50) throw new Error("the archive's directory is damaged");
42
    const method = b.getUint16(at + 10, true);
43
    const csize = b.getUint32(at + 20, true);
44
    const nlen = b.getUint16(at + 28, true), xlen = b.getUint16(at + 30, true), clen = b.getUint16(at + 32, true);
45
    const mode = b.getUint32(at + 38, true) >>> 16;
46
    const local = b.getUint32(at + 42, true);
47
    const name = dec.decode(bytes.subarray(at + 46, at + 46 + nlen));
48
    at += 46 + nlen + xlen + clen;
49
    if (name.endsWith("/") || (mode & 0o170000) === 0o120000) continue;
50
    const data = local + 30 + b.getUint16(local + 26, true) + b.getUint16(local + 28, true);
51
    const raw = bytes.subarray(data, data + csize);
52
    let content;
53
    if (method === 0) content = raw.slice();
54
    else if (method === 8) content = new Uint8Array(await new Response(new Blob([raw]).stream().pipeThrough(new DecompressionStream("deflate-raw"))).arrayBuffer());
55
    else throw new Error(`${name}: compression method ${method} is not read here`);
56
    const slash = name.indexOf("/");
57
    files.set(slash < 0 ? name : name.slice(slash + 1), content);
58
  }
59
  return files;
60
}
61
62
// --- The file system ---------------------------------------------------------
63
64
class Fs {
65
  constructor(files) {
66
    this.files = files; // path -> Uint8Array
67
    this.dirs = new Set([""]);
68
    for (const p of files.keys()) this.addParents(p);
69
  }
70
  addParents(p) {
71
    const parts = p.split("/");
72
    for (let i = 1; i < parts.length; i++) this.dirs.add(parts.slice(0, i).join("/"));
73
  }
74
  // `rel` against directory `dir`, or null when it leaves the root.
75
  join(dir, rel) {
76
    const out = rel.startsWith("/") ? [] : dir ? dir.split("/") : [];
77
    for (const part of rel.split("/")) {
78
      if (part === "" || part === ".") continue;
79
      if (part === "..") { if (!out.length) return null; out.pop(); } else out.push(part);
80
    }
81
    return out.join("/");
82
  }
83
  kind(p) {
84
    if (this.files.has(p)) return FT.FILE;
85
    if (this.dirs.has(p)) return FT.DIR;
86
    return null;
87
  }
88
  list(dir) {
89
    const prefix = dir ? dir + "/" : "";
90
    const names = new Set();
91
    for (const p of [...this.files.keys(), ...this.dirs]) {
92
      if (p !== dir && p.startsWith(prefix)) names.add(p.slice(prefix.length).split("/")[0]);
93
    }
94
    return [...names].sort();
95
  }
96
}
97
98
// The modules a job's first `exec` word names: one path, or a `*` pattern
99
// in its file name, as `flow` resolves them.
100
function modules(fs, program) {
101
  const rel = program.replace(/^\/src\//, "").replace(/^\.\//, "");
102
  if (!rel || rel.startsWith("/") || rel.split("/").includes("..")) throw new Error(`module ${program}: give a path inside the job's files`);
103
  const cut = rel.lastIndexOf("/");
104
  const dir = cut < 0 ? "" : rel.slice(0, cut), last = rel.slice(cut + 1);
105
  if (dir.includes("*")) throw new Error(`module ${program}: only the file name may hold *`);
106
  if (!last.includes("*")) {
107
    if (!fs.files.has(rel)) throw new Error(`module ${rel} is not among the repository's files (a build makes it?)`);
108
    return [rel];
109
  }
110
  const re = new RegExp("^" + last.split("*").map((s) => s.replace(/[.+?^${}()|[\]\\]/g, "\\$&")).join(".*") + "$");
111
  const found = fs.list(dir).filter((n) => re.test(n) && fs.files.has(dir ? `${dir}/${n}` : n)).map((n) => (dir ? `${dir}/${n}` : n));
112
  if (!found.length) throw new Error(`no module matches ${program}`);
113
  return found;
114
}
115
116
// --- WASI preview 1 ------------------------------------------------------------
117
118
function wasi(fs, argv, env, say) {
119
  let memory;
120
  const view = () => new DataView(memory.buffer);
121
  const u8 = () => new Uint8Array(memory.buffer);
122
  const enc = new TextEncoder(), dec = new TextDecoder();
123
  const str = (ptr, len) => dec.decode(u8().subarray(ptr, ptr + len));
124
  // Open files and directories: 0-2 the streams, 3 /src, 4 ".".
125
  const fds = new Map([
126
    [0, { stream: "in" }],
127
    [1, { stream: "out", partial: "" }],
128
    [2, { stream: "err", partial: "" }],
129
    [3, { dir: "", preopen: "/src" }],
130
    [4, { dir: "", preopen: "." }],
131
  ]);
132
  let next = 5;
133
  const emit = (fd, text) => {
134
    const f = fds.get(fd);
135
    f.partial += text;
136
    let nl;
137
    while ((nl = f.partial.indexOf("\n")) >= 0) {
138
      say(f.stream, f.partial.slice(0, nl));
139
      f.partial = f.partial.slice(nl + 1);
140
    }
141
  };
142
  const flush = () => { for (const fd of [1, 2]) { const f = fds.get(fd); if (f.partial) { say(f.stream, f.partial); f.partial = ""; } } };
143
  const filestat = (ptr, kind, size) => {
144
    const v = view();
145
    for (let i = 0; i < 64; i += 8) v.setBigUint64(ptr + i, 0n, true);
146
    v.setUint8(ptr + 16, kind);
147
    v.setBigUint64(ptr + 24, 1n, true);
148
    v.setBigUint64(ptr + 32, BigInt(size), true);
149
  };
150
  const iovs = (ptr, n) => {
151
    const v = view(), out = [];
152
    for (let i = 0; i < n; i++) out.push([v.getUint32(ptr + i * 8, true), v.getUint32(ptr + i * 8 + 4, true)]);
153
    return out;
154
  };
155
  const strings = (list) => list.map((s) => enc.encode(s + "\0"));
156
  const sizes = (list, countPtr, sizePtr) => {
157
    const v = view(), all = strings(list);
158
    v.setUint32(countPtr, all.length, true);
159
    v.setUint32(sizePtr, all.reduce((n, s) => n + s.length, 0), true);
160
    return E.SUCCESS;
161
  };
162
  const fill = (list, ptrs, buf) => {
163
    const v = view(), m = u8();
164
    for (const s of strings(list)) { v.setUint32(ptrs, buf, true); ptrs += 4; m.set(s, buf); buf += s.length; }
165
    return E.SUCCESS;
166
  };
167
  const envList = Object.entries(env).map(([k, v]) => `${k}=${v}`);
168
  const imports = {
169
    args_sizes_get: (c, s) => sizes(argv, c, s),
170
    args_get: (p, b) => fill(argv, p, b),
171
    environ_sizes_get: (c, s) => sizes(envList, c, s),
172
    environ_get: (p, b) => fill(envList, p, b),
173
    clock_time_get: (_id, _precision, out) => { view().setBigUint64(out, BigInt(Math.round(performance.timeOrigin + performance.now())) * 1000000n, true); return E.SUCCESS; },
174
    clock_res_get: (_id, out) => { view().setBigUint64(out, 1000n, true); return E.SUCCESS; },
175
    random_get: (ptr, len) => { crypto.getRandomValues(u8().subarray(ptr, ptr + len)); return E.SUCCESS; },
176
    sched_yield: () => E.SUCCESS,
177
    proc_exit: (code) => { throw new Exit(code); },
178
    fd_prestat_get: (fd, ptr) => {
179
      const f = fds.get(fd);
180
      if (!f || !f.preopen) return E.BADF;
181
      const v = view();
182
      v.setUint8(ptr, 0);
183
      v.setUint32(ptr + 4, enc.encode(f.preopen).length, true);
184
      return E.SUCCESS;
185
    },
186
    fd_prestat_dir_name: (fd, ptr, len) => {
187
      const f = fds.get(fd);
188
      if (!f || !f.preopen) return E.BADF;
189
      u8().set(enc.encode(f.preopen).subarray(0, len), ptr);
190
      return E.SUCCESS;
191
    },
192
    fd_fdstat_get: (fd, ptr) => {
193
      const f = fds.get(fd);
194
      if (!f) return E.BADF;
195
      const v = view();
196
      v.setUint8(ptr, f.stream ? FT.CHAR : f.dir !== undefined ? FT.DIR : FT.FILE);
197
      v.setUint16(ptr + 2, f.append ? FD_APPEND : 0, true);
198
      v.setBigUint64(ptr + 8, RIGHTS, true);
199
      v.setBigUint64(ptr + 16, RIGHTS, true);
200
      return E.SUCCESS;
201
    },
202
    fd_fdstat_set_flags: () => E.SUCCESS,
203
    fd_filestat_get: (fd, ptr) => {
204
      const f = fds.get(fd);
205
      if (!f) return E.BADF;
206
      if (f.stream) filestat(ptr, FT.CHAR, 0);
207
      else if (f.dir !== undefined) filestat(ptr, FT.DIR, 0);
208
      else filestat(ptr, FT.FILE, fs.files.get(f.path).length);
209
      return E.SUCCESS;
210
    },
211
    path_filestat_get: (fd, _flags, p, len, ptr) => {
212
      const d = fds.get(fd);
213
      if (!d || d.dir === undefined) return E.BADF;
214
      const path = fs.join(d.dir, str(p, len));
215
      if (path === null) return E.ACCES;
216
      const kind = fs.kind(path);
217
      if (kind === null) return E.NOENT;
218
      filestat(ptr, kind, kind === FT.FILE ? fs.files.get(path).length : 0);
219
      return E.SUCCESS;
220
    },
221
    path_open: (fd, _dirflags, p, len, oflags, _rb, _ri, fdflags, out) => {
222
      const d = fds.get(fd);
223
      if (!d || d.dir === undefined) return E.BADF;
224
      const path = fs.join(d.dir, str(p, len));
225
      if (path === null) return E.ACCES;
226
      let kind = fs.kind(path);
227
      if (oflags & O.DIRECTORY) {
228
        if (kind === null) return E.NOENT;
229
        if (kind !== FT.DIR) return E.NOTDIR;
230
      }
231
      if (kind === null) {
232
        if (!(oflags & O.CREAT)) return E.NOENT;
233
        const parent = path.includes("/") ? path.slice(0, path.lastIndexOf("/")) : "";
234
        if (fs.kind(parent) !== FT.DIR) return E.NOENT;
235
        fs.files.set(path, new Uint8Array(0));
236
        kind = FT.FILE;
237
      } else if (oflags & O.EXCL) return E.EXIST;
238
      if (kind === FT.DIR) fds.set(next, { dir: path, entries: null });
239
      else {
240
        if (oflags & O.TRUNC) fs.files.set(path, new Uint8Array(0));
241
        fds.set(next, { path, pos: 0, append: !!(fdflags & FD_APPEND) });
242
      }
243
      view().setUint32(out, next, true);
244
      next += 1;
245
      return E.SUCCESS;
246
    },
247
    path_create_directory: (fd, p, len) => {
248
      const d = fds.get(fd);
249
      if (!d || d.dir === undefined) return E.BADF;
250
      const path = fs.join(d.dir, str(p, len));
251
      if (path === null) return E.ACCES;
252
      if (fs.kind(path) !== null) return E.EXIST;
253
      fs.dirs.add(path);
254
      return E.SUCCESS;
255
    },
256
    path_unlink_file: (fd, p, len) => {
257
      const d = fds.get(fd);
258
      if (!d || d.dir === undefined) return E.BADF;
259
      const path = fs.join(d.dir, str(p, len));
260
      if (path === null || !fs.files.has(path)) return E.NOENT;
261
      fs.files.delete(path);
262
      return E.SUCCESS;
263
    },
264
    fd_close: (fd) => {
265
      if (!fds.has(fd) || fd < 3) return fds.has(fd) ? E.SUCCESS : E.BADF;
266
      fds.delete(fd);
267
      return E.SUCCESS;
268
    },
269
    fd_read: (fd, ptr, n, out) => {
270
      const f = fds.get(fd);
271
      if (!f) return E.BADF;
272
      if (f.dir !== undefined) return E.ISDIR;
273
      let total = 0;
274
      if (!f.stream) {
275
        const data = fs.files.get(f.path), m = u8();
276
        for (const [buf, len] of iovs(ptr, n)) {
277
          const k = Math.min(len, data.length - f.pos);
278
          if (k <= 0) break;
279
          m.set(data.subarray(f.pos, f.pos + k), buf);
280
          f.pos += k;
281
          total += k;
282
        }
283
      }
284
      view().setUint32(out, total, true);
285
      return E.SUCCESS;
286
    },
287
    fd_write: (fd, ptr, n, out) => {
288
      const f = fds.get(fd);
289
      if (!f) return E.BADF;
290
      if (f.dir !== undefined) return E.ISDIR;
291
      let total = 0;
292
      const m = u8();
293
      for (const [buf, len] of iovs(ptr, n)) {
294
        const chunk = m.slice(buf, buf + len);
295
        if (f.stream === "out" || f.stream === "err") emit(fd, dec.decode(chunk, { stream: true }));
296
        else if (!f.stream) {
297
          const old = fs.files.get(f.path);
298
          const at = f.append ? old.length : f.pos;
299
          const grown = new Uint8Array(Math.max(old.length, at + len));
300
          grown.set(old);
301
          grown.set(chunk, at);
302
          fs.files.set(f.path, grown);
303
          f.pos = at + len;
304
        }
305
        total += len;
306
      }
307
      view().setUint32(out, total, true);
308
      return E.SUCCESS;
309
    },
310
    fd_seek: (fd, offset, whence, out) => {
311
      const f = fds.get(fd);
312
      if (!f || f.stream || f.dir !== undefined) return E.BADF;
313
      const size = fs.files.get(f.path).length;
314
      const base = whence === 0 ? 0 : whence === 1 ? f.pos : size;
315
      const pos = base + Number(offset);
316
      if (pos < 0) return E.INVAL;
317
      f.pos = pos;
318
      view().setBigUint64(out, BigInt(pos), true);
319
      return E.SUCCESS;
320
    },
321
    fd_tell: (fd, out) => {
322
      const f = fds.get(fd);
323
      if (!f || f.stream || f.dir !== undefined) return E.BADF;
324
      view().setBigUint64(out, BigInt(f.pos), true);
325
      return E.SUCCESS;
326
    },
327
    fd_readdir: (fd, buf, len, cookie, out) => {
328
      const d = fds.get(fd);
329
      if (!d || d.dir === undefined) return E.BADF;
330
      if (!d.entries) d.entries = [".", "..", ...fs.list(d.dir)];
331
      const v = view(), m = u8();
332
      let used = 0;
333
      for (let i = Number(cookie); i < d.entries.length; i++) {
334
        const name = d.entries[i];
335
        const bytes = enc.encode(name);
336
        const child = name === "." || name === ".." ? d.dir : fs.join(d.dir, name);
337
        const head = new DataView(new ArrayBuffer(24));
338
        head.setBigUint64(0, BigInt(i + 1), true);
339
        head.setBigUint64(8, BigInt(i + 1), true);
340
        head.setUint32(16, bytes.length, true);
341
        head.setUint8(20, name === "." || name === ".." ? FT.DIR : fs.kind(child) ?? FT.UNKNOWN);
342
        const entry = new Uint8Array(24 + bytes.length);
343
        entry.set(new Uint8Array(head.buffer));
344
        entry.set(bytes, 24);
345
        const room = len - used;
346
        m.set(entry.subarray(0, Math.min(room, entry.length)), buf + used);
347
        used += Math.min(room, entry.length);
348
        if (used >= len) break;
349
      }
350
      v.setUint32(out, used, true);
351
      return E.SUCCESS;
352
    },
353
  };
354
  return { imports, flush, bind: (mem) => { memory = mem; } };
355
}
356
357
// --- A job -------------------------------------------------------------------------
358
359
async function run({ archive, exec, env }) {
360
  const say = (kind, text) => postMessage({ kind, text });
361
  const started = performance.now();
362
  const fs = new Fs(await unzip(archive));
363
  say("note", `${fs.files.size} files unpacked in ${Math.round(performance.now() - started)} ms`);
364
  const list = modules(fs, exec[0]);
365
  let first = 0;
366
  for (const path of list) {
367
    say("note", `running ${path}`);
368
    const name = path.slice(path.lastIndexOf("/") + 1);
369
    const w = wasi(fs, [name, ...exec.slice(1)], { ...env, PWD: "/src" }, say);
370
    const module = await WebAssembly.compile(fs.files.get(path));
371
    const imports = { wasi_snapshot_preview1: {} };
372
    // What the shim lacks answers ENOSYS rather than refusing the module.
373
    for (const imp of WebAssembly.Module.imports(module)) {
374
      if (imp.kind !== "function") continue;
375
      imports[imp.module] ??= {};
376
      if (imp.module === "wasi_snapshot_preview1" && w.imports[imp.name]) imports[imp.module][imp.name] = w.imports[imp.name];
377
      else imports[imp.module][imp.name] ??= () => E.NOSYS;
378
    }
379
    const instance = await WebAssembly.instantiate(module, imports);
380
    w.bind(instance.exports.memory);
381
    let code = 0;
382
    try {
383
      instance.exports._start();
384
    } catch (e) {
385
      if (e instanceof Exit) code = e.code;
386
      else {
387
        w.flush();
388
        say("err", `${path} trapped: ${e.message}`);
389
        code = 134;
390
      }
391
    }
392
    w.flush();
393
    if (code !== 0 && first === 0) first = code;
394
  }
395
  postMessage({ kind: "done", code: first, ms: Math.round(performance.now() - started) });
396
}
397
398
onmessage = (m) => {
399
  run(m.data).catch((e) => postMessage({ kind: "failed", text: e.message }));
400
};