buninu-linux 0.11.56 → 0.14.57

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.
@@ -0,0 +1,91 @@
1
+ # ping — send ICMP echo requests
2
+
3
+ `ping` sends real IPv4 ICMP or IPv6 ICMPv6 echo requests through Bun FFI.
4
+ It does not execute a system ping, open a TCP connection, or use an HTTP probe.
5
+
6
+ ```sh
7
+ ping [-4|-6] [-nqD] [-c COUNT] [-i SECONDS] [-W SECONDS]
8
+ [-w SECONDS] [-s BYTES] [-t TTL] [-I ADDRESS|INTERFACE] DESTINATION
9
+ ping --help
10
+ ```
11
+
12
+ | Option | Meaning |
13
+ |---|---|
14
+ | `-c COUNT` | Stop after COUNT requests and their replies/timeouts. Default: 4 requests, then exit. |
15
+ | `-i SECONDS` | Send interval, default 1 second; fractional values supported, minimum 0.002 seconds. |
16
+ | `-W SECONDS` | Per-request reply timeout, default 2 seconds; fractional values supported. |
17
+ | `-w SECONDS` | Overall probing deadline, including outstanding requests. DNS resolution happens first. |
18
+ | `-s BYTES` | Payload size, default 56; 0–65507 bytes. The ICMP header adds 8 bytes. |
19
+ | `-t TTL` | IPv4 TTL or IPv6 hop limit, 1–255; default 64. |
20
+ | `-I ADDRESS` | Bind a source IPv4/IPv6 address. |
21
+ | `-I INTERFACE` | Bind an interface such as `eth0` or `wlan0`; OS permissions may be required. |
22
+ | `-4`, `-6` | Select IPv4 or IPv6. Numeric destinations infer their family; hostnames default to IPv4. |
23
+ | `-n` | Numeric reply addresses (currently always used; no reverse DNS). |
24
+ | `-q` | Print only the initial line and final statistics. |
25
+ | `-D` | Add Unix timestamps to per-packet output. |
26
+ | `-h`, `--help` | Show this document. |
27
+
28
+ Short options can be combined (`-nq`) and values attached (`-c3`). Use `--`
29
+ to end option parsing. Unknown options are rejected rather than ignored.
30
+
31
+ ```sh
32
+ ping -c 4 1.1.1.1
33
+ ping -c 3 localhost
34
+ ping -6 -c 3 ::1
35
+ ping -n -c 10 -i 0.2 -W 1 -s 1200 192.168.1.1
36
+ ping -q -w 5 192.168.1.1
37
+ ping -I eth0 -c 3 192.168.1.1
38
+ ```
39
+
40
+ Statistics include transmitted/received counts, loss and RTT min/average/max/
41
+ standard deviation. Ctrl-C prints statistics and closes the socket. Exit status
42
+ is 0 if any reply arrived, 1 if none arrived (or a combined `-c`/`-w` deadline
43
+ prevented receiving the requested count), and 2 for argument/setup/system errors.
44
+ RTT is measured by the Bun process, so event-loop scheduling affects it.
45
+
46
+ The preferred socket is an unprivileged ICMP datagram (ping) socket. If the OS
47
+ refuses that socket, a raw ICMP socket is attempted; this normally requires root
48
+ or `CAP_NET_RAW`. Linux controls datagram access with `ping_group_range`, and
49
+ Android may impose additional restrictions. The command does not change system
50
+ permissions. Replies are matched by identifier, sequence and payload; IPv4
51
+ checksums are checked. Concurrent outstanding probes are supported. Datagram
52
+ replies do not expose their received TTL through this implementation, so it is
53
+ not printed; raw IPv4 replies do include TTL.
54
+
55
+ Supported runtimes: 64-bit Linux (glibc or musl) and native Android/Termux
56
+ (bionic), on x86_64 or aarch64. `/lib/dlopen.js` chooses libc automatically:
57
+
58
+ - Android: `libc.so`, then the APEX and `/system/lib64` bionic paths; `__errno`.
59
+ - Linux glibc runtime: `libc.so.6`, then architecture-specific `/lib` and
60
+ `/usr/lib` glibc paths; `__errno_location`.
61
+ - Linux musl runtime: the adjacent `libc.musl-<arch>.so.1`, its soname,
62
+ `/lib/libc.musl-<arch>.so.1`, `/lib/ld-musl-<arch>.so.1`, then `libc.so`;
63
+ `__errno_location`.
64
+ - Search stays within the runtime ABI: loading musl into glibc can crash on
65
+ errno/TLS access.
66
+ - `BUNINU_LIBC=/absolute/path` overrides the search. A bad override fails
67
+ explicitly rather than silently selecting a different library. Symbols resolve
68
+ on first use: an unavailable mount symbol does not prevent ping from using
69
+ the socket symbols. An explicit override is not rejected based on its filename;
70
+ matching the runtime ABI remains the caller's responsibility.
71
+
72
+ Windows requires a separate `iphlpapi.dll` ICMP backend and is not implemented.
73
+ Flood/adaptive modes, IPv4 record-route/timestamps, reverse DNS, broadcast ping,
74
+ and IPv6 scoped address notation (`fe80::1%eth0`) are not implemented.
75
+
76
+ ## Tests
77
+
78
+ ```sh
79
+ bun test test/ping.test.js test/libc.test.js
80
+ ```
81
+
82
+ These compare local IPv4/IPv6 echo replies, payload sizes, counts, quiet output,
83
+ source selection, deadlines and exit codes against the installed system ping.
84
+ RTT values are not expected to match. A missing system ping or unavailable
85
+ loopback ICMP support skips the corresponding network comparisons. Older
86
+ Android systems have a separate `ping6` and a 200 ms interval restriction; the
87
+ reference tests accommodate those without limiting this command's options.
88
+ Packet validation, argument errors, fast intervals and Ctrl-C also have tests.
89
+
90
+ References: [Linux ICMP sockets](https://man7.org/linux/man-pages/man7/icmp.7.html),
91
+ [Android errno](https://android.googlesource.com/platform/bionic/+/master/libc/include/errno.h).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "buninu-linux",
3
- "version": "0.11.56",
3
+ "version": "0.14.57",
4
4
  "description": "A Linux distribution running Bun as PID 1 with the Buninu userspace.",
5
5
  "type": "module",
6
6
  "main": "./index.js",
@@ -37,6 +37,6 @@
37
37
  "fetch": "./index.js -f",
38
38
  "pack": "./index.js -b",
39
39
  "start": "./index.js -r",
40
- "check": "for f in ./index.js ./initramfs/init.js ./initramfs/lib/document.js ./initramfs/lib/dlopen.js ./initramfs/lib/fbdev.js ./initramfs/lib/canvas.js ./initramfs/lib/bunterm/glyphs.js ./initramfs/lib/bunterm/renderer.js ./initramfs/lib/bunterm/images.js ./initramfs/lib/bunterm/input.js ./initramfs/lib/bunterm/mouse.js ./initramfs/lib/bunterm/terminal.js ./initramfs/lib/modprobe.js ./initramfs/lib/power.js ./initramfs/bin/bunterm ./initramfs/bin/chroot ./initramfs/bin/mount ./initramfs/bin/umount ./initramfs/bin/ip ./initramfs/bin/ps ./initramfs/bin/top ./initramfs/bin/poweroff ./initramfs/bin/reboot ./initramfs/bin/tar ./initramfs/bin/stripansi; do node --check \"$f\" || exit 1; done"
40
+ "check": "for f in ./index.js ./initramfs/init.js ./initramfs/lib/document.js ./initramfs/lib/dlopen.js ./initramfs/lib/ping.js ./initramfs/lib/dhcp.js ./initramfs/lib/dhcp-transport.js ./initramfs/lib/dhclient.js ./initramfs/lib/dhcp-start.js ./initramfs/lib/fbdev.js ./initramfs/lib/canvas.js ./initramfs/lib/bunterm/glyphs.js ./initramfs/lib/bunterm/renderer.js ./initramfs/lib/bunterm/images.js ./initramfs/lib/bunterm/input.js ./initramfs/lib/bunterm/mouse.js ./initramfs/lib/bunterm/terminal.js ./initramfs/lib/modprobe.js ./initramfs/lib/power.js ./initramfs/bin/bunterm ./initramfs/bin/chroot ./initramfs/bin/mount ./initramfs/bin/umount ./initramfs/bin/ip ./initramfs/bin/ping ./initramfs/bin/dhclient ./initramfs/bin/ps ./initramfs/bin/top ./initramfs/bin/poweroff ./initramfs/bin/reboot ./initramfs/bin/tar ./initramfs/bin/stripansi; do node --check \"$f\" || exit 1; done"
41
41
  }
42
42
  }
package/scripts/arch.js CHANGED
@@ -16,8 +16,8 @@ export const rootDir = resolve(dirname(fileURLToPath(import.meta.url)), "..");
16
16
 
17
17
  const pins = {
18
18
  x86_64: {
19
- linuxVirtSha256: "755deb045cfeb8c2bcaa2b2febe01ab2101d747921e64d4cd54d0312321e8d5d",
20
- linuxLtsSha256: "b0d8cc4d7e031b5619221e7978920d051bf1dd1e335f6d05d03964b2285fc3b4",
19
+ linuxVirtSha256: "414aa2572ef88164b1af9905fa6dfbe863bf50f6130d895d57282dea3e15f7b9",
20
+ linuxLtsSha256: "7614556471859956a436b95813daf409b444aa849ec899178b404e828c5a9d98",
21
21
  muslSha256: "573712e2f49c15bfc20a2699f204acdfc74c772722b15e7353d768057fae0e71",
22
22
  stubSha256: "8e64a5a3afee5f930e6e6716be726dc6d405530ac7f8fa5be6251dae68671ec9",
23
23
  libstdcppSha256: "14c987b556f5385a5db18376e788c75f37d85321b8dc1920d926ea7daac1d6f6",
@@ -32,8 +32,8 @@ const pins = {
32
32
  serialMajor: 4,
33
33
  },
34
34
  aarch64: {
35
- linuxVirtSha256: "e3839bfd751ff0a4421484f712b9b29efb514024523736a0ef62521521571b12",
36
- linuxLtsSha256: "795bcd49840be7f4ff9566f5f669201bd41202376a455b720a98eb814f0e72f8",
35
+ linuxVirtSha256: "b3820fee22e0366a15f7c139e5bc00348e333f3e7f77802271df7ded795aee4f",
36
+ linuxLtsSha256: "67b8f61c5cfb6ac9b5a65cb1610b67d467e6c4ffc17e7d29dbea4bd8c447ff32",
37
37
  muslSha256: "5e9674b7f41152fe2119093b5cb4c13eaaadb19c2d5422b2d7267913e663ee6e",
38
38
  stubSha256: "a1823d2d7082db555d528f82c1809f276c818aeb5b40f198f4565f104e055c38",
39
39
  libstdcppSha256: "2302e766d4e4926038ec166ecb85837ee884576115236ddb565e3a5fca4a11d7",
@@ -67,7 +67,7 @@ export const config = ({
67
67
  // The Alpine repository keeps only the newest build of a package, so a pin
68
68
  // stops downloading when Alpine bumps it; the hashes then fail loudly.
69
69
  const alpineBase = `${mirror}/v3.24/main/${arch}`;
70
- const kernelVersion = "6.18.56";
70
+ const kernelVersion = "6.18.57";
71
71
  const kernelRelease = `${kernelVersion}-0-${flavor}`;
72
72
  const bunVersion = "1.4.2";
73
73
  const downloadsDir = join(rootDir, "downloads", arch);
package/scripts/arch.sh CHANGED
@@ -44,7 +44,7 @@ fi
44
44
  alpine_mirror=${ALPINE_MIRROR:-https://dl-cdn.alpinelinux.org/alpine}
45
45
  alpine_base="$alpine_mirror/v3.24/main/$arch"
46
46
 
47
- kernel_version=6.18.56
47
+ kernel_version=6.18.57
48
48
  kernel_release="$kernel_version-0-$linux_flavor"
49
49
  linux_package="linux-$linux_flavor-$kernel_version-r0.apk"
50
50
  musl_package=musl-1.2.6-r2.apk
@@ -63,8 +63,8 @@ musl_libc="libc.musl-$arch.so.1"
63
63
 
64
64
  case $arch in
65
65
  x86_64)
66
- linux_virt_sha256=755deb045cfeb8c2bcaa2b2febe01ab2101d747921e64d4cd54d0312321e8d5d
67
- linux_lts_sha256=b0d8cc4d7e031b5619221e7978920d051bf1dd1e335f6d05d03964b2285fc3b4
66
+ linux_virt_sha256=414aa2572ef88164b1af9905fa6dfbe863bf50f6130d895d57282dea3e15f7b9
67
+ linux_lts_sha256=7614556471859956a436b95813daf409b444aa849ec899178b404e828c5a9d98
68
68
  musl_sha256=573712e2f49c15bfc20a2699f204acdfc74c772722b15e7353d768057fae0e71
69
69
  stub_sha256=8e64a5a3afee5f930e6e6716be726dc6d405530ac7f8fa5be6251dae68671ec9
70
70
  libstdcpp_sha256=14c987b556f5385a5db18376e788c75f37d85321b8dc1920d926ea7daac1d6f6
@@ -82,8 +82,8 @@ case $arch in
82
82
  serial_major=4
83
83
  ;;
84
84
  aarch64)
85
- linux_virt_sha256=e3839bfd751ff0a4421484f712b9b29efb514024523736a0ef62521521571b12
86
- linux_lts_sha256=795bcd49840be7f4ff9566f5f669201bd41202376a455b720a98eb814f0e72f8
85
+ linux_virt_sha256=b3820fee22e0366a15f7c139e5bc00348e333f3e7f77802271df7ded795aee4f
86
+ linux_lts_sha256=67b8f61c5cfb6ac9b5a65cb1610b67d467e6c4ffc17e7d29dbea4bd8c447ff32
87
87
  musl_sha256=5e9674b7f41152fe2119093b5cb4c13eaaadb19c2d5422b2d7267913e663ee6e
88
88
  stub_sha256=a1823d2d7082db555d528f82c1809f276c818aeb5b40f198f4565f104e055c38
89
89
  libstdcpp_sha256=2302e766d4e4926038ec166ecb85837ee884576115236ddb565e3a5fca4a11d7
@@ -21,6 +21,7 @@ export const requests = ({ arch, flavor, real }) => [
21
21
  "failover",
22
22
  "net_failover",
23
23
  "virtio_net",
24
+ "af_packet", // DHCP before an interface has an IPv4 address
24
25
  ]),
25
26
  ...optional([
26
27
  "virtio_pci",
@@ -1,19 +1,19 @@
1
1
  # npm packages
2
2
  # Latest versions checked on 2026-10-11; SHA-256 pins verified against registry SHA-512 integrity.
3
- # buninu-browser updated and verified on 2026-10-12.
4
- # casty 1.3.6 and buninu-land 0.1.2 verified on 2026-10-16.
3
+ # buninu-browser 0.2.4 and buninu-land 0.1.4 verified on 2026-10-18.
4
+ # casty 1.3.7 verified on 2026-10-16.
5
5
 
6
- "buninu-browser@0.2.3":
7
- - https://registry.npmjs.org/buninu-browser/-/buninu-browser-0.2.3.tgz
8
- - sha256:0f7c9cd9df689d7b0900fdf3172851125ebd8aba12bed555be7e091d427146e8
6
+ "buninu-browser@0.2.4":
7
+ - https://registry.npmjs.org/buninu-browser/-/buninu-browser-0.2.4.tgz
8
+ - sha256:247c1ae9644b5016d1dc3f74d1c6c18a986e3961b1d86d2497862e4007bc50aa
9
9
 
10
- "buninu-land@0.1.2":
11
- - https://registry.npmjs.org/buninu-land/-/buninu-land-0.1.2.tgz
12
- - sha256:0b682e1d57f953d9fa9415369c320e4e35bf7575c2859673c26a0789f5369490
10
+ "buninu-land@0.1.4":
11
+ - https://registry.npmjs.org/buninu-land/-/buninu-land-0.1.4.tgz
12
+ - sha256:5cda65c60a7af8c7a05ec519a254e0af3f9913ea94f6ae493e5ddc93a8d87407
13
13
 
14
- "@drxiaozhi/casty@1.3.6":
15
- - https://registry.npmjs.org/@drxiaozhi/casty/-/casty-1.3.6.tgz
16
- - sha256:2b5f84e3a63c2c16fa92dc88faa43eec54edc2fe69154e5270af3cd9e7f472b8
14
+ "@drxiaozhi/casty@1.3.7":
15
+ - https://registry.npmjs.org/@drxiaozhi/casty/-/casty-1.3.7.tgz
16
+ - sha256:acc0641fb68c00222ba1cd2b7520ba99c269b996e145e82f18466be4d126b811
17
17
 
18
18
  "@drxiaozhi/jspulse@0.2.1":
19
19
  - https://registry.npmjs.org/@drxiaozhi/jspulse/-/jspulse-0.2.1.tgz
@@ -1,7 +1,7 @@
1
1
  // Assemble fetched packages in a disposable pack staging tree, never in the
2
2
  // source initramfs. No registry access, dependency resolution or lifecycle code.
3
3
  import { createHash } from "node:crypto";
4
- import { chmodSync, existsSync, mkdirSync, readFileSync, symlinkSync, writeFileSync } from "node:fs";
4
+ import { chmodSync, existsSync, mkdirSync, readFileSync, symlinkSync, unlinkSync, writeFileSync } from "node:fs";
5
5
  import { join, relative, resolve } from "node:path";
6
6
  import { spawnSync } from "node:child_process";
7
7
  import { jsRuntime, rootDir, runIfMain } from "./arch.js";
@@ -34,6 +34,13 @@ export function stageNpm(staging, { packages = npmPackages, downloadsDir = npmDo
34
34
  "--exclude=package/usr/lib/canvaskit", "--exclude=package/usr/share/fonts");
35
35
  const result = spawnSync(runtime, args, { stdio: "inherit" });
36
36
  if (result.status !== 0) throw new Error(`npm extraction failed: ${pkg.name}`);
37
+ if (pkg.name === "buninu-land") {
38
+ const wasm = join(destination, "vendor/canvaskit/canvaskit.wasm");
39
+ const shared = join(staging, "lib/canvaskit/canvaskit.wasm");
40
+ const systemWasm = existsSync(shared) ? shared : join(rootDir, "initramfs/lib/canvaskit/canvaskit.wasm");
41
+ // Deduplicate only byte-identical releases; future differing WASM stays bundled.
42
+ if (existsSync(wasm) && existsSync(systemWasm) && readFileSync(wasm).equals(readFileSync(systemWasm))) unlinkSync(wasm);
43
+ }
37
44
  const info = JSON.parse(readFileSync(join(destination, "package.json"), "utf8"));
38
45
  if (info.name !== pkg.name || info.version !== pkg.version) throw new Error(`npm identity mismatch: ${pkg.name}`);
39
46
  const entries = typeof info.bin === "string" ? { [pkg.name.split("/").pop()]: info.bin } : info.bin ?? {};
@@ -45,7 +45,15 @@ for ((i=0; i<${#names[@]}; i++)); do
45
45
  extra=()
46
46
  [[ "$name" != buninu-browser ]] || extra+=(--exclude=package/usr/lib/canvaskit --exclude=package/usr/share/fonts)
47
47
  tar -xf "downloads/npm/${archives[i]}" -C "$destination" --strip-components=1 "${extra[@]}"
48
- # Bin entries of the four SHA-pinned packages; no package scripts execute.
48
+ if [[ "$name" == buninu-land ]]; then
49
+ wasm="$destination/vendor/canvaskit/canvaskit.wasm"
50
+ shared="$staging/lib/canvaskit/canvaskit.wasm"
51
+ [[ -f "$shared" ]] || shared=initramfs/lib/canvaskit/canvaskit.wasm
52
+ if [[ -f "$wasm" && -f "$shared" ]] && cmp -s "$wasm" "$shared"; then
53
+ rm -- "$wasm"
54
+ fi
55
+ fi
56
+ # Bin entries of the SHA-pinned packages; no package scripts execute.
49
57
  bin=${name##*/}; entry=index.js
50
58
  [[ "$name" != @drxiaozhi/jspulse ]] || entry=jspulse.js
51
59
  chmod 755 "$destination/$entry"
@@ -0,0 +1,55 @@
1
+ import { expect, test } from 'bun:test';
2
+ import { createRequire } from 'node:module';
3
+ import { mkdtempSync, writeFileSync, rmSync } from 'node:fs';
4
+ import { tmpdir } from 'node:os';
5
+ import { join } from 'node:path';
6
+ import { deflateSync } from 'node:zlib';
7
+ import { decodeImage } from '../initramfs/lib/bunterm/images.js';
8
+ const { KittyGraphicsParser } = createRequire(import.meta.url)('../initramfs/buninu/apps/jsgotty/gotty.js');
9
+ const png = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aOc8AAAAASUVORK5CYII=', 'base64');
10
+ function parse(parser, control, payload = '') {
11
+ const { events } = parser.consume(Buffer.from(`\x1b_G${control};${payload}\x1b\\`));
12
+ return parser.parsePacket(events[0].packet, { row: 1, col: 1 });
13
+ }
14
+ async function checkDecode(image) {
15
+ const decoded = {};
16
+ let received;
17
+ expect(await decodeImage({ MakeImageFromEncoded(bytes) { received = bytes; return decoded; } }, image)).toBe(decoded);
18
+ // Identity verifies that rendering neither copies nor base64-roundtrips bytes.
19
+ expect(received).toBe(image.data);
20
+ expect(received.equals(png)).toBe(true);
21
+ }
22
+ test('binary parser assembles inline chunks and renderer consumes the same bytes', async () => {
23
+ const parser = new KittyGraphicsParser({ binaryData: true });
24
+ const encoded = png.toString('base64');
25
+ expect(parse(parser, 'a=T,f=100,i=1,m=1', encoded.slice(0, 32))).toBeNull();
26
+ const graphic = parse(parser, 'm=0', encoded.slice(32));
27
+ await checkDecode(graphic.image);
28
+ expect(parse(parser, 'a=p,i=1').image).toBe(graphic.image);
29
+ });
30
+ test('binary file transfer reads bytes without re-encoding; compressed inline also works', async () => {
31
+ const dir = mkdtempSync(join(tmpdir(), 'bunterm-image-bytes-'));
32
+ try {
33
+ const path = join(dir, 'frame.png');
34
+ writeFileSync(path, png);
35
+ const parser = new KittyGraphicsParser({ binaryData: true });
36
+ await checkDecode(parse(parser, 'a=T,t=f,f=100,i=1', Buffer.from(path).toString('base64')).image);
37
+ await checkDecode(parse(parser, 'a=T,f=100,o=z,i=2', deflateSync(png).toString('base64')).image);
38
+ } finally { rmSync(dir, { recursive: true, force: true }); }
39
+ });
40
+ test('default parser and legacy renderer callers retain base64 compatibility', async () => {
41
+ const image = parse(new KittyGraphicsParser(), 'a=T,f=100,i=1', png.toString('base64')).image;
42
+ expect(image.data).toBe(png.toString('base64'));
43
+ let received;
44
+ await decodeImage({ MakeImageFromEncoded(bytes) { received = bytes; return {}; } }, image);
45
+ expect(received.equals(png)).toBe(true);
46
+ });
47
+ test('raw RGBA bytes pass directly to CanvasKit', async () => {
48
+ const pixels = Buffer.from([1, 2, 3, 255]);
49
+ const image = parse(new KittyGraphicsParser({ binaryData: true }), 'a=T,f=32,s=1,v=1,i=3', pixels.toString('base64')).image;
50
+ let received;
51
+ await decodeImage({ ColorType: { RGBA_8888: 1 }, AlphaType: { Unpremul: 1 }, ColorSpace: { SRGB: 1 },
52
+ MakeImage(info, bytes, stride) { received = bytes; expect(stride).toBe(4); return {}; } }, image);
53
+ expect(received).toBe(image.data);
54
+ expect(received.equals(pixels)).toBe(true);
55
+ });
@@ -0,0 +1,67 @@
1
+ import {test,expect} from 'bun:test';
2
+ import {createDhcpLauncher} from '../initramfs/lib/dhcp-start.js';
3
+ const pause=ms=>Bun.sleep(ms);
4
+ function fakeChild(){
5
+ let resolve,reject,stdout;
6
+ const exited=new Promise((yes,no)=>{resolve=yes;reject=no;});
7
+ return {exited,resolve,reject,kills:[],kill(signal){this.kills.push(signal);},
8
+ stdout:new ReadableStream({start(c){stdout=c;}}),stderr:null,
9
+ output(line){stdout.enqueue(Buffer.from(line));},end(){stdout.close();}};
10
+ }
11
+ test('launch returns immediately, isolates spawn failures and reuses running children',async()=>{
12
+ const calls=[],logs=[],children=[];
13
+ const launch=createDhcpLauncher({spawn(args){calls.push(args);if(args.at(-1)==='bad')throw new Error('spawn denied');const child=fakeChild();children.push(child);return child;},log:s=>logs.push(s),watchdogMs:500});
14
+ expect(launch(['bad','eth0','eth1'])).toEqual(['eth0','eth1']);
15
+ expect(launch(['eth0','eth1'])).toEqual([]);expect(calls.length).toBe(3);
16
+ expect(calls[2]).toContain('--no-dns');expect(calls[2]).toContain('--metric');
17
+ expect(logs.some(s=>s.includes('spawn denied'))).toBe(true);
18
+ for(const child of children){child.end();child.resolve(1);}await pause(5);
19
+ expect(logs.some(s=>s.includes('(exit 1); other interfaces are unaffected'))).toBe(true);
20
+ expect(launch(['eth0'])).toEqual(['eth0']);children.at(-1).end();children.at(-1).resolve(0);
21
+ });
22
+ test('watchdog terminates hung acquisition and escalates if SIGTERM does not exit',async()=>{
23
+ const child=fakeChild(),logs=[];
24
+ const launch=createDhcpLauncher({spawn:()=>child,log:s=>logs.push(s),watchdogMs:10,killGraceMs:10});
25
+ launch(['eth0']);await pause(60);
26
+ expect(child.kills).toEqual(['SIGTERM','SIGKILL']);child.end();child.resolve(137);await pause(5);
27
+ expect(logs.some(s=>s.includes('timed out; other interfaces are unaffected'))).toBe(true);
28
+ });
29
+ test('successful one-shot exits and clears the watchdog',async()=>{
30
+ const child=fakeChild();const launch=createDhcpLauncher({spawn:()=>child,log:()=>{},watchdogMs:20,killGraceMs:5});
31
+ launch(['eth0']);child.output('dhclient: bound to 10.0.2.15/24, lease 86400s\n');child.end();child.resolve(0);await pause(50);
32
+ expect(child.kills).toEqual([]);
33
+ });
34
+ test('stream rejection, exit rejection, kill failure and throwing logger cannot escape',async()=>{
35
+ const child=fakeChild(),logs=[];
36
+ child.stderr=new ReadableStream({start(c){c.error(null);}});
37
+ child.kill=()=>{throw null;};
38
+ const launch=createDhcpLauncher({spawn:()=>child,log:s=>logs.push(s),watchdogMs:5,killGraceMs:5});
39
+ expect(()=>launch(['eth0'])).not.toThrow();await pause(30);child.end();child.reject(null);await pause(5);
40
+ expect(logs.some(s=>s.startsWith('[dhcp eth0] failed: timed out'))).toBe(true);
41
+ const bad=createDhcpLauncher({spawn(){throw null;},log(){throw new Error('console failed');}});
42
+ expect(()=>bad(['eth0'])).not.toThrow();expect(()=>bad(null)).not.toThrow();
43
+ });
44
+
45
+ test('usb0 success and late eth0 failure each produce one correctly attributed result',async()=>{
46
+ const children={eth0:fakeChild(),usb0:fakeChild()},logs=[];let stderr;
47
+ children.eth0.stderr=new ReadableStream({start(c){stderr=c;}});
48
+ const launch=createDhcpLauncher({spawn:args=>children[args.at(-1)],log:s=>logs.push(s),watchdogMs:500});
49
+ launch(['eth0','usb0']);
50
+ children.usb0.output('dhclient: bound to 192.168.42.2/24, lease 3600s\n');children.usb0.end();children.usb0.resolve(0);
51
+ await pause(5);
52
+ children.eth0.resolve(1);children.eth0.end();
53
+ // Exit notification precedes delivery of the error; the final result must wait.
54
+ await pause(5);stderr.enqueue(Buffer.from('dhclient: no DHCP lease received before timeout\n'));stderr.close();await pause(10);
55
+ expect(logs.filter(s=>s.includes('configured '))).toEqual(['[dhcp usb0] configured 192.168.42.2/24; command finished']);
56
+ expect(logs.filter(s=>s.includes('failed:'))).toEqual(['[dhcp eth0] failed: no DHCP lease received before timeout (exit 1); other interfaces are unaffected']);
57
+ expect(logs.some(s=>s.includes('usb0')&&s.includes('failed'))).toBe(false);
58
+ });
59
+
60
+ test('unterminated output cannot hold the result open or prevent later invocations',async()=>{
61
+ const children=[],logs=[];
62
+ const launch=createDhcpLauncher({spawn(){const c=fakeChild();children.push(c);return c;},log:s=>logs.push(s),watchdogMs:500,outputGraceMs:10});
63
+ launch(['usb0']);children[0].output('dhclient: bound to 192.168.42.2/24, lease 3600s\n');children[0].resolve(0);
64
+ await pause(30);
65
+ expect(logs.some(s=>s.includes('configured 192.168.42.2/24'))).toBe(true);
66
+ expect(launch(['usb0'])).toEqual(['usb0']);children[1].end();children[1].resolve(0);
67
+ });
@@ -0,0 +1,99 @@
1
+ import {test,expect} from 'bun:test';
2
+ import {mkdtempSync,readFileSync,writeFileSync,rmSync} from 'node:fs';
3
+ import {tmpdir} from 'node:os';
4
+ import {TYPE,encode,decode,leaseFrom,wrapIPv4,unwrapIPv4,checksum,DhcpClient,NakError} from '../initramfs/lib/dhcp.js';
5
+ import {parseDhclient,Configuration,manageLease} from '../initramfs/lib/dhclient.js';
6
+ const mac=Buffer.from('020000000001','hex');
7
+ function reply(request,type,{ip='10.20.0.10',server=[10,20,0,1],duration=120}={}){
8
+ const b=Buffer.from(request.subarray(0,240));b[0]=2;Buffer.from(ip.split('.').map(Number)).copy(b,16);
9
+ const lease=Buffer.alloc(4);lease.writeUInt32BE(duration);
10
+ return Buffer.concat([b,Buffer.from([53,1,type,54,4,...server,1,4,255,255,255,0,51,4]),lease,Buffer.from([3,4,...server,6,4,1,1,1,1,255])]);
11
+ }
12
+ test('DHCP CLI validates options before opening sockets',()=>{
13
+ expect(parseDhclient(['-4','-d','-1','-v','-timeout','8','--no-dns','-H','buninu','eth0'])).toMatchObject({interface:'eth0',once:true,timeout:8,dns:false});
14
+ expect(parseDhclient(['usb0']).interface).toBe('usb0');
15
+ expect(parseDhclient(['--metric','120','usb0']).metric).toBe(120);
16
+ for(const args of [['--metric','32767','eth0'],[],['-6','eth0'],['-timeout','NaN','eth0'],['-r','-1','eth0'],['eth0','eth1'],['../bad']])expect(()=>parseDhclient(args)).toThrow();
17
+ });
18
+ test('codec validates xid, MAC, truncation, options, mask and lease timers',()=>{
19
+ const req=encode({type:TYPE.DISCOVER,xid:123,mac}),ack=reply(req,TYPE.ACK);
20
+ expect(req.length).toBeGreaterThanOrEqual(300);expect(req.readUInt16BE(10)).toBe(0x8000);
21
+ const parsed=decode(ack,{xid:123,mac});expect(parsed.type).toBe(TYPE.ACK);
22
+ expect(decode(ack,{xid:456,mac})).toBeNull();expect(decode(ack,{mac:Buffer.alloc(6)})).toBeNull();
23
+ expect(decode(ack.subarray(0,ack.length-3))).toBeNull();
24
+ expect(leaseFrom(parsed,null,1000)).toMatchObject({prefix:24,renewAt:61000,rebindAt:106000,expiresAt:121000});
25
+ expect(()=>leaseFrom({...parsed,mask:'255.0.255.0'},null,0)).toThrow();
26
+ expect(leaseFrom({...parsed,t1:200,t2:100},null,0).renewAt).toBe(60000);
27
+ });
28
+ test('packet transport checks header checksum, fragmentation, ports and UDP checksum',()=>{
29
+ const payload=reply(encode({type:1,xid:1,mac}),5);
30
+ const packet=wrapIPv4(payload,'10.20.0.1');packet.writeUInt16BE(67,20);packet.writeUInt16BE(68,22);
31
+ expect(unwrapIPv4(packet)).toEqual(payload);
32
+ const bad=Buffer.from(packet);bad[12]^=1;expect(unwrapIPv4(bad)).toBeNull();
33
+ const fragment=Buffer.from(packet);fragment.writeUInt16BE(0x2000,6);fragment.writeUInt16BE(0,10);fragment.writeUInt16BE(checksum(fragment.subarray(0,20)),10);expect(unwrapIPv4(fragment)).toBeNull();
34
+ packet.writeUInt16BE(123,26);expect(unwrapIPv4(packet)).toBeNull();
35
+ });
36
+ test('DORA retries, ignores unrelated replies, then renews and releases via unicast',async()=>{
37
+ let time=0,discover=0;const queue=[],sent=[];
38
+ const transport={send(b,server){sent.push({b,server});const t=b[242];
39
+ if(t===1&&++discover===1)return;
40
+ if(t===1){const wrong=reply(b,2);wrong.writeUInt32BE(999,4);queue.push(wrong,reply(b,2));}
41
+ if(t===3)queue.push(reply(b,5));
42
+ },receive(){return queue.shift();}};
43
+ const client=new DhcpClient({transport,mac,timeout:10,now:()=>time,sleep:async ms=>{time+=ms;},random:()=>.5});
44
+ const lease=await client.acquire();expect(lease.address).toBe('10.20.0.10');expect(discover).toBe(2);
45
+ time=lease.renewAt;const renewed=await client.renew(lease);expect(renewed.address).toBe(lease.address);
46
+ const renew=sent.at(-1);expect(renew.server).toBe('10.20.0.1');expect(renew.b.subarray(12,16)).toEqual(Buffer.from([10,20,0,10]));
47
+ expect(renew.b.readUInt16BE(10)).toBe(0);
48
+ client.release(renewed);expect(sent.at(-1).b[242]).toBe(7);expect(sent.at(-1).server).toBe(lease.server);
49
+ });
50
+ test('acquisition times out and NAK is surfaced during renewal',async()=>{
51
+ let time=0;const queue=[];
52
+ const client=new DhcpClient({transport:{send(){},receive:()=>queue.shift()},mac,timeout:.05,now:()=>time,sleep:async ms=>{time+=ms;}});
53
+ expect(await client.acquire()).toBeNull();
54
+ client.transport.send=b=>queue.push(reply(b,6));
55
+ await expect(client.renew({address:'10.20.0.10',server:'10.20.0.1',rebindAt:1000})).rejects.toBeInstanceOf(NakError);
56
+ });
57
+ test('configuration uses existing ip commands, preserves DNS across renewal and restores it',async()=>{
58
+ const dir=mkdtempSync(`${tmpdir()}/dhcp-test-`),path=`${dir}/resolv.conf`,calls=[];
59
+ try {
60
+ writeFileSync(path,'nameserver 9.9.9.9\n');
61
+ const config=new Configuration({interface:'eth0',dns:true,resolv:path,metric:100},async args=>calls.push(args));
62
+ const lease={address:'10.20.0.10',prefix:24,routers:['10.20.0.1'],dns:['1.1.1.1']};
63
+ await config.apply(lease);const renewed={...lease,dns:['8.8.8.8']};await config.apply(renewed,lease);
64
+ expect(calls[0]).toEqual(['addr','replace','10.20.0.10/24','brd','+','dev','eth0']);
65
+ expect(readFileSync(path,'utf8')).toContain('8.8.8.8');await config.clear(renewed);
66
+ expect(calls).toContainEqual(['route','replace','default','via','10.20.0.1','dev','eth0','metric','100']);
67
+ expect(calls).toContainEqual(['route','del','default','via','10.20.0.1','dev','eth0','metric','100']);
68
+ expect(readFileSync(path,'utf8')).toBe('nameserver 9.9.9.9\n');
69
+ await config.apply(lease);writeFileSync(path,'external edit\n');await config.clear(lease);expect(readFileSync(path,'utf8')).toBe('external edit\n');
70
+ }finally{rmSync(dir,{recursive:true,force:true});}
71
+ });
72
+ test('default invocation configures once and returns without starting renewal',async()=>{
73
+ const events=[],lease={address:'10.20.0.10',prefix:24,seconds:60};
74
+ const client={async acquire(){events.push('acquire');return lease;},renew(){throw new Error('must not renew');},release(){events.push('release');}};
75
+ await manageLease(client,{async apply(){events.push('apply');},async clear(){events.push('clear');}},{log:()=>{}});
76
+ expect(events).toEqual(['acquire','apply']);
77
+ expect(parseDhclient(['usb0']).once).toBe(true);
78
+ });
79
+ test('one-shot keeps a successful lease but cleans up failed configuration',async()=>{
80
+ const events=[],client={async acquire(){return {address:'10.0.0.2',prefix:24,seconds:60};},release(){events.push('release');}};
81
+ const config={async apply(){},async clear(){events.push('clear');}};
82
+ await manageLease(client,config,{once:true,log:()=>{}});expect(events).toEqual([]);
83
+ config.apply=async()=>{throw new Error('route failed');};
84
+ await expect(manageLease(client,config,{once:true,log:()=>{}})).rejects.toThrow('route failed');expect(events).toEqual(['release','clear']);
85
+ });
86
+
87
+ test('runtime libc sendto delivers a UDP datagram on loopback',async()=>{
88
+ const {libc,ptr,check}=await import('../initramfs/lib/dlopen.js');
89
+ let received;
90
+ const server=await Bun.udpSocket({hostname:'127.0.0.1',port:0,socket:{data(_socket,data){received=Buffer.from(data);}}});
91
+ const fd=check('socket',libc.symbols.socket(2,2,17));
92
+ try{
93
+ const target=Buffer.alloc(16);target.writeUInt16LE(2);target.writeUInt16BE(server.port,2);target.set([127,0,0,1],4);
94
+ const data=Buffer.from('dhcp transport libc probe');
95
+ expect(check('sendto',Number(libc.symbols.sendto(fd,ptr(data),data.length,0,ptr(target),16)))).toBe(data.length);
96
+ for(let i=0;i<100&&!received;i++)await Bun.sleep(5);
97
+ expect(received).toEqual(data);
98
+ }finally{libc.symbols.close(fd);server.close();}
99
+ });
@@ -23,7 +23,7 @@ const url = `http://127.0.0.1:${server.port}/archive`;
23
23
 
24
24
  test("five exact registry pins share an architecture-independent cache", () => {
25
25
  expect(npmPackages.map(({ name, version }) => `${name}@${version}`)).toEqual([
26
- "buninu-browser@0.2.3", "buninu-land@0.1.2", "@drxiaozhi/casty@1.3.6",
26
+ "buninu-browser@0.2.4", "buninu-land@0.1.4", "@drxiaozhi/casty@1.3.7",
27
27
  "@drxiaozhi/jspulse@0.2.1", "@drxiaozhi/jspinyin@0.1.1",
28
28
  ]);
29
29
  expect(basename(npmDownloadsDir)).toBe("npm");
@@ -0,0 +1,41 @@
1
+ import {test,expect} from 'bun:test';
2
+ import {libc,libcPath,errno,strerror,SysError,withInetSocket} from '../initramfs/lib/dlopen.js';
3
+ import {spawnSync} from 'node:child_process';
4
+ test('host libc loads automatically and exposes the platform errno',()=>{
5
+ expect(libc.symbols.close(-1)).toBe(-1);expect(libcPath.length).toBeGreaterThan(0);expect(errno()).toBe(9);expect(strerror(9)).toContain('descriptor');expect(new SysError('close').errno).toBe(9);
6
+ if(process.platform==='android')expect(libcPath).toBe('libc.so');
7
+ });
8
+ test('an explicit invalid libc path fails instead of silently falling back',()=>{
9
+ const r=spawnSync(process.execPath,['-e',`const {libc}=await import(${JSON.stringify(new URL('../initramfs/lib/dlopen.js',import.meta.url).href)});libc.symbols.close(-1)`],{env:{...process.env,BUNINU_LIBC:'/nonexistent/buninu-libc.so'},encoding:'utf8'});
10
+ expect(r.status).not.toBe(0);expect(r.stderr).toContain('/nonexistent/buninu-libc.so');
11
+ });
12
+
13
+ test('existing libc socket/ioctl helper still queries loopback without sysfs',()=>{
14
+ const request=Buffer.alloc(40);request.write('lo');
15
+ withInetSocket(ioctl=>ioctl(0x8933,request,'SIOCGIFINDEX'));
16
+ expect(request.readInt32LE(16)).toBeGreaterThan(0);
17
+ });
18
+
19
+ test('manual library resolves only requested symbols and keeps working after a missing symbol',()=>{
20
+ const moduleURL=new URL('../initramfs/lib/dlopen.js',import.meta.url).href;
21
+ const script=`
22
+ import {mock} from 'bun:test';
23
+ const ffi=await import('bun:ffi');
24
+ const calls=[];
25
+ mock.module('bun:ffi',()=>({...ffi,dlopen(path,symbols){
26
+ if(path!=='/manual/partial.so')throw new Error('unexpected fallback');
27
+ const name=Object.keys(symbols)[0];calls.push(name);
28
+ if(name!=='close')throw new Error('missing '+name);
29
+ return {symbols:{close:()=>7},close(){}};
30
+ }}));
31
+ const {libc}=await import(${JSON.stringify(moduleURL)});
32
+ if(calls.length)throw new Error('eager symbol resolution');
33
+ if(libc.symbols.close(0)!==7)throw new Error('supported symbol failed');
34
+ try {libc.symbols.mount;throw new Error('missing symbol accepted');}
35
+ catch(e){if(!e.message.includes('missing mount'))throw e;}
36
+ if(libc.symbols.close(0)!==7)throw new Error('missing symbol poisoned library');
37
+ if(calls.filter(x=>x==='close').length!==1)throw new Error('symbol not cached');
38
+ `;
39
+ const r=spawnSync(process.execPath,['-e',script],{env:{...process.env,BUNINU_LIBC:'/manual/partial.so'},encoding:'utf8'});
40
+ expect(r.stderr).toBe('');expect(r.status).toBe(0);
41
+ });
@@ -0,0 +1,80 @@
1
+ import {test,expect} from 'bun:test';
2
+ import {spawnSync} from 'node:child_process';
3
+ import {resolve} from 'node:path';
4
+ import {parsePing,checksum,echoPacket,isEchoReply} from '../initramfs/lib/ping.js';
5
+ const command=resolve(import.meta.dir,'../initramfs/bin/ping');
6
+ const system=Bun.which('ping');
7
+ const separatePing6=Bun.which('ping6');
8
+ const supports6=system&&spawnSync(system,['-6','-c','1','-W','1','::1'],{encoding:'utf8',timeout:2000}).status===0;
9
+ const run=(args,reference=false)=>{
10
+ const usePing6=reference&&args.includes('-6')&&!supports6&&separatePing6;
11
+ const executable=reference?(usePing6?separatePing6:system):process.execPath;
12
+ const options=reference?args.filter(a=>a!=='-4'&&(!usePing6||a!=='-6')):[command,...args];
13
+ return spawnSync(executable,options,{encoding:'utf8',timeout:6000,env:{...process.env,LC_ALL:'C'}});
14
+ };
15
+ const summary=s=>{const m=/(\d+) packets transmitted, (\d+) (?:packets )?received,.*?(\d+)% packet loss/.exec(s);return m?m.slice(1).map(Number):null};
16
+ const probe=system?run(['-4','-c','1','-W','1','127.0.0.1'],true):null;
17
+ const available=probe?.status===0;
18
+
19
+ test('ping parses common flags and rejects malformed arguments',()=>{
20
+ expect(parsePing(['-4nqD','-c3','-i','.2','-W','1','-w','5','-s','17','-t64','-I','lo','localhost'])).toMatchObject({family:4,count:3,interval:.2,timeout:1,deadline:5,size:17,ttl:64,source:'lo',host:'localhost',quiet:true,timestamp:true});
21
+ expect(parsePing(['localhost']).count).toBe(4);
22
+ expect(parsePing(['-6','--','::1']).family).toBe(6);
23
+ for(const args of [[],['-c'],['-c','0','localhost'],['-c','Infinity','localhost'],['-W','NaN','localhost'],['-s','65508','localhost'],['-s','-1','localhost'],['-t','256','localhost'],['-i','0','localhost'],['-w','0','localhost'],['-Z','localhost'],['a','b']])expect(()=>parsePing(args)).toThrow();
24
+ expect(parsePing(['--help']).help).toBe(true);
25
+ expect(run(['-c','0','127.0.0.1']).status).toBe(2);
26
+ });
27
+
28
+ test('ICMP checksum, reply identity, data integrity and raw IPv4 header validation',()=>{
29
+ for(const size of [0,1,17,56]){
30
+ const request=echoPacket(4,123,7,size);expect(checksum(request)).toBe(0);
31
+ const reply=Buffer.from(request);reply[0]=0;reply.writeUInt16BE(0,2);reply.writeUInt16BE(checksum(reply),2);
32
+ const info={family:4,raw:false,id:123,seq:7,packet:request};
33
+ expect(isEchoReply(reply,info)).toBe(true);expect(isEchoReply(request,info)).toBe(false);
34
+ expect(isEchoReply(reply,{...info,id:124})).toBe(false);expect(isEchoReply(reply,{...info,seq:8})).toBe(false);
35
+ const ip=Buffer.alloc(24);ip[0]=0x46;ip[9]=1;
36
+ expect(isEchoReply(Buffer.concat([ip,reply]),{...info,raw:true})).toBe(true);
37
+ expect(isEchoReply(reply.subarray(0,7),info)).toBe(false);
38
+ if(size){reply[8]^=1;expect(isEchoReply(reply,info)).toBe(false);}
39
+ }
40
+ const packet=echoPacket(6,3,4,8),reply=Buffer.from(packet);reply[0]=129;
41
+ expect(isEchoReply(reply,{family:6,raw:false,id:3,seq:4,packet})).toBe(true);
42
+ });
43
+
44
+ for(const size of [0,17,56,1400])test.skipIf(!available)(`system ping comparison: IPv4 count, payload ${size}, statistics and exit status`,()=>{
45
+ const args=['-4','-n','-c','3','-i','0.2','-W','1','-s',String(size),'127.0.0.1'];
46
+ const reference=run(args,true),actual=run(args);
47
+ expect(actual.status).toBe(reference.status);expect(actual.status).toBe(0);
48
+ expect(summary(actual.stdout)).toEqual(summary(reference.stdout));expect(summary(actual.stdout)).toEqual([3,3,0]);
49
+ expect(actual.stdout).toContain(`${size+8} bytes from 127.0.0.1`);expect(actual.stdout).toContain('icmp_seq=3');
50
+ expect(actual.stdout).toMatch(/rtt min\/avg\/max\/mdev = [\d.]+\/[\d.]+\/[\d.]+\/[\d.]+ ms/);
51
+ });
52
+
53
+ test.skipIf(!available)('system ping comparison: hostname, source address, quiet mode and TTL',()=>{
54
+ const args=['-4','-n','-q','-c','2','-i','.2','-W','1','-t','32','-I','127.0.0.1','localhost'];
55
+ const reference=run(args,true),actual=run(args);
56
+ expect(actual.status).toBe(reference.status);expect(actual.status).toBe(0);expect(summary(actual.stdout)).toEqual(summary(reference.stdout));expect(actual.stdout).not.toContain('icmp_seq=');
57
+ });
58
+ const ipv6=available&&run(['-6','-c','1','-W','1','::1'],true).status===0;
59
+ test.skipIf(!ipv6)('system ping comparison: IPv6 loopback',()=>{
60
+ const args=['-6','-n','-c','3','-i','.2','-W','1','::1'],reference=run(args,true),actual=run(args);
61
+ expect(actual.status).toBe(reference.status);expect(actual.status).toBe(0);expect(summary(actual.stdout)).toEqual([3,3,0]);expect(summary(actual.stdout)).toEqual(summary(reference.stdout));
62
+ });
63
+ test.skipIf(!available)('system ping comparison: deadline before requested count returns failure',()=>{
64
+ const args=['-4','-q','-c','100','-i','.2','-w','1','127.0.0.1'],reference=run(args,true),begin=performance.now(),actual=run(args);
65
+ expect(actual.status).toBe(reference.status);expect(actual.status).toBe(1);expect(performance.now()-begin).toBeLessThan(2500);expect(summary(actual.stdout)[0]).toBeLessThan(100);
66
+ });
67
+ test.skipIf(!available)('Ctrl-C prints statistics and releases the socket',async()=>{
68
+ const child=Bun.spawn([process.execPath,command,'-i','.2','127.0.0.1'],{stdout:'pipe',stderr:'pipe'});
69
+ const reader=child.stdout.getReader();let output='';
70
+ try{
71
+ for(;;){const {value,done}=await reader.read();if(done)break;output+=new TextDecoder().decode(value);if(output.includes('icmp_seq=')){child.kill('SIGINT');break;}}
72
+ for(;;){const {value,done}=await reader.read();if(done)break;output+=new TextDecoder().decode(value);}
73
+ expect(await child.exited).toBe(0);expect(output).toContain('ping statistics');expect(summary(output)[1]).toBeGreaterThan(0);
74
+ }finally{child.kill();reader.releaseLock();}
75
+ },6000);
76
+
77
+ test.skipIf(!available)('sub-200ms interval and fractional timeout are supported independently of old Android ping',()=>{
78
+ const r=run(['-c','3','-i','.01','-W','.1','-D','127.0.0.1']);
79
+ expect(r.status).toBe(0);expect(summary(r.stdout)).toEqual([3,3,0]);expect(r.stdout).toMatch(/\[\d+\.\d+\] 64 bytes/);
80
+ });