@ai-ecoverse/slicc-kernel 1.8.12 → 1.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -67,6 +67,29 @@ Starts `argv` as the session leader on a new terminal and resolves once it is ru
67
67
 
68
68
  Stops the kernel worker and every process. Pending and later calls reject.
69
69
 
70
+ ### `kernel.connect() → Promise<MessagePort>`
71
+
72
+ Makes a port for another client of the same kernel. Hand it to a dedicated worker, or through a SharedWorker to another tab: it can be transferred any number of times. All clients share one process table, so a terminal on the page can `ps` and `kill` what a worker started.
73
+
74
+ ### `attachKernel(port, options?) → Promise<KernelClient>`
75
+
76
+ Attaches to a kernel over a port from `kernel.connect()`, in any realm: a worker, another tab, or Node. `options.timeoutMs` (10 s by default) bounds the handshake: a port with no kernel behind it rejects with `KernelGoneError`.
77
+
78
+ | Client | |
79
+ |---|---|
80
+ | `spawn(argv, { cwd, env, stdin, onStdout, onStderr })` | runs over pipes and resolves with `{ pid, pgid, exited, signal(name) }` once the process has started; output arrives in chunks, as bytes, and the kernel keeps no copy; every spawn leads its own process group, which `signal` (default `SIGTERM`) signals; a program that cannot start rejects with `code: 'ENOENT'` |
81
+ | `run(argv, options?)` | `spawn` and wait: `{ pid, status, stdout, stderr }` as text, with `onStdout`/`onStderr` streaming text |
82
+ | `openTerminal(argv, options?)` | a pty session, as `kernel.openTerminal` |
83
+ | `ps()` | the kernel's processes: `{ pid, ppid, pgid, sid, argv, tty, started, state }`, `state` being `'S'` or `'Z'` |
84
+ | `kill(pid, signal?)` | any process, or a process group with a negative pid; no such process rejects with `code: 'ESRCH'` |
85
+ | `fs` | `readFile`, `readText`, `writeFile(path, string \| bytes)`, `stat`, `lstat`, `readdir`, `mkdir` (with parents), `rm(path, { force })` (recursive), `rename`, `realpath`, `symlink(target, path)`, `readlink`, `exists`, on the processes' file system; failures reject with `KernelCallError` and a POSIX `code` |
86
+ | `fetch({ url, method, headers, body, signal })` | through the kernel's network transport (the one the page passed to `createKernel`), with a streaming body; `transport` is the same as a `NetworkTransport` |
87
+ | `fs.watch(paths, { recursive }, onChange)` | resolves with `{ close() }`; `onChange` gets `{ paths }`, the changed paths at, below (or, without `recursive`, directly in) the watched ones, batched per task, or `{ overflow: true }` when there were too many to list, to rescan; it covers every change made through this kernel, by its processes and its clients |
88
+ | `close({ kill })` | detaches: the client's processes keep running unless `kill` is set, which ends their process groups with `SIGKILL`; its terminals are hung up |
89
+ | `closed` | resolves with the error that ended the client |
90
+
91
+ When the kernel goes away (the page closed or reloaded, `terminate()`), pending calls and `exited` reject with `KernelGoneError`, and so do later calls. Each side holds a Web Lock and waits on the other's, since a `MessagePort` reports no close in browsers; in Node, the port's `close` event does the same. The first message is a handshake on the protocol version, `1.1` (`1.0` had no `watch`, which rejects with `code: 'ENOSYS'` on such a kernel): a client or kernel of another major version is refused with an error naming both.
92
+
70
93
  ### Headless in Node, for tests
71
94
 
72
95
  `@ai-ecoverse/slicc-kernel/node` runs the same kernel in Node without a browser, so packages built for SLICC can test against it. It is a testing entry, not a supported runtime: there is no OPFS and no isolation, and nothing beyond what its tests use is promised.
@@ -80,7 +103,7 @@ const { status, stdout } = await kernel.run(['bash', '-c', 'echo hi'], { cwd: '/
80
103
  kernel.terminate();
81
104
  ```
82
105
 
83
- `createNodeKernel({ root, modules, env, network, worker })` takes the options of `createKernel` except `metadata`. `root` is an in-memory directory by default (`memoryRoot()` makes another), and POSIX metadata stays in memory. Processes and threads run on `worker_threads`. The kernel has `run`, `openTerminal` and `terminate` as above, plus `root`, `writeFile(path, data)` (creating the parent directories) and `readFile(path)` to put files in place and read results. `nodeTransport()` is `fetchTransport()` with Node's `fetch`, which no CORS binds (`crossOrigin: 'any'`).
106
+ `createNodeKernel({ root, modules, env, network, worker })` takes the options of `createKernel` except `metadata`. `root` is an in-memory directory by default (`memoryRoot()` makes another), and POSIX metadata stays in memory. Processes and threads run on `worker_threads`. The kernel has `run`, `openTerminal` and `terminate` as above, plus `root`, `writeFile(path, data)` (creating the parent directories) and `readFile(path)` to put files in place and read results. `nodeTransport()` is `fetchTransport()` with Node's `fetch`, which no CORS binds (`crossOrigin: 'any'`). `connect()` and `attachKernel` work as in the browser: the port is a `worker_threads` `MessagePort`, which a worker thread can attach with, and `terminate()` ends every attached client.
84
107
 
85
108
  ## Commands
86
109
 
@@ -156,6 +179,10 @@ The proxy answers `200` with `Content-Type: application/vnd.slicc.raw-fetch`: a
156
179
 
157
180
  Directories are renamed with `FileSystemHandle.move()` where available, else by copy and delete.
158
181
 
182
+ ### `/proc`
183
+
184
+ Every process of the kernel has `/proc/<pid>/` with `cmdline`, `comm`, `stat`, `statm` and `status`, in the formats procps reads, and `/proc/self/` has the same files for the process that reads them. They come from the kernel's process table when they are opened, so a terminal sees the processes of every client; an exec'd program shows under the pid its parent knows. `/proc/uptime`, `/proc/loadavg`, `/proc/stat` and `/proc/meminfo` are there too, and `/proc/mounts`. Pids, parents, process groups, sessions, command lines, terminals, start and boot times are real; CPU times, memory sizes, load and `MemTotal` (from `navigator.deviceMemory`, else 4 GiB) are placeholders. Only Emscripten processes see this `/proc`.
185
+
159
186
  ### Metadata
160
187
 
161
188
  OPFS stores names, bytes, sizes and modification times, nothing else. Everything POSIX needs on top of that lives in an IndexedDB sidecar, so it survives reloads and new kernels:
package/dist/index.js CHANGED
@@ -164,6 +164,424 @@ function serveTransport(peer, transport) {
164
164
  };
165
165
  }
166
166
 
167
+ // src/kernel/net/remote-transport.ts
168
+ var TransportError = class extends Error {
169
+ status;
170
+ code;
171
+ constructor(message, status, code) {
172
+ super(message);
173
+ this.status = status;
174
+ this.code = code;
175
+ }
176
+ };
177
+ var RemoteTransport = class {
178
+ traits;
179
+ port;
180
+ waiting = /* @__PURE__ */ new Map();
181
+ nextId = 0;
182
+ failure;
183
+ constructor(port, traits) {
184
+ this.port = port;
185
+ this.traits = traits;
186
+ }
187
+ receive(reply) {
188
+ const waiter = this.waiting.get(reply.nid);
189
+ if (!waiter) return;
190
+ this.waiting.delete(reply.nid);
191
+ if (reply.net === "error") {
192
+ waiter.reject(new TransportError(reply.message, reply.status, reply.code));
193
+ } else waiter.resolve(reply);
194
+ }
195
+ fail(error) {
196
+ this.failure = error;
197
+ for (const waiter of this.waiting.values()) waiter.reject(error);
198
+ this.waiting.clear();
199
+ }
200
+ ask(call) {
201
+ if (this.failure) return Promise.reject(this.failure);
202
+ return new Promise((resolve, reject) => {
203
+ this.waiting.set(call.nid, { resolve, reject });
204
+ this.port.postMessage(call);
205
+ });
206
+ }
207
+ cancel(nid) {
208
+ const waiter = this.waiting.get(nid);
209
+ this.waiting.delete(nid);
210
+ waiter?.reject(new TransportError("the request was cancelled"));
211
+ this.port.postMessage({ net: "cancel", nid });
212
+ }
213
+ async fetch(request) {
214
+ const nid = ++this.nextId;
215
+ const { signal } = request;
216
+ signal.throwIfAborted();
217
+ let finished = false;
218
+ const abort = () => {
219
+ finished = true;
220
+ this.cancel(nid);
221
+ };
222
+ signal.addEventListener("abort", abort, { once: true });
223
+ const finish = () => {
224
+ if (finished) return;
225
+ finished = true;
226
+ signal.removeEventListener("abort", abort);
227
+ };
228
+ let head;
229
+ try {
230
+ head = await this.ask({
231
+ net: "fetch",
232
+ nid,
233
+ url: request.url,
234
+ method: request.method,
235
+ headers: request.headers,
236
+ ...request.body ? { body: request.body } : {}
237
+ });
238
+ } catch (e) {
239
+ finish();
240
+ throw e;
241
+ }
242
+ const { status, statusText, headers } = head;
243
+ const read = async () => {
244
+ if (finished) return void 0;
245
+ const reply = await this.ask({ net: "read", nid }).catch((e) => {
246
+ finish();
247
+ throw e;
248
+ });
249
+ if (reply.net === "chunk") return reply.bytes;
250
+ finish();
251
+ return void 0;
252
+ };
253
+ const cancel = async () => {
254
+ if (finished) return;
255
+ finish();
256
+ this.cancel(nid);
257
+ };
258
+ return {
259
+ status,
260
+ statusText,
261
+ headers,
262
+ cancel,
263
+ body: {
264
+ [Symbol.asyncIterator]: () => ({
265
+ next: async () => {
266
+ const bytes = await read();
267
+ return bytes ? { value: bytes, done: false } : { value: void 0, done: true };
268
+ },
269
+ return: async () => {
270
+ await cancel();
271
+ return { value: void 0, done: true };
272
+ }
273
+ })
274
+ }
275
+ };
276
+ }
277
+ };
278
+
279
+ // src/client/protocol.ts
280
+ var PROTOCOL = [1, 1];
281
+ function locksOf(scope = globalThis) {
282
+ const locks = scope.navigator?.locks;
283
+ return typeof locks?.request === "function" ? locks : void 0;
284
+ }
285
+ function versionError(theirs) {
286
+ const major = Array.isArray(theirs) ? theirs[0] : void 0;
287
+ if (major === PROTOCOL[0]) return void 0;
288
+ const named = typeof major === "number" ? `${major}.x` : "without a version";
289
+ return `slicc-kernel client protocol ${named} is not supported: this side speaks ${PROTOCOL.join(".")}`;
290
+ }
291
+
292
+ // src/client/attach.ts
293
+ var KernelGoneError = class extends Error {
294
+ constructor(message = "the slicc-kernel this client was attached to is gone") {
295
+ super(message);
296
+ this.name = "KernelGoneError";
297
+ }
298
+ };
299
+ var KernelCallError = class extends Error {
300
+ code;
301
+ constructor(message, code) {
302
+ super(message);
303
+ this.name = "KernelCallError";
304
+ this.code = code;
305
+ }
306
+ };
307
+ var encoder = new TextEncoder();
308
+ var bytesOf = (data) => typeof data === "string" ? encoder.encode(data) : data;
309
+ var UNAVAILABLE = {
310
+ manualRedirects: false,
311
+ encodedBodies: false,
312
+ maxRequestBody: 0,
313
+ unavailable: true
314
+ };
315
+ async function holdLock(locks) {
316
+ if (!locks) return { release() {
317
+ } };
318
+ const name = `slicc-kernel-client:${crypto.randomUUID()}`;
319
+ const granted = Promise.withResolvers();
320
+ const held = Promise.withResolvers();
321
+ void locks.request(name, () => {
322
+ granted.resolve();
323
+ return held.promise;
324
+ });
325
+ await granted.promise;
326
+ return { name, release: () => held.resolve() };
327
+ }
328
+ function concat(chunks3) {
329
+ const out = new Uint8Array(chunks3.reduce((n, c) => n + c.length, 0));
330
+ let at = 0;
331
+ for (const chunk of chunks3) {
332
+ out.set(chunk, at);
333
+ at += chunk.length;
334
+ }
335
+ return out;
336
+ }
337
+ var Channel = class {
338
+ greeted = Promise.withResolvers();
339
+ ended = Promise.withResolvers();
340
+ remote;
341
+ failure;
342
+ pending = /* @__PURE__ */ new Map();
343
+ watching = /* @__PURE__ */ new Map();
344
+ protocol = PROTOCOL;
345
+ nextId = 0;
346
+ port;
347
+ release;
348
+ constructor(port, release) {
349
+ this.port = port;
350
+ this.release = release;
351
+ this.greeted.promise.catch(() => void 0);
352
+ port.addEventListener("message", (event) => this.receive(event.data));
353
+ port.addEventListener("close", () => this.end(new KernelGoneError()));
354
+ port.start?.();
355
+ }
356
+ post(message) {
357
+ if (!this.failure) this.port.postMessage(message);
358
+ }
359
+ end(error) {
360
+ if (this.failure) return;
361
+ this.failure = error;
362
+ this.greeted.reject(error);
363
+ this.remote?.fail(error);
364
+ for (const call of this.pending.values()) call.reject(error);
365
+ this.pending.clear();
366
+ this.release();
367
+ this.port.close?.();
368
+ this.ended.resolve(error);
369
+ }
370
+ request(call, handlers = {}) {
371
+ const id = ++this.nextId;
372
+ const { failure: failure2 } = this;
373
+ if (failure2) return { id, done: Promise.reject(failure2) };
374
+ const done = new Promise((resolve, reject) => {
375
+ this.pending.set(id, { resolve, reject, ...handlers });
376
+ this.port.postMessage({ ...call, id });
377
+ });
378
+ return { id, done };
379
+ }
380
+ call(call) {
381
+ return this.request(call).done;
382
+ }
383
+ receive(data) {
384
+ if (data.watch !== void 0) {
385
+ this.watching.get(data.watch)?.(data.change);
386
+ return;
387
+ }
388
+ if (data.bye !== void 0) {
389
+ this.end(new KernelGoneError(data.bye));
390
+ return;
391
+ }
392
+ if (data.hello) {
393
+ this.greeted.resolve(data.hello);
394
+ return;
395
+ }
396
+ if (data.net !== void 0) {
397
+ this.remote?.receive(data);
398
+ return;
399
+ }
400
+ const reply = data;
401
+ const call = this.pending.get(reply.id);
402
+ if (!call) return;
403
+ if (reply.fd !== void 0) {
404
+ call.output?.(reply.fd, reply.bytes);
405
+ return;
406
+ }
407
+ if (reply.started !== void 0) {
408
+ call.started?.(reply.started);
409
+ return;
410
+ }
411
+ this.pending.delete(reply.id);
412
+ if (reply.error !== void 0) call.reject(new KernelCallError(reply.error, reply.code));
413
+ else call.resolve(reply.result);
414
+ }
415
+ };
416
+ async function spawnOn(channel, argv, opts) {
417
+ const { onStdout, onStderr, stdin, group: _, ...rest } = opts;
418
+ const started = Promise.withResolvers();
419
+ const options = { ...rest, ...stdin !== void 0 ? { stdin: bytesOf(stdin) } : {} };
420
+ const { done } = channel.request(
421
+ { op: "spawn", argv, options },
422
+ {
423
+ output: (fd, bytes) => (fd === 1 ? onStdout : onStderr)?.(bytes),
424
+ started: started.resolve
425
+ }
426
+ );
427
+ done.catch(started.reject);
428
+ const pid = await started.promise;
429
+ return {
430
+ pid,
431
+ pgid: pid,
432
+ exited: done,
433
+ signal: async (name = "SIGTERM") => void await channel.call({ op: "kill", pid: -pid, signal: name })
434
+ };
435
+ }
436
+ async function runOn(channel, argv, opts) {
437
+ const { onStdout, onStderr, ...rest } = opts;
438
+ const chunks3 = { 1: [], 2: [] };
439
+ const live = { 1: new TextDecoder(), 2: new TextDecoder() };
440
+ const tee = (fd, on) => (bytes) => {
441
+ chunks3[fd].push(bytes);
442
+ on?.(live[fd].decode(bytes, { stream: true }));
443
+ };
444
+ const child = await spawnOn(channel, argv, {
445
+ ...rest,
446
+ onStdout: tee(1, onStdout),
447
+ onStderr: tee(2, onStderr)
448
+ });
449
+ const status = await child.exited;
450
+ const text = (fd) => new TextDecoder().decode(concat(chunks3[fd]));
451
+ return { pid: child.pid, status, stdout: text(1), stderr: text(2) };
452
+ }
453
+ async function terminalOn(channel, argv, opts) {
454
+ const { onData, ...options } = opts;
455
+ let listener = onData ?? null;
456
+ const backlog = [];
457
+ const started = Promise.withResolvers();
458
+ const { id, done } = channel.request(
459
+ { op: "open-terminal", argv, options },
460
+ {
461
+ output: (_fd, bytes) => listener ? listener(bytes) : backlog.push(bytes),
462
+ started: started.resolve
463
+ }
464
+ );
465
+ done.catch(started.reject);
466
+ const pid = await started.promise;
467
+ const send2 = (action) => void channel.call({ op: "terminal", terminal: id, ...action }).catch(() => void 0);
468
+ return {
469
+ pid,
470
+ exited: done,
471
+ get onData() {
472
+ return listener;
473
+ },
474
+ set onData(next) {
475
+ listener = next;
476
+ if (next) for (const bytes of backlog.splice(0)) next(bytes);
477
+ },
478
+ write: (data) => send2({ action: "write", bytes: bytesOf(data) }),
479
+ resize: (cols, rows) => send2({ action: "resize", cols, rows }),
480
+ signal: (name) => send2({ action: "signal", signal: name }),
481
+ close: () => send2({ action: "close" })
482
+ };
483
+ }
484
+ function fsOn(channel) {
485
+ const fs = (method, ...args) => channel.call({ op: "fs", method, args });
486
+ const done = async (method, ...args) => void await fs(method, ...args);
487
+ return {
488
+ readFile: (path) => fs("readFile", path),
489
+ readText: async (path) => new TextDecoder().decode(await fs("readFile", path)),
490
+ writeFile: (path, data) => done("writeFile", path, data),
491
+ stat: (path) => fs("stat", path),
492
+ lstat: (path) => fs("lstat", path),
493
+ readdir: (path) => fs("readdir", path),
494
+ mkdir: (path) => done("mkdir", path),
495
+ rm: (path, o = {}) => done("rm", path, o.force === true),
496
+ rename: (from, to) => done("rename", from, to),
497
+ realpath: (path) => fs("realpath", path),
498
+ symlink: (target, path) => done("symlink", target, path),
499
+ readlink: (path) => fs("readlink", path),
500
+ exists: (path) => fs("exists", path),
501
+ async watch(paths, options, onChange) {
502
+ if (channel.protocol[1] < 1) {
503
+ throw new KernelCallError(
504
+ `this kernel speaks protocol ${channel.protocol.join(".")}, which has no watch`,
505
+ "ENOSYS"
506
+ );
507
+ }
508
+ const { id, done: done2 } = channel.request({
509
+ op: "watch",
510
+ paths,
511
+ recursive: options.recursive === true
512
+ });
513
+ channel.watching.set(id, onChange);
514
+ try {
515
+ await done2;
516
+ } catch (error) {
517
+ channel.watching.delete(id);
518
+ throw error;
519
+ }
520
+ return {
521
+ close: () => {
522
+ if (!channel.watching.delete(id)) return;
523
+ void channel.call({ op: "unwatch", watch: id }).catch(() => void 0);
524
+ }
525
+ };
526
+ }
527
+ };
528
+ }
529
+ async function greet(channel, lock, timeoutMs) {
530
+ channel.post({ hello: { protocol: PROTOCOL, ...lock ? { lock } : {} } });
531
+ const timer = setTimeout(
532
+ () => channel.end(
533
+ new KernelGoneError(`no slicc-kernel answered on this port within ${timeoutMs} ms`)
534
+ ),
535
+ timeoutMs
536
+ );
537
+ let hello;
538
+ try {
539
+ hello = await channel.greeted.promise;
540
+ } finally {
541
+ clearTimeout(timer);
542
+ }
543
+ const refused = hello.error ?? versionError(hello.protocol);
544
+ if (refused) {
545
+ const error = new Error(refused);
546
+ channel.end(error);
547
+ throw error;
548
+ }
549
+ return hello;
550
+ }
551
+ async function attachKernel(port, options = {}) {
552
+ const locks = options.locks === null ? void 0 : options.locks ?? locksOf();
553
+ const own = await holdLock(locks);
554
+ const channel = new Channel(port, own.release);
555
+ const hello = await greet(channel, own.name, options.timeoutMs ?? 1e4);
556
+ channel.protocol = hello.protocol;
557
+ if (hello.lock && locks) void locks.request(hello.lock, () => channel.end(new KernelGoneError()));
558
+ const transport = new RemoteTransport(
559
+ { postMessage: (call) => channel.post(call) },
560
+ hello.traits ?? UNAVAILABLE
561
+ );
562
+ channel.remote = transport;
563
+ return {
564
+ protocol: hello.protocol,
565
+ transport,
566
+ closed: channel.ended.promise,
567
+ spawn: (argv, opts = {}) => spawnOn(channel, argv, opts),
568
+ run: (argv, opts = {}) => runOn(channel, argv, opts),
569
+ openTerminal: (argv, opts = {}) => terminalOn(channel, argv, opts),
570
+ ps: () => channel.call({ op: "ps" }),
571
+ kill: async (pid, signal = "SIGTERM") => void await channel.call({ op: "kill", pid, signal }),
572
+ fetch: (req) => {
573
+ const { signal, ...rest } = req;
574
+ return transport.fetch({ ...rest, signal: signal ?? new AbortController().signal });
575
+ },
576
+ fs: fsOn(channel),
577
+ async close(o = {}) {
578
+ if (channel.failure) return;
579
+ await channel.call({ op: "detach", ...o.kill ? { kill: true } : {} }).catch(() => void 0);
580
+ channel.end(new KernelGoneError("this client is closed"));
581
+ }
582
+ };
583
+ }
584
+
167
585
  // src/local-proxy-transport.ts
168
586
  var PATH = "/api/fetch-proxy";
169
587
  var RAW_CONTENT_TYPE = "application/vnd.slicc.raw-fetch";
@@ -212,7 +630,7 @@ function isHead(value) {
212
630
  (pair) => Array.isArray(pair) && pair.length === 2 && typeof pair[0] === "string" && typeof pair[1] === "string"
213
631
  );
214
632
  }
215
- function concat(a, b) {
633
+ function concat2(a, b) {
216
634
  const out = new Uint8Array(a.byteLength + b.byteLength);
217
635
  out.set(a);
218
636
  out.set(b, a.byteLength);
@@ -236,7 +654,7 @@ async function readHead(reader, idleMs) {
236
654
  }
237
655
  const next = await readWithin(reader, idleMs);
238
656
  if (next.done) throw failure("closed before the response head");
239
- buffer = concat(buffer, next.value);
657
+ buffer = concat2(buffer, next.value);
240
658
  }
241
659
  }
242
660
  async function* chunks2(rest, reader, idleMs) {
@@ -446,6 +864,7 @@ async function createKernel(options = {}) {
446
864
  };
447
865
  },
448
866
  openTerminal,
867
+ connect: async () => await call({ op: "connect" }),
449
868
  terminate() {
450
869
  fail(new Error("slicc-kernel terminated"));
451
870
  worker.terminate();
@@ -453,6 +872,9 @@ async function createKernel(options = {}) {
453
872
  };
454
873
  }
455
874
  export {
875
+ KernelCallError,
876
+ KernelGoneError,
877
+ attachKernel,
456
878
  checkLocalProxy,
457
879
  createKernel,
458
880
  fetchTransport,