buninu-linux 0.5.1 → 0.7.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.
Files changed (41) hide show
  1. package/Dockerfile +23 -0
  2. package/NOTICE.md +111 -63
  3. package/README.md +280 -60
  4. package/build-image.sh +9 -5
  5. package/build-uki.sh +44 -28
  6. package/fetch-alpine.sh +43 -35
  7. package/fetch-bun.sh +14 -21
  8. package/index.js +150 -35
  9. package/initramfs/bin/buninu-linux-help +12 -0
  10. package/initramfs/bin/bunterm +64 -5
  11. package/initramfs/bin/chroot +4 -1
  12. package/initramfs/bin/ip +3 -2
  13. package/initramfs/bin/ps +3 -0
  14. package/initramfs/bin/tar +9 -0
  15. package/initramfs/init.js +79 -41
  16. package/initramfs/lib/bunterm/images.js +17 -2
  17. package/initramfs/lib/bunterm/terminal.js +25 -9
  18. package/initramfs/lib/bunterm/vt.js +91 -0
  19. package/initramfs/lib/dlopen.js +4 -0
  20. package/initramfs/lib/fbdev.js +33 -10
  21. package/initramfs/usr/share/doc/buninu-linux/bunterm.md +25 -7
  22. package/initramfs/usr/share/doc/buninu-linux/fbdev.md +1 -1
  23. package/initramfs/usr/share/doc/buninu-linux/tar.md +2 -0
  24. package/initramfs/usr/share/licenses/README.md +4 -0
  25. package/native/aarch64/lib/ld-musl-aarch64.so.1 +0 -0
  26. package/native/aarch64/lib/libc.musl-aarch64.so.1 +0 -0
  27. package/native/aarch64/lib/libgcc_s.so.1 +0 -0
  28. package/native/aarch64/lib/libstdc++.so.6 +0 -0
  29. package/package.json +2 -2
  30. package/run-qemu.sh +61 -12
  31. package/scripts/arch.sh +113 -0
  32. package/scripts/fetch.sh +5 -3
  33. package/scripts/pack-initramfs.sh +22 -4
  34. package/test/host/Dockerfile +13 -0
  35. package/test/host/boot-test.exp +70 -0
  36. package/test/host/host-test.sh +49 -0
  37. package/test/host/in-container.sh +58 -0
  38. /package/{initramfs → native/x86_64}/lib/ld-musl-x86_64.so.1 +0 -0
  39. /package/{initramfs → native/x86_64}/lib/libc.musl-x86_64.so.1 +0 -0
  40. /package/{initramfs → native/x86_64}/lib/libgcc_s.so.1 +0 -0
  41. /package/{initramfs → native/x86_64}/lib/libstdc++.so.6 +0 -0
@@ -3,16 +3,22 @@
3
3
 
4
4
  import { showDocument } from "../lib/document.js";
5
5
  import { runTerminal } from "../lib/bunterm/terminal.js";
6
+ import { activeConsole, openConsole } from "../lib/fbdev.js";
7
+ import { virtualConsoleNumber } from "../lib/bunterm/vt.js";
8
+ import { closeSync, openSync, readFileSync } from "node:fs";
9
+
10
+ const CANONICAL_BUNTERM = "/bin/bunterm";
6
11
 
7
12
  const usage = () => {
8
13
  console.error(`Usage:
9
- bunterm [/dev/ttyN] [--font-size N] [--fb /dev/fb0] [--no-mouse] [-e command [argument...]]
14
+ bunterm [N | /dev/ttyN] [-s N | --font-size N] [--fb /dev/fb0] [--no-mouse] [-e command [argument...]]
10
15
  bunterm -h | --help`);
11
16
  };
12
17
 
13
18
  const main = async () => {
14
19
  const options = { argv: [] };
15
20
  const arguments_ = process.argv.slice(2);
21
+ let consoleArgumentIndex = -1;
16
22
  for (let index = 0; index < arguments_.length; index++) {
17
23
  const argument = arguments_[index];
18
24
  if (argument === "-h" || argument === "--help") {
@@ -36,8 +42,10 @@ const main = async () => {
36
42
  }
37
43
  return value;
38
44
  };
39
- if (argument === "--font-size" || argument.startsWith("--font-size=")) {
40
- options.fontSize = Number(valued("--font-size"));
45
+ if (argument === "-s" || argument.startsWith("-s=")
46
+ || argument === "--font-size" || argument.startsWith("--font-size=")) {
47
+ const name = argument === "-s" || argument.startsWith("-s=") ? "-s" : "--font-size";
48
+ options.fontSize = Number(valued(name));
41
49
  if (!Number.isFinite(options.fontSize) || options.fontSize < 6) throw new Error("--font-size must be a number of at least 6");
42
50
  } else if (argument === "--line-height" || argument.startsWith("--line-height=")) {
43
51
  options.lineHeight = Number(valued("--line-height"));
@@ -50,13 +58,64 @@ const main = async () => {
50
58
  options.mouse = true;
51
59
  } else if (argument === "--no-mouse") {
52
60
  options.mouse = false;
53
- } else if (argument.startsWith("/dev/")) {
54
- options.console = argument;
61
+ } else if (/^[1-9][0-9]*$/.test(argument) || argument.startsWith("/dev/")) {
62
+ const number = /^[1-9][0-9]*$/.test(argument) ? Number(argument) : virtualConsoleNumber(argument);
63
+ if (number === null) throw new Error(`expected N or /dev/ttyN, got ${argument}`);
64
+ if (consoleArgumentIndex !== -1) throw new Error("only one virtual console may be specified");
65
+ options.console = `/dev/tty${number}`;
66
+ consoleArgumentIndex = index;
55
67
  } else {
56
68
  usage();
57
69
  throw new Error(`unexpected argument: ${argument}`);
58
70
  }
59
71
  }
72
+
73
+ const probe = openConsole(["/dev/tty0"]);
74
+ let activeConsoleNumber;
75
+ try {
76
+ activeConsoleNumber = activeConsole(probe);
77
+ } finally {
78
+ probe.close();
79
+ }
80
+ if (!options.console) options.console = `/dev/tty${activeConsoleNumber}`;
81
+
82
+ // Every live bunterm converges on one unambiguous /proc/<pid>/cmdline:
83
+ // /bin/bunterm /bin/bunterm /dev/ttyN [options...]
84
+ // execve replaces the original process, so no forwarding parent remains.
85
+ const remaining = arguments_.filter((_argument, index) => index !== consoleArgumentIndex);
86
+ const canonicalArgv = [CANONICAL_BUNTERM, CANONICAL_BUNTERM, options.console, ...remaining];
87
+ const currentArgv = readFileSync("/proc/self/cmdline").toString("utf8").split("\0");
88
+ if (currentArgv.at(-1) === "") currentArgv.pop();
89
+ if (currentArgv.length !== canonicalArgv.length
90
+ || currentArgv.some((argument, index) => argument !== canonicalArgv[index])) {
91
+ const environment = Object.fromEntries(
92
+ Object.entries(process.env).filter((entry) => typeof entry[1] === "string"),
93
+ );
94
+ const targetConsoleNumber = virtualConsoleNumber(options.console);
95
+ if (consoleArgumentIndex !== -1 && targetConsoleNumber !== activeConsoleNumber) {
96
+ const consoleFd = openSync(options.console, "r+");
97
+ try {
98
+ const child = Bun.spawn(
99
+ [process.execPath, CANONICAL_BUNTERM, options.console, ...remaining],
100
+ {
101
+ argv0: CANONICAL_BUNTERM,
102
+ detached: true,
103
+ env: environment,
104
+ stdin: "ignore",
105
+ stdout: consoleFd,
106
+ stderr: consoleFd,
107
+ },
108
+ );
109
+ child.unref();
110
+ } finally {
111
+ closeSync(consoleFd);
112
+ }
113
+ return 0;
114
+ }
115
+ process.execve(process.execPath, canonicalArgv, environment);
116
+ throw new Error("process.execve returned unexpectedly");
117
+ }
118
+
60
119
  return runTerminal(options);
61
120
  };
62
121
 
@@ -8,7 +8,7 @@
8
8
  // /etc/resolv.conf, so `chroot /mnt/rootfs` lands in a usable system. Every
9
9
  // mount it makes is undone when the command exits.
10
10
 
11
- import { existsSync, mkdirSync, realpathSync, statSync, readFileSync, writeFileSync } from "node:fs";
11
+ import { chmodSync, existsSync, mkdirSync, realpathSync, statSync, readFileSync, writeFileSync } from "node:fs";
12
12
  import { libc, cString, ptr, errno, strerror, Errno } from "../lib/dlopen.js";
13
13
  import { showDocument } from "../lib/document.js";
14
14
 
@@ -125,6 +125,9 @@ if (options.mount) {
125
125
  // A bound file, so name resolution inside follows the host's configuration
126
126
  // and nothing inside the root is modified.
127
127
  if (existsSync("/etc/resolv.conf") && existsSync(`${root}/etc`)) {
128
+
129
+ chmodSync(`/etc/resolv.conf`, 0o644);
130
+
128
131
  prepare({ inside: "/etc/resolv.conf", source: "/etc/resolv.conf", type: "none", flags: MS.BIND, file: true });
129
132
  }
130
133
  }
package/initramfs/bin/ip CHANGED
@@ -498,8 +498,9 @@ const commandAddress = (words, options) => {
498
498
  });
499
499
  };
500
500
 
501
- // struct rtentry on x86_64: rt_dst @8, rt_gateway @24, rt_genmask @40 (each a
502
- // 16-byte sockaddr), rt_flags u16 @56, rt_metric i16 @80, rt_dev char* @88.
501
+ // struct rtentry on 64-bit Linux, x86_64 and aarch64 alike: rt_dst @8,
502
+ // rt_gateway @24, rt_genmask @40 (each a 16-byte sockaddr), rt_flags u16 @56,
503
+ // rt_metric i16 @80, rt_dev char* @88.
503
504
  const rtentry = ({ destination, prefix, gateway, device, metric }) => {
504
505
  const route = new Uint8Array(120);
505
506
  const routeView = view(route);
package/initramfs/bin/ps CHANGED
@@ -16,6 +16,9 @@ const ttyName = (number) => {
16
16
  const minor = (device & 0xff) | ((device >>> 12) & 0xfff00);
17
17
  if (major >= 136 && major <= 143) return `pts/${minor}`;
18
18
  if (major === 4 && minor < 64) return `tty${minor}`;
19
+ // Serial ports: the 8250 UARTs of a PC and the PL011 of QEMU's aarch64 virt.
20
+ if (major === 4) return `ttyS${minor - 64}`;
21
+ if (major === 204 && minor >= 64) return `ttyAMA${minor - 64}`;
19
22
  if (major === 5 && minor === 0) return "tty";
20
23
  return `${major}:${minor}`;
21
24
  };
package/initramfs/bin/tar CHANGED
@@ -41,6 +41,14 @@ for (let index = 0; index < argv.length; index++) {
41
41
  }
42
42
  if (argument === "--zstd") { options.compression = "zstd"; continue; }
43
43
  if (argument === "--gzip") { options.compression = "gzip"; continue; }
44
+ // GNU tar compatibility used by js-udocker. Bun.Archive never restores
45
+ // ownership or permissions, and extraction replaces existing files, so
46
+ // these options already describe its normal extraction behaviour.
47
+ if (argument === "--no-same-owner" || argument === "--no-same-permissions" || argument === "--overwrite") continue;
48
+ // Bun.Archive does not extract device nodes. js-udocker separately removes
49
+ // whiteout files after extraction, so its excludes are safe no-ops here.
50
+ if (argument === "--exclude") { take(index++, argument); continue; }
51
+ if (argument.startsWith("--exclude=")) continue;
44
52
  if (argument === "-C" || argument === "--directory") {
45
53
  options.directory = take(index++, argument);
46
54
  continue;
@@ -67,6 +75,7 @@ for (let index = 0; index < argv.length; index++) {
67
75
  } else if (flag === "r") {
68
76
  fail("-r append is not supported by Bun.Archive");
69
77
  } else if (flag === "v") options.verbose = true;
78
+ else if (flag === "o") {} // GNU tar: --no-same-owner
70
79
  else if (flag === "z") options.compression = "gzip";
71
80
  else if (flag === "f") {
72
81
  const attached = flags.slice(offset + 1);
package/initramfs/init.js CHANGED
@@ -2,9 +2,19 @@ console.log("init.js: Bun runtime entered JavaScript");
2
2
 
3
3
  const { dlopen, FFIType, ptr, read } = await import("bun:ffi");
4
4
 
5
- console.log("init.js: loading physical musl libc for FFI");
6
-
7
- const libc = dlopen("/lib/libc.musl-x86_64.so.1", {
5
+ // The musl file name and the number of finit_module, which musl has no
6
+ // wrapper for, are all that differs per CPU in this file. /lib/dlopen.js has
7
+ // the same table for the commands; it is repeated rather than imported so
8
+ // that nothing but bun:ffi loads before the mounts below.
9
+ const architecture = {
10
+ x64: { libc: "libc.musl-x86_64.so.1", SYS_finit_module: 313 },
11
+ arm64: { libc: "libc.musl-aarch64.so.1", SYS_finit_module: 273 },
12
+ }[process.arch];
13
+ if (!architecture) throw new Error(`init.js: unsupported CPU architecture ${process.arch}`);
14
+
15
+ console.log(`init.js: loading physical musl libc /lib/${architecture.libc} for FFI`);
16
+
17
+ const libc = dlopen(`/lib/${architecture.libc}`, {
8
18
  mount: {
9
19
  args: [FFIType.ptr, FFIType.ptr, FFIType.ptr, FFIType.u64, FFIType.ptr],
10
20
  returns: FFIType.i32,
@@ -152,11 +162,13 @@ try {
152
162
  // The input layer's character devices, so a pointing device has a
153
163
  // /dev/input/event* node for `bunterm --mouse` to read. Loading them only
154
164
  // creates those nodes: the console keyboard is unaffected, and nothing reads
155
- // a pointer unless it is asked to. A USB mouse also needs the usbhid stack,
156
- // which `cfg.all` or a --real boot brings in.
165
+ // a pointer unless it is asked to. psmouse is the PS/2 mouse of a PC or q35;
166
+ // QEMU's aarch64 virt machine has virtio-input instead. Each image ships only
167
+ // the one its machine has, and tryModprobe skips the other. A USB mouse also
168
+ // needs the usbhid stack, which `cfg.all` or a --real boot brings in.
157
169
  try {
158
170
  const { tryModprobe } = await import("/lib/modprobe.js");
159
- const loaded = ["evdev", "psmouse"].filter((module) => tryModprobe(module) !== null);
171
+ const loaded = ["evdev", "psmouse", "virtio_input"].filter((module) => tryModprobe(module) !== null);
160
172
  const { readdirSync } = await import("node:fs");
161
173
  const devices = (() => {
162
174
  try { return readdirSync("/dev/input").filter((name) => name.startsWith("event")); } catch { return []; }
@@ -190,7 +202,11 @@ const loadModule = (path) => {
190
202
  const parameters = cString("");
191
203
  // -EEXIST is harmless when firmware or another module loaded it first.
192
204
  // musl has no finit_module wrapper, so use its generic syscall(2).
193
- check(`finit_module(${path})`, libc.symbols.syscall(313, fd, ptr(parameters), 0), [-17]);
205
+ check(
206
+ `finit_module(${path})`,
207
+ libc.symbols.syscall(architecture.SYS_finit_module, fd, ptr(parameters), 0),
208
+ [-17],
209
+ );
194
210
  } finally {
195
211
  libc.symbols.close(fd);
196
212
  }
@@ -231,21 +247,14 @@ const bringUpLoopback = () => {
231
247
  }
232
248
  };
233
249
 
234
- const configureQemuNetwork = () => {
235
- const release = process.env.KERNEL_RELEASE ?? "6.18.52-0-virt";
236
- const modules = [
237
- `lib/modules/${release}/kernel/net/core/failover.ko`,
238
- `lib/modules/${release}/kernel/drivers/net/net_failover.ko`,
239
- `lib/modules/${release}/kernel/drivers/net/virtio_net.ko`,
240
- ];
241
- if (release.endsWith("-lts")) modules.unshift(
242
- `lib/modules/${release}/kernel/drivers/virtio/virtio_ring.ko`,
243
- `lib/modules/${release}/kernel/drivers/virtio/virtio.ko`,
244
- `lib/modules/${release}/kernel/drivers/virtio/virtio_pci_legacy_dev.ko`,
245
- `lib/modules/${release}/kernel/drivers/virtio/virtio_pci_modern_dev.ko`,
246
- `lib/modules/${release}/kernel/drivers/virtio/virtio_pci.ko`,
247
- );
248
- for (const module of modules) loadModule(`/${module}`);
250
+ // Which virtio pieces are modules differs per kernel flavor and CPU (linux-lts
251
+ // has virtio_pci as a module, linux-virt builds it in), so /lib/modprobe.js
252
+ // resolves them from the shipped modules.dep and modules.builtin: the PCI
253
+ // transport first, since nothing in modules.dep depends on it, then the NIC.
254
+ const configureQemuNetwork = async () => {
255
+ const { modprobe, tryModprobe } = await import("/lib/modprobe.js");
256
+ tryModprobe("virtio_pci");
257
+ modprobe("virtio_net");
249
258
 
250
259
  const fd = check("socket(AF_INET, SOCK_DGRAM)", libc.symbols.socket(2, 2, 0));
251
260
  const ioctl = (request, value, operation) =>
@@ -264,8 +273,9 @@ const configureQemuNetwork = () => {
264
273
 
265
274
  bringUp(ioctl, "eth0");
266
275
 
267
- // struct rtentry on Linux x86_64. Only gateway and flags are needed for
268
- // the default route; rt_dst and rt_genmask remain 0.0.0.0.
276
+ // struct rtentry on 64-bit Linux, the same on x86_64 and aarch64. Only
277
+ // gateway and flags are needed for the default route; rt_dst and
278
+ // rt_genmask remain 0.0.0.0.
269
279
  const route = new Uint8Array(120);
270
280
  const routeView = new DataView(route.buffer);
271
281
  writeSockaddrIpv4(route, 8, "0.0.0.0");
@@ -359,7 +369,7 @@ try {
359
369
 
360
370
 
361
371
  try {
362
- configureQemuNetwork();
372
+ await configureQemuNetwork();
363
373
  console.log("network: eth0 10.0.2.15/24 via 10.0.2.2");
364
374
  const response = await fetch("http://example.com");
365
375
  console.log(`fetch example.com: HTTP ${response.status}, ${response.headers.get("content-type")}`);
@@ -412,7 +422,9 @@ const launchBunmsh = () => {
412
422
 
413
423
 
414
424
 
415
- const launchBuninu = () => {
425
+ let replServer = null;
426
+
427
+ const launchBuninu = async () => {
416
428
 
417
429
  console.log(`
418
430
  Type bunterm in bunmsh: Graphical Terminal
@@ -420,22 +432,36 @@ Type bunterm in bunmsh: Graphical Terminal
420
432
  Supports Kitty Graphics Protocol
421
433
  `)
422
434
 
435
+ const classic_path = "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"
436
+
437
+ const buninu_path = "/buninu/.local/bin:/buninu/.bun/bin:/buninu/bin"
438
+
423
439
  const entry = `/buninu/bin/init.js`;
424
440
  console.log(`buninu: Starting ${entry} --local`);
425
- const shell = Bun.spawnSync([
426
- "/bin/bun", entry, "--local"
427
- ], {
428
- env: {
429
- ...process.env,
430
- PATH:"/bin:/sbin:/usr/bin:/usr/sbin:/buninu/.bun/bin:/buninu/bin",
431
- HOME:"/buninu"
432
- },
433
- stdin: "inherit",
434
- stdout: "inherit",
435
- stderr: "inherit",
436
- });
437
- console.log(`buninu exited with status ${shell.exitCode}; returning to Bun REPL.`);
438
- return shell.exitCode;
441
+ replServer?.pause();
442
+ try {
443
+ const shell = Bun.spawn([
444
+ "/bin/bun", entry, "--local"
445
+ ], {
446
+ env: {
447
+ ...process.env,
448
+ PATH: classic_path + ":" +
449
+ buninu_path,
450
+ HOME:"/buninu"
451
+ },
452
+ stdin: "inherit",
453
+ stdout: "inherit",
454
+ stderr: "inherit",
455
+ });
456
+ const exitCode = await shell.exited;
457
+ console.log(`buninu exited with status ${exitCode}; returning to Bun REPL.`);
458
+ return exitCode;
459
+ } finally {
460
+ // The child inherits the console directly. Keep the REPL from consuming
461
+ // the same input while it runs, but leave PID 1's event loop alive so its
462
+ // SIGCHLD handler can reap orphaned grandchildren such as Chromium.
463
+ replServer?.resume();
464
+ }
439
465
 
440
466
  }
441
467
 
@@ -551,12 +577,24 @@ Type start() to run buninu --local`);
551
577
  showWelcome();
552
578
 
553
579
  const startRepl = () => {
554
- const server = repl.start({
580
+ const server = replServer = repl.start({
555
581
  prompt: "bun-repl> ",
556
582
  input: process.stdin,
557
583
  output: process.stdout,
558
584
  terminal: true,
559
585
  });
586
+ // `start()` returns a promise so PID 1's event loop can keep reaping
587
+ // children. Make the two convenient whole-line forms foreground commands;
588
+ // otherwise the REPL finishes evaluating the pending promise and resumes
589
+ // itself, competing with Buninu for the console input.
590
+ const defaultEval = server.eval.bind(server);
591
+ server.eval = (command, context, filename, callback) => {
592
+ const trimmed = command.trim();
593
+ if (trimmed === "start" || /^start\s*\(\s*\)\s*;?$/.test(trimmed)) {
594
+ command = "await start()\n";
595
+ }
596
+ return defaultEval(command, context, filename, callback);
597
+ };
560
598
  // Run before REPL's own line listener so its next prompt is printed after
561
599
  // the repeated welcome. Empty and whitespace-only lines both trigger it.
562
600
  server.prependListener("line", (line) => {
@@ -53,6 +53,7 @@ export class ImageStore {
53
53
  this.term = term;
54
54
  this.renderer = renderer;
55
55
  this.images = new Map(); // image id → Skia Image
56
+ this.imageSources = new Map(); // image id → parser image object
56
57
  this.placements = new Map(); // placement key → placement
57
58
  this.counter = 0;
58
59
  this.coveredKey = "";
@@ -70,14 +71,26 @@ export class ImageStore {
70
71
  if (graphic.kind !== "placement") return "";
71
72
  const { control, image } = graphic;
72
73
  let decoded = this.images.get(image.id);
73
- if (!decoded) {
74
+ const replacesImage = decoded && this.imageSources.get(image.id) !== image;
75
+ if (!decoded || replacesImage) {
74
76
  try {
75
- decoded = await decodeImage(this.CanvasKit, image);
77
+ const replacement = await decodeImage(this.CanvasKit, image);
78
+ if (replacesImage) {
79
+ // Kitty requires retransmitting a non-zero image id to delete the
80
+ // old image and all its placements. Screen-streaming clients such
81
+ // as casty deliberately reuse one id for every frame.
82
+ for (const placement of [...this.placements.values()]) {
83
+ if (placement.imageId === image.id) this.dispose(placement);
84
+ }
85
+ decoded.delete();
86
+ }
87
+ decoded = replacement;
76
88
  } catch (error) {
77
89
  graphic.responseMessage = `EBADPNG:${error.message}`;
78
90
  return "";
79
91
  }
80
92
  this.images.set(image.id, decoded);
93
+ this.imageSources.set(image.id, image);
81
94
  }
82
95
  const { cellWidth, cellHeight } = this.renderer;
83
96
  const buffer = this.term.buffer.active;
@@ -169,6 +182,7 @@ export class ImageStore {
169
182
  if (!used.has(id)) {
170
183
  image.delete();
171
184
  this.images.delete(id);
185
+ this.imageSources.delete(id);
172
186
  }
173
187
  }
174
188
  }
@@ -221,5 +235,6 @@ export class ImageStore {
221
235
  this.placements.clear();
222
236
  for (const image of this.images.values()) image.delete();
223
237
  this.images.clear();
238
+ this.imageSources.clear();
224
239
  }
225
240
  }
@@ -20,6 +20,7 @@ import { UnicodeGraphemesAddon } from "../xterm/addon-unicode-graphemes.mjs";
20
20
  import { Renderer, defaultTheme } from "./renderer.js";
21
21
  import { ImageStore } from "./images.js";
22
22
  import { openKeyboard, translateKeys } from "./input.js";
23
+ import { claimVirtualConsole } from "./vt.js";
23
24
 
24
25
  const require = createRequire(import.meta.url);
25
26
 
@@ -81,6 +82,7 @@ export const createSession = ({
81
82
  });
82
83
 
83
84
  let frameTimer = null;
85
+ let active = true;
84
86
  let blinkOn = true;
85
87
  const cursorHidden = () => Boolean(term._core?.coreService?.isCursorHidden);
86
88
  // Only set when a pointer is attached; without one the renderer is called
@@ -89,6 +91,7 @@ export const createSession = ({
89
91
  const paint = () => {
90
92
  if (frameTimer) clearTimeout(frameTimer);
91
93
  frameTimer = null;
94
+ if (!active) return false;
92
95
  const buffer = term.buffer.active;
93
96
  // cursorY counts from the buffer's base, so a scrolled-back viewport
94
97
  // moves the cursor down the screen and eventually off it.
@@ -104,8 +107,20 @@ export const createSession = ({
104
107
  });
105
108
  };
106
109
  const requestFrame = () => {
110
+ if (!active) return;
107
111
  frameTimer ??= setTimeout(paint, 16);
108
112
  };
113
+ const suspend = () => {
114
+ active = false;
115
+ if (frameTimer) clearTimeout(frameTimer);
116
+ frameTimer = null;
117
+ };
118
+ const resume = () => {
119
+ if (active) return;
120
+ active = true;
121
+ renderer.invalidate();
122
+ paint();
123
+ };
109
124
  const blinkTimer = cursorBlink
110
125
  ? setInterval(() => { blinkOn = !blinkOn; requestFrame(); }, 500)
111
126
  : null;
@@ -193,6 +208,9 @@ export const createSession = ({
193
208
  };
194
209
 
195
210
  const reportMouse = (report) => {
211
+ // /dev/input is global rather than routed through the active VT. Do not
212
+ // draw or send mouse input to a terminal whose console is off-screen.
213
+ if (!active) return;
196
214
  pointer = { x: report.x, y: report.y };
197
215
  requestFrame();
198
216
  const service = term._core?.coreMouseService;
@@ -268,7 +286,7 @@ export const createSession = ({
268
286
  paint();
269
287
  return {
270
288
  term, backend, renderer, images, kitty,
271
- paint, requestFrame, settle, input, exited, close, attachPointer, reportMouse, wheelScroll,
289
+ paint, requestFrame, suspend, resume, settle, input, exited, close, attachPointer, reportMouse, wheelScroll,
272
290
  };
273
291
  };
274
292
 
@@ -282,15 +300,13 @@ export const runTerminal = async ({ console: consoleDevice = null, device = "/de
282
300
  const fonts = loadFontSet(CanvasKit, "terminal");
283
301
  const consolePaths = consoleDevice ? [consoleDevice] : undefined;
284
302
 
285
- return runGraphics(async (display, { onRestore, onAcquire }) => {
303
+ return runGraphics(async (display, { onRestore, onRelease, onAcquire }) => {
286
304
  const screen = openCanvas(CanvasKit, display);
287
305
  const session = createSession({ CanvasKit, screen, fonts, ...options });
288
- // Coming back from another console (Ctrl-Alt-Fn): fbcon has drawn over
289
- // the framebuffer, so repaint everything.
290
- onAcquire(() => {
291
- session.renderer.invalidate();
292
- session.paint();
293
- });
306
+ onRelease(session.suspend);
307
+ // Coming back from another console (Ctrl-Alt-Fn): fbcon or another
308
+ // bunterm has drawn over the framebuffer, so repaint everything.
309
+ onAcquire(session.resume);
294
310
  let keyboard = null;
295
311
  try {
296
312
  keyboard = openKeyboard({
@@ -320,5 +336,5 @@ export const runTerminal = async ({ console: consoleDevice = null, device = "/de
320
336
  session.close();
321
337
  screen.close();
322
338
  }
323
- }, { device, console: consolePaths });
339
+ }, { device, console: consolePaths, prepareConsole: claimVirtualConsole });
324
340
  };
@@ -0,0 +1,91 @@
1
+ // Cooperative ownership for one Linux virtual console.
2
+ //
3
+ // Linux VT_SETMODE replaces an existing VT_PROCESS owner rather than failing,
4
+ // and VT_GETMODE does not expose that owner's pid. bunterm therefore publishes
5
+ // a short per-VT task name and verifies that another such process does not also
6
+ // hold the console device open. Both facts live under /proc/<pid> and disappear
7
+ // with the process; there is no persistent pid or lock file to become stale.
8
+
9
+ import { readdirSync, readFileSync, statSync } from "node:fs";
10
+ import { libc, cString, check } from "../dlopen.js";
11
+
12
+ const PR_SET_NAME = 15;
13
+ const PR_GET_NAME = 16;
14
+
15
+ export const virtualConsoleNumber = (path) => {
16
+ const match = /^\/dev\/tty([1-9][0-9]*)$/.exec(path);
17
+ return match ? Number(match[1]) : null;
18
+ };
19
+
20
+ const taskName = (number) => `bunterm:tty${number}`;
21
+
22
+ const getTaskName = () => {
23
+ const name = new Uint8Array(16);
24
+ check("prctl(PR_GET_NAME)", libc.symbols.prctl(PR_GET_NAME, name, 0n, 0n, 0n));
25
+ const zero = name.indexOf(0);
26
+ return new TextDecoder().decode(zero === -1 ? name : name.subarray(0, zero));
27
+ };
28
+
29
+ const setTaskName = (name) => {
30
+ const encoded = cString(name);
31
+ if (encoded.byteLength > 16) throw new Error(`Linux task name is too long: ${name}`);
32
+ check("prctl(PR_SET_NAME)", libc.symbols.prctl(PR_SET_NAME, encoded, 0n, 0n, 0n));
33
+ };
34
+
35
+ const processHoldsDevice = (pid, deviceId) => {
36
+ let descriptors;
37
+ try {
38
+ descriptors = readdirSync(`/proc/${pid}/fd`);
39
+ } catch {
40
+ return false;
41
+ }
42
+ for (const descriptor of descriptors) {
43
+ try {
44
+ if (statSync(`/proc/${pid}/fd/${descriptor}`).rdev === deviceId) return true;
45
+ } catch {
46
+ // The process or descriptor disappeared between readdir and stat.
47
+ }
48
+ }
49
+ return false;
50
+ };
51
+
52
+ export const claimVirtualConsole = (consoleDevice) => {
53
+ const number = virtualConsoleNumber(consoleDevice.path);
54
+ if (number === null) throw new Error(`bunterm needs a canonical virtual console, got ${consoleDevice.path}`);
55
+ const name = taskName(number);
56
+ const previousName = getTaskName();
57
+ const deviceId = statSync(consoleDevice.path).rdev;
58
+
59
+ // Publish before scanning. Two simultaneous starters can then reject each
60
+ // other, but they cannot both scan an unpublished peer and proceed.
61
+ setTaskName(name);
62
+ try {
63
+ const pids = readdirSync("/proc")
64
+ .filter((entry) => /^[0-9]+$/.test(entry) && Number(entry) !== process.pid);
65
+ for (const pid of pids) {
66
+ let otherName;
67
+ try {
68
+ otherName = readFileSync(`/proc/${pid}/comm`, "utf8").trim();
69
+ } catch {
70
+ continue;
71
+ }
72
+ if (otherName !== name || !processHoldsDevice(pid, deviceId)) continue;
73
+
74
+ throw new Error(`
75
+ ${consoleDevice.path} is occupied
76
+ Controlled by bunterm pid: ${pid}
77
+ See --help or try another VT:
78
+ bunterm ${number- -1}
79
+ Switch between TTYs by Ctrl+Alt+F1-12
80
+ or Alt-Left / Alt-Right
81
+ `);
82
+ }
83
+ } catch (error) {
84
+ try { setTaskName(previousName); } catch {}
85
+ throw error;
86
+ }
87
+
88
+ return () => {
89
+ try { setTaskName(previousName); } catch {}
90
+ };
91
+ };
@@ -85,6 +85,10 @@ export const libc = dlopen(libcPath, {
85
85
  args: [FFIType.i32, FFIType.ptr, FFIType.i32],
86
86
  returns: FFIType.i32,
87
87
  },
88
+ prctl: {
89
+ args: [FFIType.i32, FFIType.ptr, FFIType.u64, FFIType.u64, FFIType.u64],
90
+ returns: FFIType.i32,
91
+ },
88
92
  chroot: {
89
93
  args: [FFIType.ptr],
90
94
  returns: FFIType.i32,