Rebuild the koffi out-parameter marshalling on probed behaviour, not a guess
A `node -e` probe against the real bridge (loaded through koffi 3.3.0 on
Yggdrasil) showed the `_Out_` prototype marker does not auto-allocate storage
or reshape the return value: `openFn(null, null)` returned the plain scalar
`-1`, because the null "out" pointer is itself a bad call under the header's
own contract, and koffi never populated anything because there was nothing to
write into. `openFn(null, koffi.alloc("void *", 1))` returns the plain
`int32_t` status and the pointer decodes correctly with
`koffi.decode(buf, "void *")`, as a `bigint`.
Every out parameter is now `koffi.alloc(type, 1)` before the call and
`koffi.decode` after, inside `loadBindings`; the rest of the file is
unaffected, since it already treated ids as `bigint`. The runtime handle gets
its own `CultMeshQuicNativeHandle` alias (`bigint`) so its type is named
rather than smuggled through as `unknown`.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
parent
148bd69c06
commit
7b8b948d1d
1 changed files with 128 additions and 93 deletions
|
|
@ -16,6 +16,17 @@
|
|||
// (the committed tree a later cut lands; `__dirname` is `dist/` at runtime, so
|
||||
// this path holds in the repo, an `npm pack` tarball and a pnpm `file:` copy
|
||||
// alike). No `node_modules` lookup, no platform package, no registry.
|
||||
//
|
||||
// koffi's out-parameter marker (`_Out_`) does not auto-allocate storage or
|
||||
// reshape the return value the way its name suggests: probed on the pinned
|
||||
// 3.3.0 build (`node -e`, loading the real bridge), a function declared with
|
||||
// `_Out_ void **out_runtime` still demands a real buffer at that call-site
|
||||
// position (a bare `null` is read by the bridge as a null out parameter and
|
||||
// refused with -1) and its return value is the plain scalar `int32_t`, not an
|
||||
// object. Every out parameter here is therefore `koffi.alloc(type, 1)`,
|
||||
// decoded with `koffi.decode(buffer, type)` after the call. A `void *` value
|
||||
// — the runtime handle, and every listener/connection/stream id, since ids
|
||||
// share the 64-bit width — decodes as a JavaScript `bigint`.
|
||||
|
||||
import { existsSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
|
@ -53,16 +64,8 @@ export const CULTMESH_QUIC_RESULT_BAD_ARGUMENT = -2;
|
|||
|
||||
type NativeEventListener = (event: CultMeshQuicNativeEvent) => void;
|
||||
|
||||
interface KoffiOutResult<T> {
|
||||
readonly result: number;
|
||||
readonly out_runtime?: unknown;
|
||||
readonly out_listener_id?: bigint;
|
||||
readonly out_bound_port?: number;
|
||||
readonly out_connection_id?: bigint;
|
||||
readonly out_stream_id?: bigint;
|
||||
readonly out_event?: T;
|
||||
readonly out_required?: number;
|
||||
}
|
||||
/** The opaque native runtime handle: a `void *`, decoded by koffi as a `bigint`. */
|
||||
export type CultMeshQuicNativeHandle = bigint;
|
||||
|
||||
interface NativeEventStruct {
|
||||
type: number;
|
||||
|
|
@ -76,37 +79,45 @@ interface NativeEventStruct {
|
|||
}
|
||||
|
||||
interface NativeBindings {
|
||||
runtimeOpen(appName: string | null): KoffiOutResult<never>;
|
||||
runtimeClose(runtime: unknown): void;
|
||||
runtimeOpen(appName: string | null): { status: number; runtime: CultMeshQuicNativeHandle | null };
|
||||
runtimeClose(runtime: CultMeshQuicNativeHandle): void;
|
||||
listenerOpen(
|
||||
runtime: unknown,
|
||||
runtime: CultMeshQuicNativeHandle,
|
||||
host: string | null,
|
||||
port: number,
|
||||
pkcs12: Uint8Array,
|
||||
pkcs12Length: number,
|
||||
password: string | null,
|
||||
): KoffiOutResult<never>;
|
||||
listenerClose(runtime: unknown, listenerId: bigint): void;
|
||||
connectionOpen(runtime: unknown, host: string, port: number): KoffiOutResult<never>;
|
||||
connectionCertificateComplete(runtime: unknown, connectionId: bigint, accept: number): number;
|
||||
connectionShutdown(runtime: unknown, connectionId: bigint, code: bigint): void;
|
||||
streamOpen(runtime: unknown, connectionId: bigint, kind: number): KoffiOutResult<never>;
|
||||
): { status: number; listenerId: bigint; boundPort: number };
|
||||
listenerClose(runtime: CultMeshQuicNativeHandle, listenerId: bigint): void;
|
||||
connectionOpen(
|
||||
runtime: CultMeshQuicNativeHandle,
|
||||
host: string,
|
||||
port: number,
|
||||
): { status: number; connectionId: bigint };
|
||||
connectionCertificateComplete(runtime: CultMeshQuicNativeHandle, connectionId: bigint, accept: number): number;
|
||||
connectionShutdown(runtime: CultMeshQuicNativeHandle, connectionId: bigint, code: bigint): void;
|
||||
streamOpen(
|
||||
runtime: CultMeshQuicNativeHandle,
|
||||
connectionId: bigint,
|
||||
kind: number,
|
||||
): { status: number; streamId: bigint };
|
||||
streamSendFrame(
|
||||
runtime: unknown,
|
||||
runtime: CultMeshQuicNativeHandle,
|
||||
streamId: bigint,
|
||||
encodedFrame: Uint8Array,
|
||||
length: number,
|
||||
fin: number,
|
||||
): number;
|
||||
streamShutdown(runtime: unknown, streamId: bigint, code: bigint): void;
|
||||
streamShutdown(runtime: CultMeshQuicNativeHandle, streamId: bigint, code: bigint): void;
|
||||
nextEventAsync(
|
||||
runtime: unknown,
|
||||
runtime: CultMeshQuicNativeHandle,
|
||||
timeoutMs: number,
|
||||
payload: Uint8Array | null,
|
||||
payloadCapacity: number,
|
||||
): Promise<KoffiOutResult<NativeEventStruct>>;
|
||||
lastError(runtime: unknown, destination: Uint8Array, capacity: number): number;
|
||||
lastStatus(runtime: unknown): number;
|
||||
): Promise<{ status: number; event: NativeEventStruct | null; required: number }>;
|
||||
lastError(runtime: CultMeshQuicNativeHandle, destination: Uint8Array, capacity: number): number;
|
||||
lastStatus(runtime: CultMeshQuicNativeHandle): number;
|
||||
}
|
||||
|
||||
let bindingsCache: NativeBindings | undefined;
|
||||
|
|
@ -170,87 +181,111 @@ function loadBindings(): NativeBindings {
|
|||
reserved: koffi.array("uint8_t", 16),
|
||||
});
|
||||
|
||||
const runtimeOpen = bridgeLib.func(
|
||||
"int32_t cultmesh_quic_runtime_open(str app_name, _Out_ void **out_runtime)",
|
||||
const runtimeOpenFn = bridgeLib.func(
|
||||
"int32_t cultmesh_quic_runtime_open(str app_name, void **out_runtime)",
|
||||
);
|
||||
const runtimeClose = bridgeLib.func("void cultmesh_quic_runtime_close(void *runtime)");
|
||||
const listenerOpen = bridgeLib.func(
|
||||
const runtimeCloseFn = bridgeLib.func("void cultmesh_quic_runtime_close(void *runtime)");
|
||||
const listenerOpenFn = bridgeLib.func(
|
||||
"int32_t cultmesh_quic_listener_open(void *runtime, str host, uint16_t port, " +
|
||||
"uint8_t *pkcs12, int32_t pkcs12_length, str password, " +
|
||||
"_Out_ uint64_t *out_listener_id, _Out_ uint16_t *out_bound_port)",
|
||||
"uint64_t *out_listener_id, uint16_t *out_bound_port)",
|
||||
);
|
||||
const listenerClose = bridgeLib.func(
|
||||
const listenerCloseFn = bridgeLib.func(
|
||||
"void cultmesh_quic_listener_close(void *runtime, uint64_t listener_id)",
|
||||
);
|
||||
const connectionOpen = bridgeLib.func(
|
||||
const connectionOpenFn = bridgeLib.func(
|
||||
"int32_t cultmesh_quic_connection_open(void *runtime, str host, uint16_t port, " +
|
||||
"_Out_ uint64_t *out_connection_id)",
|
||||
"uint64_t *out_connection_id)",
|
||||
);
|
||||
const connectionCertificateComplete = bridgeLib.func(
|
||||
const connectionCertificateCompleteFn = bridgeLib.func(
|
||||
"int32_t cultmesh_quic_connection_certificate_complete(void *runtime, uint64_t connection_id, int32_t accept)",
|
||||
);
|
||||
const connectionShutdown = bridgeLib.func(
|
||||
const connectionShutdownFn = bridgeLib.func(
|
||||
"void cultmesh_quic_connection_shutdown(void *runtime, uint64_t connection_id, uint64_t code)",
|
||||
);
|
||||
const streamOpen = bridgeLib.func(
|
||||
const streamOpenFn = bridgeLib.func(
|
||||
"int32_t cultmesh_quic_stream_open(void *runtime, uint64_t connection_id, uint8_t kind, " +
|
||||
"_Out_ uint64_t *out_stream_id)",
|
||||
"uint64_t *out_stream_id)",
|
||||
);
|
||||
const streamSendFrame = bridgeLib.func(
|
||||
const streamSendFrameFn = bridgeLib.func(
|
||||
"int32_t cultmesh_quic_stream_send_frame(void *runtime, uint64_t stream_id, " +
|
||||
"uint8_t *encoded_frame, int32_t length, int32_t fin)",
|
||||
);
|
||||
const streamShutdown = bridgeLib.func(
|
||||
const streamShutdownFn = bridgeLib.func(
|
||||
"void cultmesh_quic_stream_shutdown(void *runtime, uint64_t stream_id, uint64_t code)",
|
||||
);
|
||||
const nextEvent = bridgeLib.func(
|
||||
const nextEventFn = bridgeLib.func(
|
||||
"int32_t cultmesh_quic_next_event(void *runtime, int32_t timeout_ms, " +
|
||||
`_Out_ ${CultMeshQuicEvent.name} *out_event, uint8_t *payload, int32_t payload_capacity, ` +
|
||||
"_Out_ int32_t *out_required)",
|
||||
`${CultMeshQuicEvent.name} *out_event, uint8_t *payload, int32_t payload_capacity, ` +
|
||||
"int32_t *out_required)",
|
||||
);
|
||||
const lastError = bridgeLib.func(
|
||||
const lastErrorFn = bridgeLib.func(
|
||||
"int32_t cultmesh_quic_last_error(void *runtime, uint8_t *destination, int32_t capacity)",
|
||||
);
|
||||
const lastStatus = bridgeLib.func("int32_t cultmesh_quic_last_status(void *runtime)");
|
||||
const lastStatusFn = bridgeLib.func("int32_t cultmesh_quic_last_status(void *runtime)");
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
const { promisify } = require("node:util") as typeof import("node:util");
|
||||
const nextEventAsyncRaw = promisify(nextEvent.async) as (
|
||||
runtime: unknown,
|
||||
const nextEventAsyncRaw = promisify(nextEventFn.async) as (
|
||||
runtime: CultMeshQuicNativeHandle,
|
||||
timeoutMs: number,
|
||||
outEvent: null,
|
||||
outEvent: unknown,
|
||||
payload: Uint8Array | null,
|
||||
payloadCapacity: number,
|
||||
outRequired: null,
|
||||
) => Promise<KoffiOutResult<NativeEventStruct>>;
|
||||
outRequired: unknown,
|
||||
) => Promise<number>;
|
||||
|
||||
// Every `_Out_` parameter still occupies a call-site slot in this koffi
|
||||
// version (an arity check rejects a shorter call); its value is written
|
||||
// back through the named field on the returned object regardless of what
|
||||
// was passed here, so `null` is a safe placeholder at each such slot.
|
||||
bindingsCache = {
|
||||
runtimeOpen: (appName) => runtimeOpen(appName, null),
|
||||
runtimeClose: (runtime) => runtimeClose(runtime),
|
||||
listenerOpen: (runtime, host, port, pkcs12, pkcs12Length, password) =>
|
||||
listenerOpen(runtime, host, port, pkcs12, pkcs12Length, password, null, null),
|
||||
listenerClose: (runtime, listenerId) => listenerClose(runtime, listenerId),
|
||||
connectionOpen: (runtime, host, port) => connectionOpen(runtime, host, port, null),
|
||||
runtimeOpen: (appName) => {
|
||||
const out = koffi.alloc("void *", 1);
|
||||
const status = runtimeOpenFn(appName, out);
|
||||
return { status, runtime: status === 0 ? (koffi.decode(out, "void *") as CultMeshQuicNativeHandle) : null };
|
||||
},
|
||||
runtimeClose: (runtime) => runtimeCloseFn(runtime),
|
||||
listenerOpen: (runtime, host, port, pkcs12, pkcs12Length, password) => {
|
||||
const outListenerId = koffi.alloc("uint64_t", 1);
|
||||
const outBoundPort = koffi.alloc("uint16_t", 1);
|
||||
const status = listenerOpenFn(runtime, host, port, pkcs12, pkcs12Length, password, outListenerId, outBoundPort);
|
||||
return {
|
||||
status,
|
||||
listenerId: status === 0 ? (koffi.decode(outListenerId, "uint64_t") as bigint) : 0n,
|
||||
boundPort: status === 0 ? (koffi.decode(outBoundPort, "uint16_t") as number) : 0,
|
||||
};
|
||||
},
|
||||
listenerClose: (runtime, listenerId) => listenerCloseFn(runtime, listenerId),
|
||||
connectionOpen: (runtime, host, port) => {
|
||||
const out = koffi.alloc("uint64_t", 1);
|
||||
const status = connectionOpenFn(runtime, host, port, out);
|
||||
return { status, connectionId: status === 0 ? (koffi.decode(out, "uint64_t") as bigint) : 0n };
|
||||
},
|
||||
connectionCertificateComplete: (runtime, connectionId, accept) =>
|
||||
connectionCertificateComplete(runtime, connectionId, accept),
|
||||
connectionShutdown: (runtime, connectionId, code) => connectionShutdown(runtime, connectionId, code),
|
||||
streamOpen: (runtime, connectionId, kind) => streamOpen(runtime, connectionId, kind, null),
|
||||
connectionCertificateCompleteFn(runtime, connectionId, accept),
|
||||
connectionShutdown: (runtime, connectionId, code) => connectionShutdownFn(runtime, connectionId, code),
|
||||
streamOpen: (runtime, connectionId, kind) => {
|
||||
const out = koffi.alloc("uint64_t", 1);
|
||||
const status = streamOpenFn(runtime, connectionId, kind, out);
|
||||
return { status, streamId: status === 0 ? (koffi.decode(out, "uint64_t") as bigint) : 0n };
|
||||
},
|
||||
streamSendFrame: (runtime, streamId, encodedFrame, length, fin) =>
|
||||
streamSendFrame(runtime, streamId, encodedFrame, length, fin),
|
||||
streamShutdown: (runtime, streamId, code) => streamShutdown(runtime, streamId, code),
|
||||
nextEventAsync: (runtime, timeoutMs, payload, payloadCapacity) =>
|
||||
nextEventAsyncRaw(runtime, timeoutMs, null, payload, payloadCapacity, null),
|
||||
lastError: (runtime, destination, capacity) => lastError(runtime, destination, capacity),
|
||||
lastStatus: (runtime) => lastStatus(runtime),
|
||||
streamSendFrameFn(runtime, streamId, encodedFrame, length, fin),
|
||||
streamShutdown: (runtime, streamId, code) => streamShutdownFn(runtime, streamId, code),
|
||||
nextEventAsync: async (runtime, timeoutMs, payload, payloadCapacity) => {
|
||||
const outEvent = koffi.alloc(CultMeshQuicEvent, 1);
|
||||
const outRequired = koffi.alloc("int32_t", 1);
|
||||
const status = await nextEventAsyncRaw(runtime, timeoutMs, outEvent, payload, payloadCapacity, outRequired);
|
||||
return {
|
||||
status,
|
||||
event: status === 1 ? (koffi.decode(outEvent, CultMeshQuicEvent) as NativeEventStruct) : null,
|
||||
required: koffi.decode(outRequired, "int32_t") as number,
|
||||
};
|
||||
},
|
||||
lastError: (runtime, destination, capacity) => lastErrorFn(runtime, destination, capacity),
|
||||
lastStatus: (runtime) => lastStatusFn(runtime),
|
||||
};
|
||||
return bindingsCache;
|
||||
}
|
||||
|
||||
function readLastError(bindings: NativeBindings, runtime: unknown): string {
|
||||
function readLastError(bindings: NativeBindings, runtime: CultMeshQuicNativeHandle): string {
|
||||
const buffer = Buffer.alloc(1024);
|
||||
const written = bindings.lastError(runtime, buffer, buffer.length);
|
||||
return written > 0 ? buffer.subarray(0, written).toString("utf8") : "CultMesh native QUIC call failed.";
|
||||
|
|
@ -267,14 +302,14 @@ export class CultMeshQuicNativeRuntime {
|
|||
private static shared: CultMeshQuicNativeRuntime | undefined;
|
||||
private static refCount = 0;
|
||||
|
||||
private readonly handle: unknown;
|
||||
private readonly handle: CultMeshQuicNativeHandle;
|
||||
private readonly bindings: NativeBindings;
|
||||
private readonly listenerListeners = new Map<bigint, NativeEventListener>();
|
||||
private readonly connectionListeners = new Map<bigint, NativeEventListener>();
|
||||
private closed = false;
|
||||
private pumpLoop: Promise<void>;
|
||||
|
||||
private constructor(handle: unknown, bindings: NativeBindings) {
|
||||
private constructor(handle: CultMeshQuicNativeHandle, bindings: NativeBindings) {
|
||||
this.handle = handle;
|
||||
this.bindings = bindings;
|
||||
this.pumpLoop = this.pump();
|
||||
|
|
@ -284,10 +319,10 @@ export class CultMeshQuicNativeRuntime {
|
|||
if (!CultMeshQuicNativeRuntime.shared) {
|
||||
const bindings = loadBindings();
|
||||
const opened = bindings.runtimeOpen(null);
|
||||
if (opened.result !== 0 || !opened.out_runtime) {
|
||||
throw new Error(`cultmesh_quic_runtime_open failed with status ${opened.result}.`);
|
||||
if (opened.status !== 0 || opened.runtime === null) {
|
||||
throw new Error(`cultmesh_quic_runtime_open failed with status ${opened.status}.`);
|
||||
}
|
||||
CultMeshQuicNativeRuntime.shared = new CultMeshQuicNativeRuntime(opened.out_runtime, bindings);
|
||||
CultMeshQuicNativeRuntime.shared = new CultMeshQuicNativeRuntime(opened.runtime, bindings);
|
||||
}
|
||||
CultMeshQuicNativeRuntime.refCount += 1;
|
||||
return CultMeshQuicNativeRuntime.shared;
|
||||
|
|
@ -326,10 +361,10 @@ export class CultMeshQuicNativeRuntime {
|
|||
password: string | null,
|
||||
): { listenerId: bigint; boundPort: number } {
|
||||
const opened = this.bindings.listenerOpen(this.handle, host, port, pkcs12, pkcs12.length, password);
|
||||
if (opened.result !== 0 || opened.out_listener_id === undefined) {
|
||||
if (opened.status !== 0) {
|
||||
throw new Error(`cultmesh_quic_listener_open failed: ${readLastError(this.bindings, this.handle)}`);
|
||||
}
|
||||
return { listenerId: opened.out_listener_id, boundPort: opened.out_bound_port ?? 0 };
|
||||
return { listenerId: opened.listenerId, boundPort: opened.boundPort };
|
||||
}
|
||||
|
||||
listenerClose(listenerId: bigint): void {
|
||||
|
|
@ -339,15 +374,15 @@ export class CultMeshQuicNativeRuntime {
|
|||
|
||||
connectionOpen(host: string, port: number): bigint {
|
||||
const opened = this.bindings.connectionOpen(this.handle, host, port);
|
||||
if (opened.result !== 0 || opened.out_connection_id === undefined) {
|
||||
if (opened.status !== 0) {
|
||||
throw new Error(`cultmesh_quic_connection_open failed: ${readLastError(this.bindings, this.handle)}`);
|
||||
}
|
||||
return opened.out_connection_id;
|
||||
return opened.connectionId;
|
||||
}
|
||||
|
||||
connectionCertificateComplete(connectionId: bigint, accept: boolean): void {
|
||||
const result = this.bindings.connectionCertificateComplete(this.handle, connectionId, accept ? 1 : 0);
|
||||
if (result !== 0) {
|
||||
const status = this.bindings.connectionCertificateComplete(this.handle, connectionId, accept ? 1 : 0);
|
||||
if (status !== 0) {
|
||||
throw new Error(
|
||||
`cultmesh_quic_connection_certificate_complete failed: ${readLastError(this.bindings, this.handle)}`,
|
||||
);
|
||||
|
|
@ -360,15 +395,15 @@ export class CultMeshQuicNativeRuntime {
|
|||
|
||||
streamOpen(connectionId: bigint, kind: number): bigint {
|
||||
const opened = this.bindings.streamOpen(this.handle, connectionId, kind);
|
||||
if (opened.result !== 0 || opened.out_stream_id === undefined) {
|
||||
if (opened.status !== 0) {
|
||||
throw new Error(`cultmesh_quic_stream_open failed: ${readLastError(this.bindings, this.handle)}`);
|
||||
}
|
||||
return opened.out_stream_id;
|
||||
return opened.streamId;
|
||||
}
|
||||
|
||||
streamSendFrame(streamId: bigint, encodedFrame: Uint8Array, fin: boolean): void {
|
||||
const result = this.bindings.streamSendFrame(this.handle, streamId, encodedFrame, encodedFrame.length, fin ? 1 : 0);
|
||||
if (result !== 0) {
|
||||
const status = this.bindings.streamSendFrame(this.handle, streamId, encodedFrame, encodedFrame.length, fin ? 1 : 0);
|
||||
if (status !== 0) {
|
||||
throw new Error(`cultmesh_quic_stream_send_frame failed: ${readLastError(this.bindings, this.handle)}`);
|
||||
}
|
||||
}
|
||||
|
|
@ -397,7 +432,7 @@ export class CultMeshQuicNativeRuntime {
|
|||
|
||||
private async pump(): Promise<void> {
|
||||
while (!this.closed) {
|
||||
let first: KoffiOutResult<NativeEventStruct>;
|
||||
let first: Awaited<ReturnType<NativeBindings["nextEventAsync"]>>;
|
||||
try {
|
||||
first = await this.bindings.nextEventAsync(this.handle, 250, null, 0);
|
||||
} catch {
|
||||
|
|
@ -405,22 +440,22 @@ export class CultMeshQuicNativeRuntime {
|
|||
continue;
|
||||
}
|
||||
if (this.closed) return;
|
||||
if (first.result === 0 || first.result === CULTMESH_QUIC_RESULT_BAD_CALL) continue;
|
||||
if (first.result === 1 && first.out_event) {
|
||||
this.dispatch(first.out_event, new Uint8Array(0));
|
||||
if (first.status === 0 || first.status === CULTMESH_QUIC_RESULT_BAD_CALL) continue;
|
||||
if (first.status === 1 && first.event) {
|
||||
this.dispatch(first.event, new Uint8Array(0));
|
||||
continue;
|
||||
}
|
||||
if (first.result === 2 && first.out_required) {
|
||||
const buffer = Buffer.alloc(first.out_required);
|
||||
let second: KoffiOutResult<NativeEventStruct>;
|
||||
if (first.status === 2 && first.required > 0) {
|
||||
const buffer = Buffer.alloc(first.required);
|
||||
let second: Awaited<ReturnType<NativeBindings["nextEventAsync"]>>;
|
||||
try {
|
||||
second = await this.bindings.nextEventAsync(this.handle, 0, buffer, buffer.length);
|
||||
} catch {
|
||||
if (this.closed) return;
|
||||
continue;
|
||||
}
|
||||
if (second.result === 1 && second.out_event) {
|
||||
this.dispatch(second.out_event, buffer.subarray(0, second.out_event.payload_length));
|
||||
if (second.status === 1 && second.event) {
|
||||
this.dispatch(second.event, buffer.subarray(0, second.event.payload_length));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue