mirror of
https://github.com/aphrody-labs/opentui.git
synced 2026-10-13 21:08:53 +00:00
TypeScript used to own the renderable tree. Each frame walked it in JavaScript, read layout back from Yoga, and made one FFI call per draw, so frame cost grew with node and draw count. Native objects lived in global registries and crossed FFI as raw pointers, which made renderer teardown fragile. A Context-owned Scene now holds the retained tree, its Yoga nodes, and its paint state. Renderables stage property changes and flush them in one call per frame. Zig lays out, culls, and paints the built-in widgets, fills the hit grid, and hands the frame to Session for the terminal diff. A frame without paint hooks completes in one step call. Paint hooks run in one batch before painting and record into a byte stream that native replays in paint order, so painting never pauses for JavaScript and a hook cannot tear the frame. Every native resource sits behind a generational handle, so a stale handle fails with an error instead of touching freed memory, and destroying a Context releases everything it owns.
328 lines
14 KiB
TypeScript
328 lines
14 KiB
TypeScript
import { spawnSync } from "node:child_process"
|
|
import { createHash } from "node:crypto"
|
|
import { closeSync, mkdirSync, mkdtempSync, openSync, readFileSync, rmSync, writeFileSync } from "node:fs"
|
|
import { dirname, join, resolve } from "node:path"
|
|
import { fileURLToPath } from "node:url"
|
|
import { format } from "oxfmt"
|
|
import {
|
|
nativeAddressFields,
|
|
nativePointerPolicies,
|
|
type AddressFields,
|
|
type PointerPolicies,
|
|
} from "./native-abi-pointers.js"
|
|
import { variants } from "./variants.js"
|
|
|
|
const scriptRoot = dirname(fileURLToPath(import.meta.url))
|
|
const nativeRoot = resolve(scriptRoot, "../../native")
|
|
const outputPath = resolve(scriptRoot, "../src/native-abi.generated.ts")
|
|
const archNames: Record<string, string> = { x64: "x86_64", arm64: "aarch64" }
|
|
const osNames: Record<string, string> = { linux: "linux-musl", darwin: "macos", win32: "windows-gnu" }
|
|
|
|
interface Signature {
|
|
args: string[]
|
|
returns: string
|
|
}
|
|
|
|
export interface HeaderABI {
|
|
symbols: Record<string, Signature>
|
|
callbacks: Record<string, Signature>
|
|
layouts: Record<
|
|
string,
|
|
{
|
|
size: number
|
|
alignment: number
|
|
fields: Record<string, { offset: number; size: number; alignment: number; type: string }>
|
|
}
|
|
>
|
|
constants: Record<string, number>
|
|
}
|
|
|
|
function zig(args: string[], stdout: number | "pipe" = "pipe"): string {
|
|
const result = spawnSync("zig", args, {
|
|
cwd: nativeRoot,
|
|
encoding: "utf8",
|
|
maxBuffer: 16 * 1024 * 1024,
|
|
stdio: ["pipe", stdout, "pipe"],
|
|
timeout: 300_000,
|
|
})
|
|
if (result.error) throw result.error
|
|
if (result.status !== 0) throw new Error(`zig ${args[0]} failed:\n${result.stderr}`)
|
|
return result.stdout
|
|
}
|
|
|
|
// On macOS, Zig 0.16 translate-c copies its output to stdout with fcopyfile(). With a pipe or socket
|
|
// stdout, the final ftruncate() fails and Zig retries forever, so give it a regular file.
|
|
function translateC(header: string, target: string, output: string): void {
|
|
const fd = openSync(output, "w")
|
|
try {
|
|
zig(["translate-c", header, "-target", target], fd)
|
|
} finally {
|
|
closeSync(fd)
|
|
}
|
|
}
|
|
|
|
function cType(type: string, callbacks: Map<string, string>): string {
|
|
if (callbacks.has(type)) return callbacks.get(type)!
|
|
if (type.startsWith("*const ")) return `${cType(type.slice(7), callbacks)} const *`
|
|
if (type.startsWith("*")) return `${cType(type.slice(1), callbacks)} *`
|
|
const array = /^\[(\d+)\]([^[*].*)$/.exec(type)
|
|
if (array) return `${cType(array[2]!, callbacks)}[${array[1]}]`
|
|
if (/^[ui](8|16|32|64)$/.test(type)) return `${type[0] === "u" ? "u" : ""}int${type.slice(1)}_t`
|
|
if (type === "f32") return "float"
|
|
if (type === "f64") return "double"
|
|
if (type === "void" || /^ot_\w+$/.test(type)) return type
|
|
throw new Error(`Unsupported C ABI type: ${type}`)
|
|
}
|
|
|
|
export function compileHeader(options: { header?: string; allTargets?: boolean } = {}): HeaderABI {
|
|
const hostArch = archNames[process.arch]
|
|
const hostOS = osNames[process.platform]
|
|
if (!hostArch || !hostOS) throw new Error(`Unsupported ABI generator host: ${process.platform}-${process.arch}`)
|
|
mkdirSync(join(nativeRoot, ".zig-cache"), { recursive: true })
|
|
const temporary = mkdtempSync(join(nativeRoot, ".zig-cache/native-abi-"))
|
|
try {
|
|
const headerPath =
|
|
options.header === undefined ? join(nativeRoot, "include/opentui.h") : join(temporary, "opentui.h")
|
|
if (options.header !== undefined) writeFileSync(headerPath, options.header)
|
|
const translatedPath = join(temporary, "header.zig")
|
|
const target = `${hostArch}-${hostOS}`
|
|
translateC(headerPath, target, translatedPath)
|
|
const json = zig([
|
|
"run",
|
|
"-fstrip",
|
|
"-target",
|
|
target,
|
|
"--dep",
|
|
"context_abi_c",
|
|
`-Mroot=${join(scriptRoot, "native-abi.zig")}`,
|
|
"-target",
|
|
target,
|
|
`-Mcontext_abi_c=${translatedPath}`,
|
|
])
|
|
const abi: HeaderABI = JSON.parse(json)
|
|
// Translate-C 0.16 drops some callback conventions, including inline parameter types.
|
|
const callbacks = new Map<string, string>()
|
|
const declarations = ['#include "opentui.h"', "#include <stddef.h>"]
|
|
const prototype = (signature: Signature, name = "") => {
|
|
const args = signature.args.map((type) => cType(type, callbacks)).join(", ") || "void"
|
|
return `${cType(signature.returns, callbacks)} (*${name})(${args})`
|
|
}
|
|
for (const [index, [name, signature]] of Object.entries(abi.callbacks).entries()) {
|
|
const alias = `opentui_ffi_callback_${index}`
|
|
declarations.push(`typedef ${prototype(signature, alias)};`)
|
|
declarations.push(
|
|
`_Static_assert(__builtin_types_compatible_p(${name}, ${alias}), "Unsupported ABI calling convention or callback translation: ${name}");`,
|
|
)
|
|
callbacks.set(`callback(${signature.args.join(",")})->${signature.returns}`, alias)
|
|
}
|
|
for (const [name, signature] of Object.entries(abi.symbols)) {
|
|
declarations.push(
|
|
`_Static_assert(__builtin_types_compatible_p(__typeof__(&${name}), ${prototype(signature)}), "Unsupported ABI calling convention or function translation: ${name}");`,
|
|
)
|
|
}
|
|
// Translate-C can disagree with the C compiler, for example by ignoring #pragma pack.
|
|
for (const [name, record] of Object.entries(abi.layouts)) {
|
|
declarations.push(
|
|
`_Static_assert(sizeof(${name}) == ${record.size} && _Alignof(${name}) == ${record.alignment}, "C layout differs from Translate-C: ${name}");`,
|
|
)
|
|
for (const [field, info] of Object.entries(record.fields)) {
|
|
const member = `((${name} *)0)->${field}`
|
|
declarations.push(
|
|
`_Static_assert(offsetof(${name}, ${field}) == ${info.offset} && sizeof(${member}) == ${info.size} && __alignof__(${member}) == ${info.alignment} && __builtin_types_compatible_p(__typeof__(${member}), ${cType(info.type, callbacks)}), "C layout differs from Translate-C: ${name}.${field}");`,
|
|
)
|
|
}
|
|
}
|
|
for (const [name, value] of Object.entries(abi.constants)) {
|
|
declarations.push(`_Static_assert(${name} == ${value}, "C value differs from Translate-C: ${name}");`)
|
|
}
|
|
const callbacksPath = join(temporary, "prototypes.c")
|
|
writeFileSync(callbacksPath, declarations.join("\n"))
|
|
const checkPrototypes = (target: string) =>
|
|
zig([
|
|
"cc",
|
|
"-c",
|
|
"-std=c11",
|
|
"-Wall",
|
|
"-Wextra",
|
|
"-Werror",
|
|
"-I",
|
|
dirname(headerPath),
|
|
"-target",
|
|
target,
|
|
callbacksPath,
|
|
"-o",
|
|
join(temporary, "callbacks.o"),
|
|
])
|
|
checkPrototypes(target)
|
|
if (options.allTargets) {
|
|
writeFileSync(join(temporary, "expected.json"), json)
|
|
const verifyPath = join(temporary, "verify.zig")
|
|
writeFileSync(
|
|
verifyPath,
|
|
`const std = @import("std");
|
|
const abi = @import("abi");
|
|
comptime {
|
|
@setEvalBranchQuota(2 * abi.json.len);
|
|
if (!std.mem.eql(u8, abi.json, @embedFile("expected.json")))
|
|
@compileError("Checked ABI differs from the generated host ABI");
|
|
}
|
|
`,
|
|
)
|
|
for (const variant of variants) {
|
|
const arch = archNames[variant.arch]
|
|
const os =
|
|
variant.platform === "linux"
|
|
? `linux-${variant.abi ?? "gnu.2.17"}`
|
|
: variant.platform === "darwin"
|
|
? "macos.13.0"
|
|
: osNames[variant.platform]
|
|
if (!arch || !os) throw new Error(`Unsupported ABI target: ${JSON.stringify(variant)}`)
|
|
const crossTarget = `${arch}-${os}`
|
|
checkPrototypes(crossTarget)
|
|
translateC(headerPath, crossTarget, translatedPath)
|
|
zig([
|
|
"build-obj",
|
|
"-fno-emit-bin",
|
|
"-target",
|
|
crossTarget,
|
|
"--dep",
|
|
"abi",
|
|
`-Mroot=${verifyPath}`,
|
|
"--dep",
|
|
"context_abi_c",
|
|
"-target",
|
|
crossTarget,
|
|
`-Mabi=${join(scriptRoot, "native-abi.zig")}`,
|
|
"-target",
|
|
crossTarget,
|
|
`-Mcontext_abi_c=${translatedPath}`,
|
|
])
|
|
}
|
|
}
|
|
return abi
|
|
} finally {
|
|
rmSync(temporary, { recursive: true, force: true })
|
|
}
|
|
}
|
|
|
|
export function serializeNativeABIAudit(
|
|
abi: HeaderABI,
|
|
policies: PointerPolicies = nativePointerPolicies,
|
|
addressFields: AddressFields = nativeAddressFields,
|
|
): string {
|
|
return JSON.stringify({ abi, pointerPolicies: policies, addressFields }, null, 2) + "\n"
|
|
}
|
|
|
|
export async function generateNativeABI(
|
|
abi: HeaderABI,
|
|
policies: PointerPolicies = nativePointerPolicies,
|
|
addressFields: AddressFields = nativeAddressFields,
|
|
): Promise<string> {
|
|
const used = new Set<string>()
|
|
const signatures: Record<string, Record<string, Signature>> = { symbols: {}, callbacks: {} }
|
|
for (const group of ["symbols", "callbacks"] as const) {
|
|
for (const [name, signature] of Object.entries(abi[group])) {
|
|
const convert = (type: string, position: number | "returns"): string => {
|
|
const callback = type.startsWith("callback(")
|
|
if (!type.startsWith("*") && !callback) {
|
|
if (!["void", "u8", "i8", "u16", "i16", "u32", "i32", "u64", "i64", "f32", "f64"].includes(type)) {
|
|
throw new Error(`Unsupported FFI type: ${name} ${position}: ${type}`)
|
|
}
|
|
return type
|
|
}
|
|
const label = `${name} argument ${position}`
|
|
const policy = policies[name]?.[position]
|
|
if (!policy) throw new Error(`Missing pointer policy: ${label} (${type})`)
|
|
// AGENTS.md: transient non-null views use buffer; everything else needs ptr.
|
|
const transient = policy.nullable === "never" && policy.retention === "call" && policy.source === "view"
|
|
if ((policy.ffi === "buffer") !== transient) {
|
|
throw new Error(`Pointer policy must use ${transient ? "buffer" : "ptr"}: ${label}`)
|
|
}
|
|
if (callback !== (policy.source === "callback")) throw new Error(`Callback policy mismatch: ${label}`)
|
|
if (position === "returns" && policy.ffi !== "ptr") throw new Error(`Pointer returns must use ptr: ${label}`)
|
|
used.add(label)
|
|
return policy.ffi
|
|
}
|
|
signatures[group][name] = { args: signature.args.map(convert), returns: convert(signature.returns, "returns") }
|
|
}
|
|
}
|
|
for (const [name, positions] of Object.entries(policies)) {
|
|
for (const position of Object.keys(positions)) {
|
|
const label = `${name} argument ${position}`
|
|
if (!used.has(label)) throw new Error(`Unused pointer policy: ${label}`)
|
|
}
|
|
}
|
|
for (const [name, record] of Object.entries(abi.layouts)) {
|
|
for (const [field, info] of Object.entries(record.fields)) {
|
|
if (info.type.includes("*") || info.type.includes("callback(")) {
|
|
throw new Error(`ABI record pointer needs an explicit ownership policy: ${name}.${field}`)
|
|
}
|
|
}
|
|
}
|
|
for (const [name, fields] of Object.entries(addressFields)) {
|
|
for (const [field, policy] of Object.entries(fields)) {
|
|
if (abi.layouts[name]?.fields[field]?.type !== policy.type)
|
|
throw new Error(`Address field drift: ${name}.${field}`)
|
|
}
|
|
}
|
|
for (const [name, value] of Object.entries(abi.constants)) {
|
|
if (!Number.isSafeInteger(value)) throw new Error(`ABI constant needs bigint support: ${name}`)
|
|
}
|
|
// Audit the complete compiler ABI and ownership, including pointees erased by ptr/buffer.
|
|
const fingerprint = createHash("sha256")
|
|
.update(serializeNativeABIAudit(abi, policies, addressFields))
|
|
.digest("hex")
|
|
const exports = {
|
|
nativeSymbols: signatures.symbols,
|
|
nativeCallbacks: signatures.callbacks,
|
|
nativeLayouts: abi.layouts,
|
|
nativeConstants: abi.constants,
|
|
nativeStyleEnumMaxima: sceneStyleEnumMaxima(abi),
|
|
}
|
|
const source =
|
|
"// Generated from packages/native/include/opentui.h and scripts/native-abi-pointers.ts.\n" +
|
|
"// Run `bun run generate:abi` in packages/core. Do not edit.\n" +
|
|
"// Inspect audit input: bun scripts/native-abi.ts --audit\n" +
|
|
`// ABI audit SHA-256: ${fingerprint}\n\n` +
|
|
Object.entries(exports)
|
|
.map(([name, value]) => `export const ${name} = ${JSON.stringify(value)} as const\n`)
|
|
.join("\n")
|
|
const formatted = await format(outputPath, source, { semi: false, printWidth: 120 })
|
|
if (formatted.errors.length) throw new Error(`Cannot format generated ABI: ${JSON.stringify(formatted.errors)}`)
|
|
return formatted.code
|
|
}
|
|
|
|
export function sceneStyleEnumMaxima(abi: HeaderABI): number[] {
|
|
const entries = Object.entries(abi.constants)
|
|
.filter(([name]) => name.startsWith("OT_STYLE_ENUM_") && !name.endsWith("_MAX"))
|
|
.sort((a, b) => a[1] - b[1])
|
|
return entries.map(([name, kind], index) => {
|
|
const maximum = abi.constants[`${name}_MAX`]
|
|
if (kind !== index || maximum === undefined) throw new Error(`Missing style enum constraint: ${name}`)
|
|
return maximum
|
|
})
|
|
}
|
|
|
|
export function verifyNativeABI(generated: string): void {
|
|
if (readFileSync(outputPath, "utf8") !== generated) {
|
|
throw new Error(
|
|
"native-abi.generated.ts is stale; run bun run generate:abi in packages/core.\n" +
|
|
"Review ../native/include/opentui.h and scripts/native-abi-pointers.ts changes; " +
|
|
"inspect compiler ABI and ownership with bun scripts/native-abi.ts --audit.",
|
|
)
|
|
}
|
|
}
|
|
|
|
if (import.meta.main) {
|
|
const args = process.argv.slice(2)
|
|
if (args.some((arg) => arg !== "--check" && arg !== "--all-targets" && arg !== "--audit"))
|
|
throw new Error("Expected --check, --all-targets and/or --audit")
|
|
const abi = compileHeader({ allTargets: args.includes("--all-targets") })
|
|
const generated = await generateNativeABI(abi)
|
|
if (args.includes("--check")) verifyNativeABI(generated)
|
|
else if (!args.includes("--audit")) writeFileSync(outputPath, generated)
|
|
if (args.includes("--audit")) process.stdout.write(serializeNativeABIAudit(abi))
|
|
else
|
|
console.log(`Checked ABI: ${Object.keys(abi.symbols).length} symbols, ${Object.keys(abi.layouts).length} records`)
|
|
}
|