fix(gpui): stabilize macOS packaging, connections and appearance

Release terminal WebSocket clients on the JS thread so daemon retries do not exhaust descriptors or join their own IO thread. Preserve Cargo symbols and seal ad-hoc macOS bundles with a library-loading policy that works with hardened runtime.

Use rounded border decoration without changing Fabric child layout, and route desktop hover through pointer events and Pressable render props. Include native regressions and reproducible package verification.
This commit is contained in:
Mohamed Boudra
2026-11-08 04:40:38 +01:00
parent 6d247815fd
commit 291c8ad1a8
25 changed files with 714 additions and 88 deletions
+84 -12
View File
@@ -1,9 +1,9 @@
# Experimental GPUI client
Run Paseo's existing React Native app in a native GPUI window on Linux. This is
Run Paseo's existing React Native app in a native GPUI window on Linux or macOS. This is
a community starting point: the real workspace UI runs, but terminal/diff
renderers and several native services still need porting. The normal desktop
build remains Electron. No daemon changes or release installers are included.
build remains Electron. The native app connects to a separately running daemon.
## Run it
@@ -34,6 +34,51 @@ not wired into this launcher. `build:gpui` builds without opening a window.
`.artifacts/gpui/`. Bundle validation checks that the real routes are present and
that no `react-native-webview` modules were included.
## Build a macOS test DMG
On an Apple Silicon Mac, install the dependencies above plus Xcode, CMake, and
Ninja. From the repository root, after `npm run gpui:install`:
```sh
gpui_repo_root="$PWD"
cd packages/app/gpui
MACOSX_DEPLOYMENT_TARGET=15.0 NODE_OPTIONS=--max-old-space-size=6144 CARGO_BUILD_JOBS=4 \
CARGO_TARGET_DIR="$gpui_repo_root/.artifacts/gpui/macos/target" \
PASEO_RENDERER=gpui EXPO_PUBLIC_LOCAL_DAEMON=localhost:6767 \
node node_modules/react-native/cli.js package-desktop \
--target app,dmg --out "$gpui_repo_root/.artifacts/gpui/macos/dist"
```
Use the React Native CLI entry point shown here; the standalone GPUI CLI rejects
`package-desktop`. Keep one absolute `CARGO_TARGET_DIR` for both compilation and
packaging because their default target directories differ. Metro needs more than
Node's default 2 GiB heap for this app.
Keep `MACOSX_DEPLOYMENT_TARGET` aligned with `desktop/desktop.json` when building
from source; it controls both Rust and CMake's macOS API availability.
The DMG requires macOS 15 or later, matching the bundled Hermes/JSI libraries.
It contains `Paseo GPUI.app` with ad-hoc signing and the library-validation
exception required for loading ad-hoc dylibs under hardened runtime. It is not
notarized or signed with Developer ID. Copy it out of the mounted DMG before testing. Its daemon
address is fixed at bundle time, and the installer does not include a daemon.
Native terminals and diff rendering remain unsupported.
Check the installed bundle before sharing it:
```sh
node packages/app/gpui/verify-macos-package.mjs "/Applications/Paseo GPUI.app"
```
This checks the sealed signature, ad-hoc library-loading entitlement, and the
minimum macOS version of each bundled dylib. A successful launch on a Mac with
SIP disabled does not test Team ID library validation; verify the signing policy
and use a Mac with SIP enabled for distribution launch acceptance.
Namespace GUI testing needs an active remote desktop session; SSH alone can leave
GPUI waiting for a display. Use Namespace's `devbox desktop` commands before
launching the app. Keep Cargo's generated debug-symbol symlink intact when
rebuilding; the packager copies its contents alongside the installer.
## Approach and renderer choice
```text
@@ -70,14 +115,14 @@ controlled CPU, memory, startup, or frame-time results against these alternative
## What belongs in the change
| Area | Why it is here |
| ---------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `vendor/react-native-gpui/` | Editable, pinned runtime source. This accounts for roughly 130,000 lines of the diff. GitHub marks it as vendored; inspect [UPSTREAM](../../../vendor/react-native-gpui/UPSTREAM) for provenance and the two local patches. |
| npm and Cargo lockfiles | Reproduce the experiment's dependencies. Roughly 15,000 generated lines, collapsed on GitHub. |
| `desktop/`, `index.js`, `routes.js`, Metro/Babel config, root launcher | Register the full app, select the native runtime, compile native services, and bundle the actual route graph. The explicit route context prevents an apparently successful build with no app routes. |
| `adapters/` | Desktop implementations of the app-used APIs, plus explicit unavailable-capability implementations for mobile features. About 700 lines; these are limited ports, not complete replacements for Expo/Unistyles. |
| Shared app edits | One capability gate for alpha-mask shimmer. GPUI shows the logo and labels without that effect. Existing web/iOS/Android paths keep their behavior. |
| Native tests and this guide | Make the working slice reproducible and give contributors bounded follow-up work. |
| Area | Why it is here |
| ---------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `vendor/react-native-gpui/` | Editable, pinned runtime source. This accounts for roughly 130,000 lines of the diff. GitHub marks it as vendored; inspect [UPSTREAM](../../../vendor/react-native-gpui/UPSTREAM) for provenance and the local patches. |
| npm and Cargo lockfiles | Reproduce the experiment's dependencies. Roughly 15,000 generated lines, collapsed on GitHub. |
| `desktop/`, `index.js`, `routes.js`, Metro/Babel config, root launcher | Register the full app, select the native runtime, compile native services, and bundle the actual route graph. The explicit route context prevents an apparently successful build with no app routes. |
| `adapters/` | Desktop implementations of the app-used APIs, plus explicit unavailable-capability implementations for mobile features. About 700 lines; these are limited ports, not complete replacements for Expo/Unistyles. |
| Shared app edits | One capability gate for alpha-mask shimmer. GPUI shows the logo and labels without that effect. Existing web/iOS/Android paths keep their behavior. |
| Native tests and this guide | Make the working slice reproducible and give contributors bounded follow-up work. |
Upstream examples, its tester/testing package, website, unused fonts/vector-icon
ports, starter navigation dependencies, sample icon, and unused nested Cargo config
@@ -107,11 +152,12 @@ source-built distribution, and release packaging has not been verified.
## Working slice and limits
Verified on Linux x86_64: all 24 app routes in the bundle, connection to the dev
Verified on Linux x86_64 and macOS arm64: all 24 app routes in the bundle, connection to the
daemon, the real project/workspace sidebar, opening an existing workspace,
existing message rendering, and native composer typing/deletion. The native
regression test restores the draft and does not send a message. Rust tests cover
SHA-256/random-byte bounds and real SQLite parameter binding/transaction rollback.
The macOS build also passed draft restoration after restarting the native process.
The following are not feature-parity claims:
@@ -126,7 +172,7 @@ The following are not feature-parity claims:
| Terminals | The mobile view expects an Expo `PaseoTerminal` component. No GPUI implementation yet. |
| Diffs | The mobile surface uses React Native Skia. No GPUI drawing backend yet. |
| Plugins | The existing loader evaluates JS and injects host React/RN. No end-to-end plugin verification on GPUI yet. |
| Other platforms | The upstream runtime targets macOS/Windows too; this Paseo integration has only been exercised on Linux. |
| Other platforms | Linux x86_64 and macOS arm64 have been exercised. Windows remains unverified. |
The initial stripped development executable was about 68 MiB, with about 10 MiB
of Hermes/JSI libraries and a 21 MiB JS bundle, approximately 100 MiB uncompressed.
@@ -200,3 +246,29 @@ npx vitest run packages/app/gpui/native.test.ts --bail=1
Without a driver URL the test is skipped. Put diagnostic logs/captures in
`.artifacts/gpui/`; do not commit them or publish private workspace contents as QA.
For border and hover regressions, bundle the isolated scene from `packages/app/gpui`:
```sh
PASEO_RENDERER=gpui node node_modules/react-native/cli.js bundle --platform desktop \
--entry-file appearance-regression.fixture.js --config desktop/metro.config.js \
--bundle-output ../../../.artifacts/gpui/appearance.bundle --dev false --max-workers 1
```
Launch the driver-enabled executable with `RN_GPUI_BUNDLE` pointing at that bundle.
Run `native.test.ts` from the root with the driver URL/token above and
`PASEO_GPUI_TEST_SCENE=appearance`. It checks the rendered border pixels,
Pressable hover callbacks and render props, and hover across nested controls.
No daemon is needed for this scene.
On macOS, test WebSocket cleanup against a compiled native executable:
```sh
PASEO_GPUI_TEST_BINARY="/absolute/path/to/react-native-gpui-paseo-gpui" \
npx vitest run packages/app/gpui/websocket.native.test.ts --bail=1
```
This opens an isolated native window and uses a loopback peer for 128 rejected
handshakes, 128 server closes, and 128 client closes under a 256-descriptor limit.
It needs an active display and the GPUI npm dependencies, but no Paseo daemon.
Without the executable path it is skipped. Its bundle and logs stay in `.artifacts/gpui/`.
@@ -0,0 +1,5 @@
export function setDefaultTimeZone(timeZone) {
// macOS uses the IANA alias GMT. FormatJS's timezone data omits that alias;
// normalize it at the polyfill boundary while preserving other system zones.
Intl.DateTimeFormat.__setDefaultTimeZone(timeZone.toUpperCase() === "GMT" ? "UTC" : timeZone);
}
@@ -0,0 +1,23 @@
/* oxlint-disable import/no-unassigned-import -- Exercise the actual GPUI Intl implementation. */
import "@formatjs/intl-datetimeformat/polyfill-force.js";
import "@formatjs/intl-datetimeformat/locale-data/en.js";
import "@formatjs/intl-datetimeformat/add-all-tz.js";
import { afterEach, expect, test } from "vitest";
import { setDefaultTimeZone } from "./intl-timezone";
afterEach(() => setDefaultTimeZone("UTC"));
test.each([
["GMT", "UTC"],
["gmt", "UTC"],
["UTC", "UTC"],
["Europe/Berlin", "Europe/Berlin"],
["US/Pacific", "America/Los_Angeles"],
])("initializes the system timezone %s as %s", (systemZone, expectedZone) => {
setDefaultTimeZone(systemZone);
expect(new Intl.DateTimeFormat("en").resolvedOptions().timeZone).toBe(expectedZone);
});
test("does not silently replace an invalid system timezone", () => {
expect(() => setDefaultTimeZone("Invalid/Zone")).toThrow(RangeError);
});
+2 -1
View File
@@ -13,4 +13,5 @@ import "@formatjs/intl-displaynames/polyfill-force.js";
import "@formatjs/intl-displaynames/locale-data/en.js";
import "@formatjs/intl-segmenter/polyfill-force.js";
import Desktop from "../desktop/src/NativePaseoDesktop";
Intl.DateTimeFormat.__setDefaultTimeZone(Desktop.timezone());
import { setDefaultTimeZone } from "./intl-timezone";
setDefaultTimeZone(Desktop.timezone());
@@ -0,0 +1,54 @@
/* oxlint-disable react-perf/jsx-no-new-function-as-prop, react-perf/jsx-no-new-object-as-prop -- Minimal native regression scene. */
import React, { useState } from "react";
import { AppRegistry, Pressable, Text, View } from "react-native";
function AppearanceRegression() {
const [buttonHovered, setHovered] = useState(false);
const [entered, setEntered] = useState(false);
return (
<View style={{ flex: 1, backgroundColor: "white", padding: 40, gap: 24 }}>
<View
testID="rounded-border"
style={{
width: 200,
height: 100,
borderRadius: 24,
borderWidth: 4,
borderColor: "#ff0000",
backgroundColor: "#0000ff",
}}
/>
<Pressable
testID="hover-button"
onHoverIn={() => setHovered(true)}
onHoverOut={() => setHovered(false)}
style={{ width: 200, height: 60, backgroundColor: buttonHovered ? "#00ff00" : "#0000ff" }}
>
<Text>{buttonHovered ? "hover active" : "hover inactive"}</Text>
</Pressable>
<Pressable
testID="hover-render-prop"
style={({ hovered }) => ({
width: 200,
height: 60,
backgroundColor: hovered ? "#00ff00" : "#0000ff",
})}
>
{({ hovered }) => <Text>{hovered ? "render hovered" : "render idle"}</Text>}
</Pressable>
<View
testID="hover-row"
onPointerEnter={() => setEntered(true)}
onPointerLeave={() => setEntered(false)}
style={{ width: 200, height: 60, backgroundColor: entered ? "#00ff00" : "#0000ff" }}
>
<Pressable testID="nested-button" style={{ width: 100, height: 60 }}>
<Text>{entered ? "row active" : "row inactive"}</Text>
</Pressable>
</View>
<View testID="outside-hover" style={{ width: 200, height: 60 }} />
</View>
);
}
AppRegistry.registerComponent("Paseo", () => AppearanceRegression);
+2 -2
View File
@@ -21,8 +21,8 @@ opt-level = 0
[profile.release]
debug = "line-tables-only"
# Cargo strips inside the linker, before dsymutil, so package-desktop writes the
# dSYM and then strips. Full LTO: 2 MB smaller for 1.4x the build time.
# Cargo strips inside the linker, before dsymutil, so package-desktop preserves the
# Cargo dSYM and then strips. Full LTO: 2 MB smaller for 1.4x the build time.
strip = "none"
split-debuginfo = "packed"
lto = "fat"
+4 -1
View File
@@ -1 +1,4 @@
{ "identifier": "sh.paseo.gpui" }
{
"identifier": "sh.paseo.gpui",
"bundle": { "macos": { "minimumSystemVersion": "15.0" } }
}
+97 -3
View File
@@ -1,9 +1,13 @@
import { afterAll, beforeAll, expect, test } from "vitest";
import sharp from "sharp";
import { mkdirSync, writeFileSync } from "node:fs";
import path from "node:path";
// Runs against the actual Paseo bundle in the compiled GPUI app. Start with
// RN_GPUI_TEST_DRIVER=1 and pass its loopback URL and token to this test.
const endpoint = process.env.PASEO_GPUI_TEST_URL;
const token = process.env.PASEO_GPUI_TEST_TOKEN;
const appearanceFixture = process.env.PASEO_GPUI_TEST_SCENE === "appearance";
let socket: WebSocket;
let nextId = 0;
interface Node {
@@ -62,7 +66,7 @@ beforeAll(async () => {
});
afterAll(() => socket?.close());
test.skipIf(!endpoint)(
test.skipIf(!endpoint || appearanceFixture)(
"real Paseo portal passes workspace clicks and the native composer edits text",
async () => {
const workspace = (await nodes()).find((node) =>
@@ -85,7 +89,7 @@ test.skipIf(!endpoint)(
const original = (await request<{ text: string }>("getProps", { tag: input.tag })).text;
try {
await request("click", { tag: input.tag });
await request("press", { key: "ctrl-a" });
await request("press", { key: "secondary-a" });
await request("press", { key: "backspace" });
expect(
await request("type", { tag: input.tag, text: "GPUI native input regression" }),
@@ -94,10 +98,100 @@ test.skipIf(!endpoint)(
.poll(async () => (await request<{ text: string }>("getProps", { tag: input.tag })).text)
.toBe("GPUI native input regression");
} finally {
await request("press", { key: "ctrl-a" });
await request("press", { key: "secondary-a" });
await request("press", { key: "backspace" });
if (original) await request("type", { tag: input.tag, text: original });
await expect
.poll(async () => (await request<{ text: string }>("getProps", { tag: input.tag })).text)
.toBe(original);
}
},
45_000,
);
async function nodeWithId(testID: string) {
await expect.poll(async () => (await nodes()).some((node) => node.testID === testID)).toBe(true);
return (await nodes()).find((node) => node.testID === testID)!;
}
async function expectBackground(node: Node, color: number) {
await expect
.poll(
async () =>
(await request<{ backgroundColor: number }>("getProps", { tag: node.tag })).backgroundColor,
)
.toBe(color);
}
test.skipIf(!endpoint || !appearanceFixture)(
"Pressable render props receive hover state",
async () => {
const button = await nodeWithId("hover-render-prop");
const outside = await nodeWithId("outside-hover");
await request("hover", { tag: button.tag });
await expectBackground(button, 0xff00ff00);
await expect
.poll(async () => (await nodes()).some((node) => node.text === "render hovered"))
.toBe(true);
await request("hover", { tag: outside.tag });
await expectBackground(button, 0xff0000ff);
await expect
.poll(async () => (await nodes()).some((node) => node.text === "render idle"))
.toBe(true);
},
);
test.skipIf(!endpoint || !appearanceFixture)(
"rounded borders follow the background curve",
async () => {
const node = await nodeWithId("rounded-border");
const { png } = await request<{ png: string }>("screenshot", { tag: node.tag });
const bytes = Buffer.from(png, "base64");
const directory = path.resolve(import.meta.dirname, "../../../.artifacts/gpui/appearance");
mkdirSync(directory, { recursive: true });
writeFileSync(path.join(directory, "rounded-border.png"), bytes);
const { data, info } = await sharp(bytes)
.removeAlpha()
.raw()
.toBuffer({ resolveWithObject: true });
const pixel = (x: number, y: number) => {
const offset =
(Math.floor((y * info.height) / 100) * info.width + Math.floor((x * info.width) / 200)) *
info.channels;
return [...data.subarray(offset, offset + 3)];
};
// Outside the arc must be clear; the diagonal must have an actual curved
// border, not four straight rules clipped to a rounded rectangle.
expect(pixel(1, 1)).toEqual([255, 255, 255]);
expect(pixel(8, 8)).toEqual([255, 0, 0]);
expect(pixel(15, 15)).toEqual([0, 0, 255]);
expect(pixel(100, 1)).toEqual([255, 0, 0]);
expect(pixel(198, 98)).toEqual([255, 255, 255]);
},
);
test.skipIf(!endpoint || !appearanceFixture)(
"Pressable hover highlights enter and leave",
async () => {
const button = await nodeWithId("hover-button");
const outside = await nodeWithId("outside-hover");
await request("hover", { tag: button.tag });
await expectBackground(button, 0xff00ff00);
await request("hover", { tag: outside.tag });
await expectBackground(button, 0xff0000ff);
},
);
test.skipIf(!endpoint || !appearanceFixture)(
"row hover survives entering a nested Pressable",
async () => {
const row = await nodeWithId("hover-row");
const child = await nodeWithId("nested-button");
const outside = await nodeWithId("outside-hover");
await request("hover", { tag: row.tag });
await request("hover", { tag: child.tag });
await expectBackground(row, 0xff00ff00);
await request("hover", { tag: outside.tag });
await expectBackground(row, 0xff0000ff);
},
);
@@ -0,0 +1,61 @@
import assert from "node:assert/strict";
import { execFileSync, spawnSync } from "node:child_process";
import { readdirSync } from "node:fs";
import path from "node:path";
assert.equal(process.platform, "darwin", "Run package verification on macOS");
assert.ok(process.argv[2], "Pass the packaged .app path");
const app = path.resolve(process.argv[2]);
const contents = path.join(app, "Contents");
const info = JSON.parse(
execFileSync("plutil", ["-convert", "json", "-o", "-", path.join(contents, "Info.plist")]),
);
const executable = path.join(contents, "MacOS", info.CFBundleExecutable);
const frameworks = path.join(contents, "Frameworks");
const libraries = readdirSync(frameworks, { recursive: true })
.filter((name) => name.endsWith(".dylib"))
.map((name) => path.join(frameworks, name));
execFileSync("codesign", ["--verify", "--deep", "--strict", app], { stdio: "inherit" });
const signature = spawnSync("codesign", ["-dvvv", executable], { encoding: "utf8" });
assert.equal(signature.status, 0, signature.stderr);
if (
signature.stderr.includes("Signature=adhoc") &&
/flags=[^\n]*\bruntime\b/.test(signature.stderr) &&
libraries.length > 0
) {
const entitlements = execFileSync("codesign", ["-d", "--entitlements", "-", "--xml", executable]);
assert.ok(entitlements.length > 0, "Ad-hoc app has no library-validation entitlement");
const values = JSON.parse(
execFileSync("plutil", ["-convert", "json", "-o", "-", "--", "-"], {
input: entitlements,
}),
);
assert.equal(
values["com.apple.security.cs.disable-library-validation"],
true,
"Ad-hoc app cannot satisfy Team ID library validation on a Mac with SIP enabled",
);
}
function version(value) {
const parts = value.split(".").map(Number);
return (parts[0] * 256 + (parts[1] ?? 0)) * 256 + (parts[2] ?? 0);
}
assert.ok(info.LSMinimumSystemVersion, "The app must declare its minimum macOS version");
for (const binary of [executable, ...libraries]) {
const build = execFileSync("xcrun", ["vtool", "-show-build", binary], { encoding: "utf8" });
const minima = [...build.matchAll(/\bminos\s+(\d+(?:\.\d+)*)/g)].map((match) => match[1]);
assert.ok(minima.length > 0, `No macOS deployment target found in ${binary}`);
for (const minimum of minima) {
assert.ok(
version(minimum) <= version(info.LSMinimumSystemVersion),
`${path.basename(binary)} requires macOS ${minimum}, above the app's ${info.LSMinimumSystemVersion}`,
);
}
}
console.log(
`PASS: bundle signatures, ad-hoc library loading policy, and macOS deployment targets: ${app}`,
);
@@ -0,0 +1,45 @@
import { AppRegistry } from "react-native";
AppRegistry.registerComponent("Paseo", () => () => null);
// The native test supplies a loopback server before loading this bundle.
const base = globalThis.__webSocketRegressionUrl;
async function connect(path, expectedCode, localClose = false) {
return new Promise((resolve, reject) => {
const socket = new WebSocket(`${base}/${path}`);
const timeout = setTimeout(() => reject(new Error(`${path}: timed out`)), 5000);
let echoed = false;
socket.addEventListener("open", () => socket.send("native websocket regression"));
socket.addEventListener("message", (event) => {
echoed = event.data === "native websocket regression";
if (localClose) socket.close(1000, "client done");
});
socket.addEventListener("close", (event) => {
clearTimeout(timeout);
if (event.code !== expectedCode || (expectedCode === 1000 && !echoed)) {
reject(new Error(`${path}: code=${event.code}, echoed=${echoed}`));
} else {
resolve();
}
});
});
}
async function run() {
// Keep retrying through the real RN WebSocket API. Before the fix, native
// clients survived terminal events and exhausted the Mac's descriptor limit.
for (const [path, code, localClose] of [
["reject", 1006, false],
["remote-close", 1000, false],
["local-close", 1000, true],
]) {
for (let attempt = 0; attempt < 128; attempt++) {
await connect(path, code, localClose);
}
console.log(`WEBSOCKET_REGRESSION_PASS ${path}`);
}
console.log("WEBSOCKET_REGRESSION_COMPLETE");
}
setTimeout(() => {
run().catch((error) => console.error(`WEBSOCKET_REGRESSION_FAIL ${error.message}`));
}, 0);
+117
View File
@@ -0,0 +1,117 @@
import { spawn, execFileSync } from "node:child_process";
import { createServer } from "node:http";
import { mkdtempSync, readFileSync, writeFileSync, mkdirSync } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { Server as WebSocketServer } from "ws";
import { expect, test } from "vitest";
const binary = process.env.PASEO_GPUI_TEST_BINARY;
const project = path.dirname(fileURLToPath(import.meta.url));
const root = path.resolve(project, "../../..");
function createPeer() {
const server = createServer();
const sockets = new WebSocketServer({ noServer: true });
server.on("upgrade", (request, socket, head) => {
if (request.url === "/reject") {
socket.end("HTTP/1.1 400 Bad Request\r\nContent-Length: 0\r\nConnection: close\r\n\r\n");
return;
}
sockets.handleUpgrade(request, socket, head, (websocket) => {
websocket.on("message", (message) => {
websocket.send(message.toString());
if (request.url === "/remote-close") websocket.close(1000, "server done");
});
});
});
return { server, sockets };
}
test.skipIf(!binary)(
"native WebSocket retries and peer closes release their resources",
async () => {
mkdirSync(path.join(root, ".artifacts/gpui"), { recursive: true });
const artifacts = mkdtempSync(path.join(root, ".artifacts/gpui/websocket-"));
const bundle = path.join(artifacts, "regression.bundle");
execFileSync(
process.execPath,
[
"node_modules/react-native/cli.js",
"bundle",
"--platform",
"desktop",
"--entry-file",
"websocket-regression.fixture.js",
"--config",
"desktop/metro.config.js",
"--bundle-output",
bundle,
"--dev",
"false",
"--max-workers",
"1",
],
{ cwd: project, env: { ...process.env, PASEO_RENDERER: "gpui" }, timeout: 120_000 },
);
const { server, sockets } = createPeer();
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
const address = server.address();
if (!address || typeof address === "string") throw new Error("Missing server address");
writeFileSync(
bundle,
`globalThis.__webSocketRegressionUrl = "ws://127.0.0.1:${address.port}";\n${readFileSync(bundle, "utf8")}`,
);
// Match Finder's descriptor limit even when the test shell has raised it.
const app = spawn("/bin/sh", ["-c", 'ulimit -n 256; exec "$@"', "gpui-test", binary!], {
env: {
...process.env,
RN_GPUI_BUNDLE: bundle,
RN_GPUI_APP_ID: "sh.paseo.gpui.websocket-test",
XDG_DATA_HOME: path.join(artifacts, "data"),
},
stdio: ["ignore", "pipe", "pipe"],
});
let output = "";
try {
await new Promise<void>((resolve, reject) => {
const timeout = setTimeout(
() => reject(new Error(`Native retry test timed out:\n${output}`)),
60_000,
);
const collect = (chunk: Buffer) => {
output += chunk.toString();
if (output.includes("WEBSOCKET_REGRESSION_COMPLETE")) {
clearTimeout(timeout);
resolve();
} else if (output.includes("WEBSOCKET_REGRESSION_FAIL")) {
clearTimeout(timeout);
reject(new Error(output));
}
};
app.stdout.on("data", collect);
app.stderr.on("data", collect);
app.once("error", (error) => {
clearTimeout(timeout);
reject(error);
});
app.once("exit", (code, signal) => {
clearTimeout(timeout);
reject(new Error(`Native process exited (${code}, ${signal}):\n${output}`));
});
});
expect(app.exitCode).toBeNull();
expect(output).toContain("WEBSOCKET_REGRESSION_PASS reject");
expect(output).toContain("WEBSOCKET_REGRESSION_PASS remote-close");
expect(output).toContain("WEBSOCKET_REGRESSION_PASS local-close");
} finally {
writeFileSync(path.join(artifacts, "native.log"), output);
app.kill("SIGTERM");
for (const client of sockets.clients) client.terminate();
sockets.close();
server.close();
}
},
180_000,
);