@ai-ecoverse/slicc-kernel 1.0.0 → 1.1.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
@@ -37,10 +37,30 @@ Runs `argv[0]` with `argv` as its arguments and resolves when it exits. `stdout`
37
37
  | option | |
38
38
  | --- | --- |
39
39
  | `cwd` | working directory, default `/`; created in OPFS if missing |
40
- | `env` | extra environment; the defaults are `PATH=/usr/bin:/bin`, `HOME=/`, `PWD=<cwd>` |
40
+ | `env` | extra environment; the defaults are `PATH=/usr/bin:/bin`, `HOME=/home`, `PWD=<cwd>` |
41
41
  | `stdin` | a string or `Uint8Array`; without it stdin is `/dev/null` |
42
42
  | `onStdout`, `onStderr` | called with each chunk of text as it is written |
43
43
 
44
+ ### `kernel.openTerminal(argv, options?) → Promise<Terminal>`
45
+
46
+ Starts `argv` as the session leader on a new terminal and resolves once it is running, or rejects if `argv[0]` cannot be found. Its stdin, stdout and stderr are the terminal, which has a line discipline with echo, canonical mode and `ISIG`, and supports job control, so `kernel.openTerminal(['bash', '-i'])` gives an interactive shell. The API was designed with [slicc-spectrum](https://github.com/ai-ecoverse/slicc-spectrum), a terminal web component, and these names are kept stable.
47
+
48
+ | option | |
49
+ | --- | --- |
50
+ | `cwd`, `env` | as for `run`; `TERM=xterm-256color` and `COLORTERM=truecolor` are added |
51
+ | `cols`, `rows` | initial size, default 80 × 24, so `$COLUMNS` and `$LINES` are right before the first prompt |
52
+ | `onData` | called with each chunk of output as raw bytes (escape sequences included) |
53
+
54
+ | `Terminal` | |
55
+ | --- | --- |
56
+ | `pid` | the session leader's pid |
57
+ | `onData` | the output listener; output that arrives while it is `null` is kept and delivered when one is set |
58
+ | `write(data)` | types a string or bytes into the terminal; with `ISIG` on, `^C`, `^Z` and `^\` signal the foreground process group |
59
+ | `resize(cols, rows)` | sets the window size (`TIOCGWINSZ`) and sends `SIGWINCH` to the foreground process group |
60
+ | `signal(name)` | sends a signal (`'SIGINT'`, `'SIGTSTP'`, `'SIGQUIT'`, `'SIGHUP'`, …) to the foreground process group, regardless of termios |
61
+ | `exited` | resolves with the session leader's exit status (`128 + n` if killed by signal `n`) |
62
+ | `close()` | hangs up: `SIGHUP` to the foreground process group, and reads from the terminal return end-of-file |
63
+
44
64
  ### `kernel.terminate()`
45
65
 
46
66
  Stops the kernel worker and every process. Pending and later calls reject.
@@ -53,14 +73,18 @@ Commands come from installed packages in npm's `node_modules` layout: every `<mo
53
73
  { "slicc": { "abi": "emscripten", "commands": { "bash": { "glue": "bin/bash", "wasm": "bin/bash.wasm" } } } }
54
74
  ```
55
75
 
56
- Entries may also set `argv0`, `args` and `env` (with `${package}` expanded to the package directory), and a package-wide `slicc.env`. The first package to define a name wins. `sh` runs bash as `sh` unless a package provides its own. The installed commands appear as executables in a virtual `/usr/bin` and `/bin`, so `PATH` lookups, `command -v` and `ls /usr/bin` work without writing anything to OPFS. A process can also run a program by the path of its glue when the `.wasm` sits next to it, and a script through its `#!` line (including `#!/usr/bin/env name`). The catalog is re-read at the start of every `run`, so packages installed in between are picked up.
76
+ Entries may also set `argv0`, `args` and `env` (with `${package}` expanded to the package directory), and a package-wide `slicc.env`. The first package to define a name wins. `sh` runs bash as `sh` unless a package provides its own. The installed commands appear as executables in a virtual `/usr/bin` and `/bin`, so `PATH` lookups, `command -v` and `ls /usr/bin` work without writing anything to OPFS. A process can also run a program by the path of its glue when the `.wasm` sits next to it, and a script through its `#!` line (including `#!/usr/bin/env name`). Executing any other file fails with `ENOEXEC`, so bash runs it as a shell script, as POSIX shells do. The catalog is re-read at the start of every `run`, so packages installed in between are picked up.
57
77
 
58
78
  ## Filesystem
59
79
 
60
- `/` is the OPFS root. Each process mounts the top-level directories that exist when it starts (plus `/usr` and `/bin`), so files in them are shared by all processes and visible through the OPFS API as soon as the process that wrote them has closed them; `run` resolves only after that. `/tmp`, `/dev`, `/proc` and anything created directly in `/` while a process runs live in that process's memory unless the directory already exists in OPFS. File contents are buffered per open file and written back on close, `fsync` and exit; metadata operations (`mkdir`, `rename`, `rm`, …) go straight to OPFS. The kernel worker is the only writer.
80
+ `/` is the OPFS root. Each process mounts the top-level directories that exist when it starts (plus `/usr` and `/bin`), so files in them are shared by all processes and visible through the OPFS API as soon as the process that wrote them has closed them; `run` resolves only after that. `createKernel` creates `/tmp` and `/home` in OPFS, so they are shared too. `/dev`, `/proc` and anything created directly in `/` while a process runs live in that process's memory unless it already exists in OPFS. File contents are buffered per open file and written back on close, `fsync` and exit; metadata operations (`mkdir`, `rename`, `rm`, …) go straight to OPFS. The kernel worker is the only writer.
61
81
 
62
82
  OPFS stores no POSIX metadata, so modes are synthesized: directories `0755`, files `0644`, and files under `node_modules/*/bin/` `0755`. `chmod` and `utime` are remembered for the lifetime of the kernel worker. Symlinks are not supported (`ENOSYS`). Directories are renamed with `FileSystemHandle.move()` where available, else by copy and delete.
63
83
 
84
+ Reads retry briefly when a concurrent write has invalidated the OPFS file snapshot (`NotReadableError`).
85
+
86
+ The filesystem is our own rather than [ZenFS](https://github.com/zen-fs/core) (which SLICC uses), because OPFS stays the single source of truth: other writers, such as the BIOS installing packages or a page writing files, need no index to stay consistent with, and nothing is preloaded into memory at mount. In exchange there are no persistent POSIX modes, times, inode numbers or symlinks.
87
+
64
88
  ## What is in here
65
89
 
66
90
  The kernel is ported from SLICC's `packages/webapp/src/kernel/` with the browser-specific VFS replaced by an OPFS one:
@@ -73,6 +97,10 @@ The kernel is ported from SLICC's `packages/webapp/src/kernel/` with the browser
73
97
 
74
98
  SLICC's networking (sockets, the TLS proxy) and WASI support are not included.
75
99
 
100
+ ## Installing from git
101
+
102
+ `npm install github:ai-ecoverse/slicc-kernel#<sha>` works: the `prepare` script builds `dist/`.
103
+
76
104
  ## Development
77
105
 
78
106
  ```sh
package/dist/index.js CHANGED
@@ -20,6 +20,7 @@ async function createKernel(options = {}) {
20
20
  const call2 = pending.get(data.id);
21
21
  if (!call2) return;
22
22
  if (data.fd !== void 0) return call2.output?.(data.fd, data.bytes);
23
+ if (data.started !== void 0) return call2.started?.(data.started);
23
24
  pending.delete(data.id);
24
25
  if (data.error !== void 0) call2.reject(new Error(data.error));
25
26
  else call2.resolve(data.result);
@@ -28,13 +29,49 @@ async function createKernel(options = {}) {
28
29
  event.preventDefault();
29
30
  fail(new Error(`slicc-kernel worker failed: ${event.message}`));
30
31
  });
31
- function call(req, output) {
32
- if (failure) return Promise.reject(failure);
32
+ function request(req, handlers = {}) {
33
33
  const id = ++nextId;
34
- return new Promise((resolve, reject) => {
35
- pending.set(id, { resolve, reject, ...output ? { output } : {} });
34
+ if (failure) return { id, done: Promise.reject(failure) };
35
+ const done = new Promise((resolve, reject) => {
36
+ pending.set(id, { resolve, reject, ...handlers });
36
37
  worker.postMessage({ ...req, id });
37
38
  });
39
+ return { id, done };
40
+ }
41
+ const call = (req, handlers) => request(req, handlers).done;
42
+ async function openTerminal(argv, opts = {}) {
43
+ const { onData, ...options2 } = opts;
44
+ let listener = onData ?? null;
45
+ const backlog = [];
46
+ const started = Promise.withResolvers();
47
+ const { id, done } = request(
48
+ { op: "open-terminal", argv, options: options2 },
49
+ {
50
+ output: (_fd, bytes) => listener ? listener(bytes) : backlog.push(bytes),
51
+ started: started.resolve
52
+ }
53
+ );
54
+ done.catch(started.reject);
55
+ const pid = await started.promise;
56
+ const send = (action) => void call({ op: "terminal", terminal: id, ...action }).catch(() => void 0);
57
+ return {
58
+ pid,
59
+ exited: done,
60
+ get onData() {
61
+ return listener;
62
+ },
63
+ set onData(next) {
64
+ listener = next;
65
+ if (next) for (const bytes of backlog.splice(0)) next(bytes);
66
+ },
67
+ write: (data) => send({
68
+ action: "write",
69
+ bytes: typeof data === "string" ? new TextEncoder().encode(data) : data
70
+ }),
71
+ resize: (cols, rows) => send({ action: "resize", cols, rows }),
72
+ signal: (name) => send({ action: "signal", signal: name }),
73
+ close: () => send({ action: "close" })
74
+ };
38
75
  }
39
76
  await call({
40
77
  op: "init",
@@ -49,7 +86,7 @@ async function createKernel(options = {}) {
49
86
  const input = typeof stdin === "string" ? new TextEncoder().encode(stdin) : stdin;
50
87
  const result = await call(
51
88
  { op: "run", argv, options: { ...rest, ...input ? { stdin: input } : {} } },
52
- (fd, bytes) => streams[fd](bytes)
89
+ { output: (fd, bytes) => streams[fd](bytes) }
53
90
  );
54
91
  const decoder = new TextDecoder();
55
92
  return {
@@ -58,6 +95,7 @@ async function createKernel(options = {}) {
58
95
  stderr: decoder.decode(result.stderr)
59
96
  };
60
97
  },
98
+ openTerminal,
61
99
  terminate() {
62
100
  fail(new Error("slicc-kernel terminated"));
63
101
  worker.terminate();
package/dist/index.js.map CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../src/index.ts"],
4
- "sourcesContent": ["import type { KernelCall } from './serve.ts';\n\nexport interface KernelOptions {\n root?: FileSystemDirectoryHandle;\n modules?: string;\n env?: Record<string, string>;\n worker?: string | URL;\n}\n\nexport interface RunOptions {\n cwd?: string;\n env?: Record<string, string>;\n stdin?: string | Uint8Array;\n onStdout?: (text: string) => void;\n onStderr?: (text: string) => void;\n}\n\nexport interface RunResult {\n status: number;\n stdout: string;\n stderr: string;\n}\n\nexport interface Kernel {\n run(argv: string[], options?: RunOptions): Promise<RunResult>;\n terminate(): void;\n}\n\ninterface Reply {\n id: number;\n result?: unknown;\n error?: string;\n fd?: 1 | 2;\n bytes?: Uint8Array;\n}\n\ninterface Pending {\n resolve: (value: unknown) => void;\n reject: (error: Error) => void;\n output?: (fd: 1 | 2, bytes: Uint8Array) => void;\n}\n\nconst ISOLATION =\n 'slicc-kernel needs a cross-origin isolated page (Cross-Origin-Opener-Policy: same-origin and Cross-Origin-Embedder-Policy: require-corp or credentialless)';\n\nfunction streamer(callback: ((text: string) => void) | undefined) {\n const decoder = new TextDecoder();\n return (bytes: Uint8Array) => callback?.(decoder.decode(bytes, { stream: true }));\n}\n\nexport async function createKernel(options: KernelOptions = {}): Promise<Kernel> {\n if (!globalThis.crossOriginIsolated) throw new Error(ISOLATION);\n const url = options.worker ?? new URL('./kernel-worker.js', import.meta.url);\n const worker = new Worker(url, { type: 'module', name: 'slicc-kernel' });\n const pending = new Map<number, Pending>();\n let nextId = 0;\n let failure: Error | undefined;\n\n const fail = (error: Error) => {\n failure = error;\n for (const call of pending.values()) call.reject(error);\n pending.clear();\n };\n worker.addEventListener('message', ({ data }: MessageEvent<Reply>) => {\n const call = pending.get(data.id);\n if (!call) return;\n if (data.fd !== undefined) return call.output?.(data.fd, data.bytes as Uint8Array);\n pending.delete(data.id);\n if (data.error !== undefined) call.reject(new Error(data.error));\n else call.resolve(data.result);\n });\n worker.addEventListener('error', (event) => {\n event.preventDefault();\n fail(new Error(`slicc-kernel worker failed: ${event.message}`));\n });\n\n function call(req: KernelCall, output?: Pending['output']): Promise<unknown> {\n if (failure) return Promise.reject(failure);\n const id = ++nextId;\n return new Promise((resolve, reject) => {\n pending.set(id, { resolve, reject, ...(output ? { output } : {}) });\n worker.postMessage({ ...req, id });\n });\n }\n\n await call({\n op: 'init',\n ...(options.root ? { root: options.root } : {}),\n ...(options.modules ? { modules: options.modules } : {}),\n ...(options.env ? { env: options.env } : {}),\n });\n\n return {\n async run(argv, runOptions = {}) {\n const { onStdout, onStderr, stdin, ...rest } = runOptions;\n const streams = { 1: streamer(onStdout), 2: streamer(onStderr) };\n const input = typeof stdin === 'string' ? new TextEncoder().encode(stdin) : stdin;\n const result = (await call(\n { op: 'run', argv, options: { ...rest, ...(input ? { stdin: input } : {}) } },\n (fd, bytes) => streams[fd](bytes)\n )) as { status: number; stdout: Uint8Array; stderr: Uint8Array };\n const decoder = new TextDecoder();\n return {\n status: result.status,\n stdout: decoder.decode(result.stdout),\n stderr: decoder.decode(result.stderr),\n };\n },\n terminate() {\n fail(new Error('slicc-kernel terminated'));\n worker.terminate();\n },\n };\n}\n"],
5
- "mappings": "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",
6
- "names": ["call"]
4
+ "sourcesContent": ["import type { KernelCall, TerminalAction } from './serve.ts';\n\nexport interface KernelOptions {\n root?: FileSystemDirectoryHandle;\n modules?: string;\n env?: Record<string, string>;\n worker?: string | URL;\n}\n\nexport interface RunOptions {\n cwd?: string;\n env?: Record<string, string>;\n stdin?: string | Uint8Array;\n onStdout?: (text: string) => void;\n onStderr?: (text: string) => void;\n}\n\nexport interface RunResult {\n status: number;\n stdout: string;\n stderr: string;\n}\n\nexport interface Kernel {\n run(argv: string[], options?: RunOptions): Promise<RunResult>;\n openTerminal(argv: string[], options?: TerminalOptions): Promise<Terminal>;\n terminate(): void;\n}\n\nexport interface TerminalOptions {\n cwd?: string;\n env?: Record<string, string>;\n cols?: number;\n rows?: number;\n onData?: (bytes: Uint8Array) => void;\n}\n\nexport type TerminalSignal =\n | 'SIGINT'\n | 'SIGTSTP'\n | 'SIGQUIT'\n | 'SIGHUP'\n | 'SIGTERM'\n | 'SIGKILL'\n | 'SIGCONT';\n\nexport interface Terminal {\n readonly pid: number;\n readonly exited: Promise<number>;\n onData: ((bytes: Uint8Array) => void) | null;\n write(data: string | Uint8Array): void;\n resize(cols: number, rows: number): void;\n signal(name: TerminalSignal): void;\n close(): void;\n}\n\ninterface Reply {\n id: number;\n result?: unknown;\n error?: string;\n fd?: 1 | 2;\n bytes?: Uint8Array;\n started?: number;\n}\n\ninterface Pending {\n resolve: (value: unknown) => void;\n reject: (error: Error) => void;\n output?: (fd: 1 | 2, bytes: Uint8Array) => void;\n started?: (pid: number) => void;\n}\n\ninterface Handlers {\n output?: Pending['output'];\n started?: Pending['started'];\n}\n\nconst ISOLATION =\n 'slicc-kernel needs a cross-origin isolated page (Cross-Origin-Opener-Policy: same-origin and Cross-Origin-Embedder-Policy: require-corp or credentialless)';\n\nfunction streamer(callback: ((text: string) => void) | undefined) {\n const decoder = new TextDecoder();\n return (bytes: Uint8Array) => callback?.(decoder.decode(bytes, { stream: true }));\n}\n\nexport async function createKernel(options: KernelOptions = {}): Promise<Kernel> {\n if (!globalThis.crossOriginIsolated) throw new Error(ISOLATION);\n const url = options.worker ?? new URL('./kernel-worker.js', import.meta.url);\n const worker = new Worker(url, { type: 'module', name: 'slicc-kernel' });\n const pending = new Map<number, Pending>();\n let nextId = 0;\n let failure: Error | undefined;\n\n const fail = (error: Error) => {\n failure = error;\n for (const call of pending.values()) call.reject(error);\n pending.clear();\n };\n worker.addEventListener('message', ({ data }: MessageEvent<Reply>) => {\n const call = pending.get(data.id);\n if (!call) return;\n if (data.fd !== undefined) return call.output?.(data.fd, data.bytes as Uint8Array);\n if (data.started !== undefined) return call.started?.(data.started);\n pending.delete(data.id);\n if (data.error !== undefined) call.reject(new Error(data.error));\n else call.resolve(data.result);\n });\n worker.addEventListener('error', (event) => {\n event.preventDefault();\n fail(new Error(`slicc-kernel worker failed: ${event.message}`));\n });\n\n function request(\n req: KernelCall,\n handlers: Handlers = {}\n ): { id: number; done: Promise<unknown> } {\n const id = ++nextId;\n if (failure) return { id, done: Promise.reject(failure) };\n const done = new Promise((resolve, reject) => {\n pending.set(id, { resolve, reject, ...handlers });\n worker.postMessage({ ...req, id });\n });\n return { id, done };\n }\n\n const call = (req: KernelCall, handlers?: Handlers) => request(req, handlers).done;\n\n async function openTerminal(argv: string[], opts: TerminalOptions = {}): Promise<Terminal> {\n const { onData, ...options } = opts;\n let listener = onData ?? null;\n const backlog: Uint8Array[] = [];\n const started = Promise.withResolvers<number>();\n const { id, done } = request(\n { op: 'open-terminal', argv, options },\n {\n output: (_fd, bytes) => (listener ? listener(bytes) : backlog.push(bytes)),\n started: started.resolve,\n }\n );\n done.catch(started.reject);\n const pid = await started.promise;\n const send = (action: TerminalAction) =>\n void call({ op: 'terminal', terminal: id, ...action }).catch(() => undefined);\n return {\n pid,\n exited: done as Promise<number>,\n get onData() {\n return listener;\n },\n set onData(next) {\n listener = next;\n if (next) for (const bytes of backlog.splice(0)) next(bytes);\n },\n write: (data) =>\n send({\n action: 'write',\n bytes: typeof data === 'string' ? new TextEncoder().encode(data) : data,\n }),\n resize: (cols, rows) => send({ action: 'resize', cols, rows }),\n signal: (name) => send({ action: 'signal', signal: name }),\n close: () => send({ action: 'close' }),\n };\n }\n\n await call({\n op: 'init',\n ...(options.root ? { root: options.root } : {}),\n ...(options.modules ? { modules: options.modules } : {}),\n ...(options.env ? { env: options.env } : {}),\n });\n\n return {\n async run(argv, runOptions = {}) {\n const { onStdout, onStderr, stdin, ...rest } = runOptions;\n const streams = { 1: streamer(onStdout), 2: streamer(onStderr) };\n const input = typeof stdin === 'string' ? new TextEncoder().encode(stdin) : stdin;\n const result = (await call(\n { op: 'run', argv, options: { ...rest, ...(input ? { stdin: input } : {}) } },\n { output: (fd, bytes) => streams[fd](bytes) }\n )) as { status: number; stdout: Uint8Array; stderr: Uint8Array };\n const decoder = new TextDecoder();\n return {\n status: result.status,\n stdout: decoder.decode(result.stdout),\n stderr: decoder.decode(result.stderr),\n };\n },\n openTerminal,\n terminate() {\n fail(new Error('slicc-kernel terminated'));\n worker.terminate();\n },\n };\n}\n"],
5
+ "mappings": "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",
6
+ "names": ["call", "options"]
7
7
  }
@@ -19,6 +19,7 @@ function resolvePath(base, path) {
19
19
  var S_IFDIR = 16384;
20
20
  var S_IFREG = 32768;
21
21
  var PERM_MASK = 4095;
22
+ var READ_ATTEMPTS = 3;
22
23
  var EXECUTABLE = /\/node_modules\/(?:@[^/]+\/)?[^/]+\/bin\//;
23
24
  var ERRNO_BY_DOM_ERROR = {
24
25
  NotFoundError: "ENOENT",
@@ -102,7 +103,16 @@ var OpfsFs = class {
102
103
  async readFileBuffer(path) {
103
104
  const handle = await this.handle(path);
104
105
  if (handle.kind === "directory") throw fsError("EISDIR", path);
105
- return new Uint8Array(await (await handle.getFile()).arrayBuffer());
106
+ for (let attempt = 1; ; attempt++) {
107
+ try {
108
+ return new Uint8Array(await (await handle.getFile()).arrayBuffer());
109
+ } catch (err) {
110
+ if (err?.name !== "NotReadableError" || attempt >= READ_ATTEMPTS) {
111
+ throw translate(err, path);
112
+ }
113
+ await new Promise((resolve) => setTimeout(resolve, attempt * 10));
114
+ }
115
+ }
106
116
  }
107
117
  async readFile(path) {
108
118
  return new TextDecoder().decode(await this.readFileBuffer(path));
@@ -225,6 +235,61 @@ var OpfsFs = class {
225
235
  }
226
236
  };
227
237
 
238
+ // src/kernel/signals.ts
239
+ var SIG = {
240
+ HUP: 1,
241
+ INT: 2,
242
+ QUIT: 3,
243
+ ILL: 4,
244
+ TRAP: 5,
245
+ ABRT: 6,
246
+ BUS: 7,
247
+ FPE: 8,
248
+ KILL: 9,
249
+ USR1: 10,
250
+ SEGV: 11,
251
+ USR2: 12,
252
+ PIPE: 13,
253
+ ALRM: 14,
254
+ TERM: 15,
255
+ STKFLT: 16,
256
+ CHLD: 17,
257
+ CONT: 18,
258
+ STOP: 19,
259
+ TSTP: 20,
260
+ TTIN: 21,
261
+ TTOU: 22,
262
+ URG: 23,
263
+ XCPU: 24,
264
+ XFSZ: 25,
265
+ VTALRM: 26,
266
+ PROF: 27,
267
+ WINCH: 28,
268
+ POLL: 29,
269
+ PWR: 30,
270
+ SYS: 31
271
+ };
272
+ var IGNORED_BY_DEFAULT = /* @__PURE__ */ new Set([SIG.CHLD, SIG.URG, SIG.WINCH]);
273
+ var STOPPING = /* @__PURE__ */ new Set([SIG.STOP, SIG.TSTP, SIG.TTIN, SIG.TTOU]);
274
+ function defaultAction(sig) {
275
+ if (sig === SIG.CONT) return "continue";
276
+ if (STOPPING.has(sig)) return "stop";
277
+ return IGNORED_BY_DEFAULT.has(sig) ? "ignore" : "terminate";
278
+ }
279
+ function isSignal(sig) {
280
+ return Number.isInteger(sig) && sig >= 1 && sig <= 31;
281
+ }
282
+ function sigbit(sig) {
283
+ return 1 << sig;
284
+ }
285
+ var SIGNAL_BY_NAME = {
286
+ SIGINT: SIG.INT,
287
+ SIGTERM: SIG.TERM,
288
+ SIGKILL: SIG.KILL,
289
+ SIGSTOP: SIG.STOP,
290
+ SIGCONT: SIG.CONT
291
+ };
292
+
228
293
  // src/commands.ts
229
294
  var NAME = /^[A-Za-z0-9._+-]+$/;
230
295
  var ENV_KEY = /^[A-Za-z_][A-Za-z0-9_]*$/;
@@ -1163,61 +1228,6 @@ function attachSyncSabResponder(port, sab, token, opts) {
1163
1228
  };
1164
1229
  }
1165
1230
 
1166
- // src/kernel/signals.ts
1167
- var SIG = {
1168
- HUP: 1,
1169
- INT: 2,
1170
- QUIT: 3,
1171
- ILL: 4,
1172
- TRAP: 5,
1173
- ABRT: 6,
1174
- BUS: 7,
1175
- FPE: 8,
1176
- KILL: 9,
1177
- USR1: 10,
1178
- SEGV: 11,
1179
- USR2: 12,
1180
- PIPE: 13,
1181
- ALRM: 14,
1182
- TERM: 15,
1183
- STKFLT: 16,
1184
- CHLD: 17,
1185
- CONT: 18,
1186
- STOP: 19,
1187
- TSTP: 20,
1188
- TTIN: 21,
1189
- TTOU: 22,
1190
- URG: 23,
1191
- XCPU: 24,
1192
- XFSZ: 25,
1193
- VTALRM: 26,
1194
- PROF: 27,
1195
- WINCH: 28,
1196
- POLL: 29,
1197
- PWR: 30,
1198
- SYS: 31
1199
- };
1200
- var IGNORED_BY_DEFAULT = /* @__PURE__ */ new Set([SIG.CHLD, SIG.URG, SIG.WINCH]);
1201
- var STOPPING = /* @__PURE__ */ new Set([SIG.STOP, SIG.TSTP, SIG.TTIN, SIG.TTOU]);
1202
- function defaultAction(sig) {
1203
- if (sig === SIG.CONT) return "continue";
1204
- if (STOPPING.has(sig)) return "stop";
1205
- return IGNORED_BY_DEFAULT.has(sig) ? "ignore" : "terminate";
1206
- }
1207
- function isSignal(sig) {
1208
- return Number.isInteger(sig) && sig >= 1 && sig <= 31;
1209
- }
1210
- function sigbit(sig) {
1211
- return 1 << sig;
1212
- }
1213
- var SIGNAL_BY_NAME = {
1214
- SIGINT: SIG.INT,
1215
- SIGTERM: SIG.TERM,
1216
- SIGKILL: SIG.KILL,
1217
- SIGSTOP: SIG.STOP,
1218
- SIGCONT: SIG.CONT
1219
- };
1220
-
1221
1231
  // src/kernel/tty.ts
1222
1232
  var ICRNL = 256;
1223
1233
  var IUTF8 = 16384;
@@ -2719,6 +2729,7 @@ var JobTable = class {
2719
2729
  var COMMAND2 = /^\/(?:usr\/)?bin\/([^/]+)$/;
2720
2730
  var SHEBANG_MAX = 256;
2721
2731
  var NOT_FOUND = 127;
2732
+ var SHARED_DIRS = ["/tmp", "/home"];
2722
2733
  var encoder3 = new TextEncoder();
2723
2734
  function baseName(path) {
2724
2735
  return path.slice(path.lastIndexOf("/") + 1);
@@ -2766,6 +2777,7 @@ var Launcher = class {
2766
2777
  jobs = new JobTable();
2767
2778
  ptys = new PtyTable((tty, sig) => this.jobs.signalOwnedForeground(tty, sig));
2768
2779
  nextPid = 1e3;
2780
+ terminals = 0;
2769
2781
  constructor(options) {
2770
2782
  this.base = options.fs;
2771
2783
  this.createWorker = options.createWorker;
@@ -2815,6 +2827,10 @@ var Launcher = class {
2815
2827
  if (direct) return { target: direct, args: [...direct.prefix ?? [], ...argv.slice(1)] };
2816
2828
  return this.interpreted(file, argv, cwd);
2817
2829
  }
2830
+ async unrunnable(file, cwd) {
2831
+ const head = await this.base.readFileBuffer(this.fs.resolvePath(cwd, file)).catch(() => null);
2832
+ return head && !(head[0] === 35 && head[1] === 33) ? "ENOEXEC" : "ENOENT";
2833
+ }
2818
2834
  module(path) {
2819
2835
  return this.base.stat(path).then((st) => {
2820
2836
  const key = `${path}:${st.size}:${st.mtime.getTime()}`;
@@ -2889,7 +2905,7 @@ var Launcher = class {
2889
2905
  const planned = await this.plan(req.file, req.argv, req.cwd);
2890
2906
  if (!planned) {
2891
2907
  await fds.closeAll();
2892
- throw new SpawnError("ENOENT");
2908
+ throw new SpawnError(await this.unrunnable(req.file, req.cwd));
2893
2909
  }
2894
2910
  const handle = await this.launch(planned, { env: req.env, cwd: req.cwd, fds, report, ppid });
2895
2911
  return childHandle(handle);
@@ -2905,10 +2921,59 @@ var Launcher = class {
2905
2921
  if (sig !== 0) handle.signal(sig);
2906
2922
  return true;
2907
2923
  }
2908
- async run(argv, options = {}) {
2924
+ async prepare() {
2925
+ for (const dir of SHARED_DIRS) await this.base.mkdir(dir, { recursive: true });
2926
+ }
2927
+ environment(cwd, extra) {
2928
+ return { PATH: "/usr/bin:/bin", HOME: "/home", ...this.env, PWD: cwd, ...extra };
2929
+ }
2930
+ async starting(argv, dir) {
2909
2931
  this.catalog = void 0;
2910
- const cwd = this.fs.resolvePath("/", options.cwd ?? "/");
2911
- const env = { PATH: "/usr/bin:/bin", HOME: "/", ...this.env, PWD: cwd, ...options.env };
2932
+ const cwd = this.fs.resolvePath("/", dir ?? "/");
2933
+ const [file = ""] = argv;
2934
+ await this.base.mkdir(cwd, { recursive: true });
2935
+ return { cwd, file, planned: await this.plan(file, argv, cwd) };
2936
+ }
2937
+ async openTerminal(argv, options) {
2938
+ const { cwd, file, planned } = await this.starting(argv, options.cwd);
2939
+ if (!planned) throw new Error(`${file}: command not found`);
2940
+ let leader = 0;
2941
+ const tty = new KernelTty(
2942
+ { write: (bytes) => options.onData(bytes.slice()) },
2943
+ (sig) => this.jobs.signalForeground(tty, leader, sig)
2944
+ );
2945
+ tty.name = `/dev/tty${++this.terminals}`;
2946
+ tty.setSize(options.cols ?? 80, options.rows ?? 24);
2947
+ const fds = new FdTable();
2948
+ const stdio = tty.file();
2949
+ fds.installAt(0, stdio);
2950
+ fds.installAt(1, stdio.retain());
2951
+ fds.installAt(2, stdio.retain());
2952
+ const env = this.environment(cwd, {
2953
+ TERM: "xterm-256color",
2954
+ COLORTERM: "truecolor",
2955
+ ...options.env
2956
+ });
2957
+ const report = (message) => options.onData(encoder3.encode(`${message}\r
2958
+ `));
2959
+ const handle = await this.launch(planned, { env, cwd, fds, report });
2960
+ leader = handle.pid;
2961
+ void handle.exited.then(() => tty.hangup());
2962
+ return {
2963
+ pid: handle.pid,
2964
+ exited: handle.exited,
2965
+ write: (bytes) => tty.receive(bytes),
2966
+ resize: (cols, rows) => tty.resize(cols, rows),
2967
+ signal: (sig) => this.jobs.signalForeground(tty, leader, sig),
2968
+ close: () => {
2969
+ tty.signalHangup();
2970
+ tty.hangup();
2971
+ }
2972
+ };
2973
+ }
2974
+ async run(argv, options = {}) {
2975
+ const { cwd, file, planned } = await this.starting(argv, options.cwd);
2976
+ const env = this.environment(cwd, options.env);
2912
2977
  const out = [];
2913
2978
  const err = [];
2914
2979
  const collect = (chunks, tee) => (bytes) => {
@@ -2919,9 +2984,6 @@ var Launcher = class {
2919
2984
  const stderr = collect(err, options.onStderr);
2920
2985
  const report = (message) => stderr(encoder3.encode(`${message}
2921
2986
  `));
2922
- const [file = ""] = argv;
2923
- await this.base.mkdir(cwd, { recursive: true });
2924
- const planned = await this.plan(file, argv, cwd);
2925
2987
  if (!planned) {
2926
2988
  report(`${file}: command not found`);
2927
2989
  return { status: NOT_FOUND, stdout: concat2(out), stderr: concat2(err) };
@@ -2937,24 +2999,60 @@ var Launcher = class {
2937
2999
  };
2938
3000
 
2939
3001
  // src/serve.ts
3002
+ function signalNumber(name) {
3003
+ const key = name.slice(3);
3004
+ if (!name.startsWith("SIG") || !Object.hasOwn(SIG, key))
3005
+ throw new Error(`unknown signal ${name}`);
3006
+ return SIG[key];
3007
+ }
3008
+ function act(session, req) {
3009
+ if (req.action === "write") session.write(req.bytes);
3010
+ else if (req.action === "resize") session.resize(req.cols, req.rows);
3011
+ else if (req.action === "signal") session.signal(signalNumber(req.signal));
3012
+ else session.close();
3013
+ }
2940
3014
  function serveKernel(port, deps) {
2941
3015
  let launcher;
3016
+ const terminals = /* @__PURE__ */ new Map();
2942
3017
  const reply = (id, body) => port.postMessage({ id, ...body });
3018
+ async function ready2() {
3019
+ if (!launcher) throw new Error("the kernel is not initialized");
3020
+ return launcher;
3021
+ }
3022
+ async function terminal(req) {
3023
+ const onData = (bytes) => reply(req.id, { fd: 1, bytes });
3024
+ const session = await (await ready2()).openTerminal(req.argv, { ...req.options, onData });
3025
+ terminals.set(req.id, session);
3026
+ reply(req.id, { started: session.pid });
3027
+ try {
3028
+ return await session.exited;
3029
+ } finally {
3030
+ terminals.delete(req.id);
3031
+ }
3032
+ }
2943
3033
  async function handle(req) {
2944
3034
  if (req.op === "init") {
2945
- launcher = (req.root ? Promise.resolve(req.root) : deps.storage()).then(
2946
- (root) => new Launcher({
3035
+ launcher = (req.root ? Promise.resolve(req.root) : deps.storage()).then(async (root) => {
3036
+ const started = new Launcher({
2947
3037
  fs: new OpfsFs(root),
2948
3038
  createWorker: deps.createWorker,
2949
3039
  ...req.modules ? { modules: req.modules } : {},
2950
3040
  ...req.env ? { env: req.env } : {}
2951
- })
2952
- );
3041
+ });
3042
+ await started.prepare();
3043
+ return started;
3044
+ });
2953
3045
  await launcher;
2954
3046
  return true;
2955
3047
  }
2956
- if (!launcher) throw new Error("the kernel is not initialized");
2957
- return (await launcher).run(req.argv, {
3048
+ if (req.op === "open-terminal") return terminal(req);
3049
+ if (req.op === "terminal") {
3050
+ const session = terminals.get(req.terminal);
3051
+ if (!session) throw new Error(`no terminal ${req.terminal}`);
3052
+ act(session, req);
3053
+ return true;
3054
+ }
3055
+ return (await ready2()).run(req.argv, {
2958
3056
  ...req.options,
2959
3057
  onStdout: (bytes) => reply(req.id, { fd: 1, bytes }),
2960
3058
  onStderr: (bytes) => reply(req.id, { fd: 2, bytes })