Skip to content

[Bug Report]: Perry Built-in HTTP Client Cannot Establish Network Connections on macOS arm64 #767

@FullStackPlayer

Description

@FullStackPlayer

Summary

Perry's built-in HTTP client modules (fetch, axios, node:http, net) are unable to establish any network connections — including to localhost and direct IP addresses — when compiled to a native binary on macOS arm64. The child_process module works correctly, and external tools like curl can make network requests without issues, confirming the host machine has full network connectivity.

Environment

Item Value
Perry Version 0.5.891 (also tested: 0.5.511, 0.5.890 — same result)
OS macOS 26.4.1 (Darwin)
Architecture arm64 (Apple Silicon)
Perry Package @perryts/perry-darwin-arm64 0.5.891
Xcode CLI Tools Installed
Network Fully functional (verified via system curl)

Steps to Reproduce

Case 1: fetch fails

// fetch_test.ts
const response = await fetch("https://api.ipify.org?format=json");
console.log(response.status);
perry compile fetch_test.ts -o fetch_test && ./fetch_test
# Expected: 200
# Actual: Fetch error: error sending request for url (https://api.ipify.org/?format=json)

Case 2: axios fails

// axios_test.ts
import axios from "axios"
const res = await axios.get("https://api.ipify.org?format=json");
console.log(res.status);
perry compile axios_test.ts -o axios_test && ./axios_test
# Expected: 200
# Actual: Request failed: error sending request for url (https://api.ipify.org/?format=json)

Case 3: node:http client returns undefined

// http_test.ts
import { request } from "node:http"
const req = request("http://1.1.1.1/", (res) => { console.log(res.statusCode); });
req.on("error", (err) => { console.error(err); });
req.end();
perry compile http_test.ts -o http_test && ./http_test
# Expected: 200
# Actual: TypeError: Cannot read properties of undefined (reading 'on')

Case 4: net.connect fails

// net_test.ts
import * as net from "net"
const socket = net.connect({ host: "1.1.1.1", port: 80 }, () => { console.log("connected"); });
socket.on("error", (err) => { console.error(err.message); });
perry compile net_test.ts -o net_test && ./net_test
# Expected: "connected"
# Actual: TCP error: file name contained an unexpected NUL byte

Note: The error "file name contained an unexpected NUL byte" is a Rust CString::new() error, indicating that the hostname string passed to the underlying socket API contains NUL (\0) bytes. This strongly suggests a string handling bug in Perry's net module FFI bridge, where TypeScript strings are not properly converted to C-style strings before being passed to system calls.

Case 5: Direct IP and localhost also fail

await fetch("http://1.1.1.1/");       // → error sending request
await fetch("http://127.0.0.1:9999/"); // → error sending request

This rules out DNS resolution as the root cause.

Diagnostic Evidence

child_process + curl works correctly

import { execSync } from "child_process"
const result = execSync("curl -s https://api.ipify.org?format=json", { encoding: "utf8" });
console.log(result); // → {"ip":"x.x.x.x"} ✅

This confirms:

  • The host machine has full network connectivity
  • DNS resolution works at the OS level
  • TLS/HTTPS works via system curl
  • The compiled Perry binary can spawn child processes

Compilation output confirms stdlib is linked

Collecting modules...
Found 1 module(s): 1 native, 0 JavaScript    ← correctly detected as native
Generating code...
Linking (with stdlib)...                      ← stdlib linked (includes http-client)
Binary size: 7.2MB                            ← consistent with stdlib-included binary

Compare with runtime-only linking (no fetch):

Linking (runtime-only)...
Binary size: 1.1MB

perry dev mode also fails

The same fetch code fails identically when run via perry dev, ruling out a compile-only issue.

RUST_LOG=debug produces no additional output

No networking-related debug messages are emitted, suggesting the error occurs before any network I/O is attempted (possibly in the runtime initialization or FFI bridge).

perry --print-api-manifest confirms APIs are registered

child_process.spawn    ✅ listed
child_process.execSync ✅ listed
fs.readFileSync        ✅ listed
fs.statSync            ✅ listed

The HTTP client modules (fetch, axios, node:http, net) are also present in the manifest and linked.

Comparison with Known Issues

Workaround

Using child_process + curl as an HTTP transport proxy:

  • Non-streaming: execSync("curl -s ...") — works synchronously
  • Streaming: spawn("curl", ["-s", "-N", "-o", tmpFile, ...]) + file polling with setInterval + fs.readFileSync — works for SSE streaming

This workaround is functional but has drawbacks:

  1. Requires curl to be available on the target system
  2. Adds overhead from process spawning and file I/O
  3. Streaming requires a temp file and polling (not event-driven)

Minimal Reproduction

// repro.ts — simplest possible reproduction
try {
  const res = await fetch("http://1.1.1.1/");
  console.log("OK:", res.status);
} catch (err: any) {
  console.error("FAIL:", err.message);
}
perry compile repro.ts -o repro && ./repro
# Output: FAIL: Fetch error: error sending request for url (http://1.1.1.1/)

Suggested Investigation Areas

  1. String handling in FFI bridge: The net.connect error "file name contained an unexpected NUL byte" indicates TypeScript strings are not properly converted to C-style strings (CString) before being passed to socket APIs. This may also affect fetch/axios internally when resolving hostnames.
  2. Tokio runtime initialization: Check if the async runtime is properly initialized for network I/O in CLI (non-UI) compiled binaries
  3. Hyper/rustls client configuration: Verify the HTTP client backend (hyper + rustls) is correctly wired for outbound connections
  4. Error propagation: The node:http request() returning undefined and net.connect previously returning "undefined" error both suggest the FFI error chain is broken — errors from Rust are not properly propagated to TypeScript

Environment Exclusion

The following external causes have been ruled out:

  • macOS Firewall: Disabled (socketfilterfw --getglobalstate → State = 0)
  • Binary sandbox/entitlements: Ad-hoc signed, no sandbox (codesign -dvvvflags=0x20002(adhoc,linker-signed), no runtime flag)
  • DNS issues: Direct IP connections also fail, ruling out DNS
  • TLS issues: Plain HTTP connections also fail, ruling out TLS
  • System network: child_process + curl works from the same binary, confirming host network is functional
  • Version-specific regression: Tested across 3 versions (0.5.511, 0.5.890, 0.5.891) — all exhibit identical failures, confirming this is a systemic issue, not a regression

Cross-Version Test Results

Perry Version Binary Size fetch axios net.connect node:http
0.5.511 5.4MB FAIL FAIL FAIL (NUL byte) FAIL (undefined)
0.5.890 7.3MB FAIL FAIL FAIL (NUL byte) FAIL (undefined)
0.5.891 7.3MB FAIL FAIL FAIL (NUL byte) FAIL (undefined)

All versions produce identical error messages, indicating the HTTP client stack has never worked on macOS arm64.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions