reactor-effect-native 0.2.0 → 0.3.0-rc.0
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.
- package/Dockerfile +4 -2
- package/README.md +90 -15
- package/dist/_internal/bridge.d.ts +70 -14
- package/dist/_internal/bridge.d.ts.map +1 -1
- package/dist/_internal/bridge.js +290 -145
- package/dist/_internal/bridge.js.map +1 -1
- package/dist/_internal/isolated/child.d.ts +2 -0
- package/dist/_internal/isolated/child.d.ts.map +1 -0
- package/dist/_internal/isolated/child.js +176 -0
- package/dist/_internal/isolated/child.js.map +1 -0
- package/dist/_internal/isolated/host.d.ts +147 -0
- package/dist/_internal/isolated/host.d.ts.map +1 -0
- package/dist/_internal/isolated/host.js +645 -0
- package/dist/_internal/isolated/host.js.map +1 -0
- package/dist/_internal/isolated/protocol.d.ts +399 -0
- package/dist/_internal/isolated/protocol.d.ts.map +1 -0
- package/dist/_internal/isolated/protocol.js +250 -0
- package/dist/_internal/isolated/protocol.js.map +1 -0
- package/dist/_internal/peer.d.ts +68 -13
- package/dist/_internal/peer.d.ts.map +1 -1
- package/dist/_internal/peer.js +233 -157
- package/dist/_internal/peer.js.map +1 -1
- package/dist/index.d.ts +19 -10
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +37 -33
- package/dist/index.js.map +1 -1
- package/dist/isolated.d.ts +25 -0
- package/dist/isolated.d.ts.map +1 -0
- package/dist/isolated.js +39 -0
- package/dist/isolated.js.map +1 -0
- package/lib/darwin-arm64/libreactor_effect_native.dylib +0 -0
- package/lib/darwin-arm64/native-identity.json +4 -3
- package/lib/linux-x64/libreactor_effect_native.so +0 -0
- package/lib/linux-x64/native-identity.json +4 -3
- package/package.json +7 -3
- package/rust/Cargo.toml +108 -2
- package/rust/build.rs +253 -114
- package/rust/clippy.toml +7 -0
- package/rust/include/reactor_effect_native.h +101 -42
- package/rust/src/abi.rs +258 -0
- package/rust/src/error.rs +149 -0
- package/rust/src/ffi/memory.rs +256 -0
- package/rust/src/ffi/tests.rs +719 -0
- package/rust/src/ffi.rs +474 -0
- package/rust/src/lib.rs +36 -2341
- package/rust/src/peer/callbacks.rs +145 -0
- package/rust/src/peer/media.rs +221 -0
- package/rust/src/peer/owner/tests.rs +188 -0
- package/rust/src/peer/owner.rs +353 -0
- package/rust/src/peer/shared.rs +323 -0
- package/rust/src/peer/tests.rs +513 -0
- package/rust/src/peer.rs +189 -0
- package/rust/src/protocol/event.rs +238 -0
- package/rust/src/protocol/request.rs +347 -0
- package/rust/src/protocol/stats.rs +356 -0
- package/rust/src/protocol.rs +71 -0
- package/rust/src/sync/gate.rs +159 -0
- package/rust/src/sync/notifier.rs +136 -0
- package/rust/src/sync/queue.rs +384 -0
- package/rust/src/sync.rs +20 -0
- package/rust/src/test_support.rs +99 -0
- package/rust-toolchain.toml +7 -0
- package/scripts/stage.mjs +41 -1
package/dist/_internal/bridge.js
CHANGED
|
@@ -4,12 +4,18 @@ import { readFile } from "node:fs/promises";
|
|
|
4
4
|
import { dirname, join } from "node:path";
|
|
5
5
|
import process from "node:process";
|
|
6
6
|
import { fileURLToPath } from "node:url";
|
|
7
|
-
import * as
|
|
8
|
-
import {
|
|
9
|
-
const ABI_VERSION =
|
|
7
|
+
import * as Redacted from "effect/Redacted";
|
|
8
|
+
import { Native, ReactorError } from "reactor-effect-client";
|
|
9
|
+
const ABI_VERSION = 4;
|
|
10
10
|
const CALL_BUFFER_BYTES = 4 * 1024 * 1024;
|
|
11
|
-
const
|
|
12
|
-
const
|
|
11
|
+
const FAILURE_BYTES = 1024;
|
|
12
|
+
const VIDEO_HEADER_BYTES = 48;
|
|
13
|
+
const AUDIO_HEADER_BYTES = 24;
|
|
14
|
+
const EVENT_BUFFER_BYTES = 64 * 1024;
|
|
15
|
+
// The native queues' byte bounds: no single item can exceed them.
|
|
16
|
+
const MAX_EVENT_BYTES = 16 * 1024 * 1024;
|
|
17
|
+
const MAX_VIDEO_BYTES = 64 * 1024 * 1024;
|
|
18
|
+
const MAX_AUDIO_SAMPLES = 2 * 1024 * 1024;
|
|
13
19
|
const MAX_IN_FLIGHT_CALLS = 128;
|
|
14
20
|
const MAX_IN_FLIGHT_REQUEST_BYTES = 16 * 1024 * 1024;
|
|
15
21
|
const MAX_REQUEST_BYTES = 1024 * 1024;
|
|
@@ -18,6 +24,38 @@ const STATUS_OK = 0;
|
|
|
18
24
|
const STATUS_AGAIN = 1;
|
|
19
25
|
const STATUS_BUFFER_TOO_SMALL = 2;
|
|
20
26
|
const STATUS_CLOSED = 3;
|
|
27
|
+
/** The C ABI's closed set of failure classes, keyed by status. */
|
|
28
|
+
const FAILURE_CODES = new Map([
|
|
29
|
+
[STATUS_CLOSED, "Closed"],
|
|
30
|
+
[-1, "InvalidInput"],
|
|
31
|
+
[-2, "Native"],
|
|
32
|
+
[-3, "Overflow"],
|
|
33
|
+
[-4, "Protocol"],
|
|
34
|
+
[-5, "SdpRejected"],
|
|
35
|
+
[-6, "ChannelClosed"],
|
|
36
|
+
]);
|
|
37
|
+
/** A status outside the ABI's classes is an unclassified native failure. */
|
|
38
|
+
export const failureCode = (status) => FAILURE_CODES.get(status) ?? "Native";
|
|
39
|
+
/**
|
|
40
|
+
* A failure the native ABI returned with `status`, classified by its failure
|
|
41
|
+
* class. libwebrtc's own text can contain peer SDP or caller-supplied
|
|
42
|
+
* signaling material, so it is kept Redacted for explicit inspection: in the
|
|
43
|
+
* `Native` reason for an unclassified failure, and in `context.detail`, beside
|
|
44
|
+
* the status, for a classified one.
|
|
45
|
+
*/
|
|
46
|
+
export const nativeFailure = (status, backendText, message, context, detail = {}) => {
|
|
47
|
+
const code = failureCode(status);
|
|
48
|
+
const backendMessage = Redacted.make(backendText);
|
|
49
|
+
return code === "Native"
|
|
50
|
+
? new ReactorError({
|
|
51
|
+
reason: new Native({ message: message(code), status, backendMessage }),
|
|
52
|
+
context: Object.keys(detail).length === 0 ? context : { ...context, detail },
|
|
53
|
+
})
|
|
54
|
+
: ReactorError.fromCode(code, message(code), {
|
|
55
|
+
...context,
|
|
56
|
+
detail: { status, backendMessage, ...detail },
|
|
57
|
+
});
|
|
58
|
+
};
|
|
21
59
|
export const NativeCall = Object.freeze({
|
|
22
60
|
Prepare: 1,
|
|
23
61
|
Answer: 2,
|
|
@@ -26,7 +64,11 @@ export const NativeCall = Object.freeze({
|
|
|
26
64
|
Stats: 5,
|
|
27
65
|
MediaSnapshot: 6,
|
|
28
66
|
});
|
|
67
|
+
/** Readiness bits the native notifier passes to the host callback. */
|
|
68
|
+
export const Ready = Object.freeze({ Events: 1, Video: 2, Audio: 4 });
|
|
29
69
|
const APIs = new Map();
|
|
70
|
+
// Koffi type names are process-global; one anonymous prototype serves every library.
|
|
71
|
+
let notifyType;
|
|
30
72
|
const libraryName = () => {
|
|
31
73
|
switch (process.platform) {
|
|
32
74
|
case "darwin":
|
|
@@ -46,7 +88,7 @@ const defaultPaths = () => {
|
|
|
46
88
|
};
|
|
47
89
|
const asAsync = (value, symbol) => {
|
|
48
90
|
if (typeof value !== "function" || !("async" in value)) {
|
|
49
|
-
throw
|
|
91
|
+
throw ReactorError.fromCode("Native", `native library symbol ${symbol} does not support asynchronous calls`);
|
|
50
92
|
}
|
|
51
93
|
return value;
|
|
52
94
|
};
|
|
@@ -56,7 +98,7 @@ const importKoffi = async () => {
|
|
|
56
98
|
return module.default;
|
|
57
99
|
}
|
|
58
100
|
catch (cause) {
|
|
59
|
-
throw
|
|
101
|
+
throw ReactorError.fromCode("UnsupportedHost", "the native export requires the optional koffi dependency", { detail: cause, outcome: "not-submitted" });
|
|
60
102
|
}
|
|
61
103
|
};
|
|
62
104
|
const loadAt = async (path) => {
|
|
@@ -73,7 +115,7 @@ const loadAt = async (path) => {
|
|
|
73
115
|
library = koffi.load(path);
|
|
74
116
|
}
|
|
75
117
|
catch (cause) {
|
|
76
|
-
throw
|
|
118
|
+
throw ReactorError.fromCode("Native", `could not load native WebRTC bridge at ${path}`, {
|
|
77
119
|
detail: cause,
|
|
78
120
|
outcome: "not-submitted",
|
|
79
121
|
});
|
|
@@ -83,25 +125,29 @@ const loadAt = async (path) => {
|
|
|
83
125
|
return library.func(prototype);
|
|
84
126
|
}
|
|
85
127
|
catch (cause) {
|
|
86
|
-
throw
|
|
128
|
+
throw ReactorError.fromCode("Native", `native WebRTC bridge is incompatible: missing ${prototype.split("(")[0] ?? prototype}`, { detail: cause, outcome: "not-submitted" });
|
|
87
129
|
}
|
|
88
130
|
};
|
|
89
131
|
const abi = symbol("uint32_t reactor_effect_abi_version(void)");
|
|
90
132
|
const actual = abi();
|
|
91
133
|
if (actual !== ABI_VERSION)
|
|
92
|
-
throw
|
|
134
|
+
throw ReactorError.fromCode("Native", `native WebRTC ABI mismatch: expected ${ABI_VERSION}, received ${actual}`, { outcome: "not-submitted" });
|
|
135
|
+
notifyType ??= koffi.pointer(koffi.proto("void", ["uint32_t"]));
|
|
136
|
+
const notify = notifyType;
|
|
93
137
|
const api = {
|
|
94
138
|
library,
|
|
95
139
|
binarySha256,
|
|
96
140
|
buildIdentity: symbol("const char *reactor_effect_build_identity(void)")(),
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
141
|
+
register: (callback) => koffi.register(callback, notify),
|
|
142
|
+
unregister: (callback) => koffi.unregister(callback),
|
|
143
|
+
create: symbol("void *reactor_effect_peer_create(void *notify)"),
|
|
144
|
+
call: asAsync(symbol("int reactor_effect_peer_call(void *peer, uint32_t operation, const uint8_t *request, size_t request_len, _Out_ uint8_t *response, size_t response_cap, _Out_ size_t *response_len, _Out_ uint8_t *failure)"), "reactor_effect_peer_call"),
|
|
145
|
+
send: asAsync(symbol("int reactor_effect_peer_send(void *peer, uint32_t channel, const uint8_t *data, size_t data_len, _Out_ uint8_t *failure)"), "reactor_effect_peer_send"),
|
|
146
|
+
takeEvent: symbol("int reactor_effect_peer_take_event(void *peer, _Out_ uint8_t *out, size_t out_cap, _Out_ size_t *out_len)"),
|
|
147
|
+
takeVideo: symbol("int reactor_effect_peer_take_video(void *peer, _Out_ uint8_t *header, _Out_ uint8_t *bgra, size_t bgra_cap, _Out_ uint8_t *metadata, size_t metadata_cap)"),
|
|
148
|
+
takeAudio: symbol("int reactor_effect_peer_take_audio(void *peer, _Out_ uint8_t *header, _Out_ int16_t *pcm, size_t pcm_cap)"),
|
|
103
149
|
close: symbol("void reactor_effect_peer_close(void *peer)"),
|
|
104
|
-
shutdown: asAsync(symbol("int reactor_effect_peer_shutdown(void *peer, _Out_ uint8_t *
|
|
150
|
+
shutdown: asAsync(symbol("int reactor_effect_peer_shutdown(void *peer, _Out_ uint8_t *failure)"), "reactor_effect_peer_shutdown"),
|
|
105
151
|
destroy: symbol("void reactor_effect_peer_destroy(void *peer)"),
|
|
106
152
|
};
|
|
107
153
|
APIs.set(path, api);
|
|
@@ -122,7 +168,7 @@ export const resolveNativeBridge = async (path) => {
|
|
|
122
168
|
failures.push(cause instanceof Error ? cause.message : String(cause));
|
|
123
169
|
}
|
|
124
170
|
}
|
|
125
|
-
throw
|
|
171
|
+
throw ReactorError.fromCode("Native", "native WebRTC bridge is not staged or its identity is invalid; run bun run native:build or provide an explicit library path", { detail: failures, outcome: "not-submitted" });
|
|
126
172
|
};
|
|
127
173
|
export const checkNativeBridge = async (path) => {
|
|
128
174
|
await resolveNativeBridge(path);
|
|
@@ -130,13 +176,13 @@ export const checkNativeBridge = async (path) => {
|
|
|
130
176
|
const checked = (path) => {
|
|
131
177
|
const api = APIs.get(path);
|
|
132
178
|
if (api === undefined)
|
|
133
|
-
throw
|
|
179
|
+
throw ReactorError.fromCode("InvalidState", "native WebRTC bridge was not loaded; build Native.layer before making a peer", { outcome: "not-submitted" });
|
|
134
180
|
return api;
|
|
135
181
|
};
|
|
136
182
|
const toNumber = (value, name) => {
|
|
137
183
|
const number = typeof value === "bigint" ? Number(value) : value;
|
|
138
184
|
if (typeof number !== "number" || !Number.isSafeInteger(number) || number < 0)
|
|
139
|
-
throw
|
|
185
|
+
throw ReactorError.fromCode("Protocol", `native ${name} is outside the safe integer range`);
|
|
140
186
|
return number;
|
|
141
187
|
};
|
|
142
188
|
const jsonRecord = (bytes, operation) => {
|
|
@@ -145,12 +191,12 @@ const jsonRecord = (bytes, operation) => {
|
|
|
145
191
|
value = JSON.parse(new TextDecoder().decode(bytes));
|
|
146
192
|
}
|
|
147
193
|
catch (cause) {
|
|
148
|
-
throw
|
|
194
|
+
throw ReactorError.fromCode("Protocol", `native ${operation} returned invalid JSON`, {
|
|
149
195
|
detail: cause,
|
|
150
196
|
});
|
|
151
197
|
}
|
|
152
198
|
if (value === null || typeof value !== "object" || Array.isArray(value))
|
|
153
|
-
throw
|
|
199
|
+
throw ReactorError.fromCode("Protocol", `native ${operation} returned a non-object response`);
|
|
154
200
|
return value;
|
|
155
201
|
};
|
|
156
202
|
/** Validate the staged artifact used by both source and installed-package tests. */
|
|
@@ -165,79 +211,134 @@ export const verifyStagedNativeBridge = async (path) => {
|
|
|
165
211
|
manifest.platform !== `${process.platform}-${process.arch}` ||
|
|
166
212
|
manifest.library !== libraryName() ||
|
|
167
213
|
manifest.sha256 !== createHash("sha256").update(binary).digest("hex")) {
|
|
168
|
-
throw
|
|
214
|
+
throw ReactorError.fromCode("Native", "native artifact does not match its staged identity");
|
|
169
215
|
}
|
|
170
216
|
const api = await loadAt(path);
|
|
171
217
|
if (api.binarySha256 !== manifest.sha256) {
|
|
172
|
-
throw
|
|
218
|
+
throw ReactorError.fromCode("Native", "staged native artifact changed after this process loaded it");
|
|
173
219
|
}
|
|
174
220
|
const prefix = "reactor-effect-native:build-identity:", suffix = ":end";
|
|
175
221
|
if (!api.buildIdentity.startsWith(prefix) || !api.buildIdentity.endsWith(suffix)) {
|
|
176
|
-
throw
|
|
222
|
+
throw ReactorError.fromCode("Native", "loaded native artifact omitted its source/build identity");
|
|
177
223
|
}
|
|
178
224
|
const build = jsonRecord(Buffer.from(api.buildIdentity.slice(prefix.length, -suffix.length)), "build identity");
|
|
179
225
|
if (build.abiVersion !== ABI_VERSION ||
|
|
180
226
|
build.profile !== "release" ||
|
|
181
227
|
JSON.stringify(build) !== JSON.stringify(manifest.build)) {
|
|
182
|
-
throw
|
|
228
|
+
throw ReactorError.fromCode("Native", "loaded native build identity differs from its staged artifact");
|
|
183
229
|
}
|
|
184
230
|
return Object.freeze(manifest);
|
|
185
231
|
}
|
|
186
232
|
catch (cause) {
|
|
187
|
-
throw
|
|
233
|
+
throw ReactorError.fromCode("Native", "native staged artifact verification failed", {
|
|
188
234
|
detail: cause,
|
|
189
235
|
outcome: "not-submitted",
|
|
190
236
|
});
|
|
191
237
|
}
|
|
192
238
|
};
|
|
193
|
-
const
|
|
194
|
-
const
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
const code = isErrorCode(rawCode) ? rawCode : status === STATUS_CLOSED ? "Closed" : "Native";
|
|
198
|
-
// A native status/code does not establish whether a side effect executed.
|
|
199
|
-
// Keep backend messages for explicit inspection rather than diagnostics: a
|
|
200
|
-
// libwebrtc error can contain peer SDP or caller-supplied signaling material.
|
|
201
|
-
return new ReactorError(code, `native ${operation} failed (${code})`, {
|
|
202
|
-
operation,
|
|
203
|
-
outcome: "unknown",
|
|
204
|
-
detail: response,
|
|
205
|
-
});
|
|
239
|
+
const failureOf = (status, failure, operation, detail = {}) => {
|
|
240
|
+
const length = Math.min(new DataView(failure.buffer, failure.byteOffset, failure.byteLength).getUint32(0, true), failure.byteLength - 4);
|
|
241
|
+
// A native status does not establish whether a side effect executed.
|
|
242
|
+
return nativeFailure(status, new TextDecoder().decode(failure.subarray(4, 4 + length)), (code) => `native ${operation} failed (${code})`, { operation, outcome: "unknown" }, detail);
|
|
206
243
|
};
|
|
207
244
|
const asyncStatus = (fn, args) => new Promise((resolve, reject) => {
|
|
208
|
-
fn.async(...args, (error, result) =>
|
|
245
|
+
fn.async(...args, (error, result) => error == null
|
|
246
|
+
? resolve(result)
|
|
247
|
+
: reject(error instanceof Error
|
|
248
|
+
? error
|
|
249
|
+
: new Error("native async call failed", { cause: error })));
|
|
209
250
|
});
|
|
210
251
|
const parsePacket = (bytes) => {
|
|
211
252
|
if (bytes.length < 4)
|
|
212
|
-
throw
|
|
253
|
+
throw ReactorError.fromCode("Protocol", "native packet omitted its header length");
|
|
213
254
|
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
|
214
255
|
const headerLength = view.getUint32(0, true);
|
|
215
256
|
if (headerLength > bytes.length - 4)
|
|
216
|
-
throw
|
|
257
|
+
throw ReactorError.fromCode("Protocol", "native packet header length exceeds packet size");
|
|
217
258
|
const header = jsonRecord(bytes.subarray(4, 4 + headerLength), "packet");
|
|
218
|
-
|
|
219
|
-
return { header: Object.freeze(header), payload };
|
|
259
|
+
return { header: Object.freeze(header), payload: bytes.slice(4 + headerLength) };
|
|
220
260
|
};
|
|
261
|
+
const overSized = (what, size) => ReactorError.fromCode("Protocol", `native ${what} of ${size} exceeds the native queue bound`);
|
|
262
|
+
/**
|
|
263
|
+
* Bridges whose owner join outlived the host's shutdown deadline. The pending
|
|
264
|
+
* join still owns the handle and needs the registered callback until the
|
|
265
|
+
* notifier is joined; holding the bridge here keeps both reachable whatever
|
|
266
|
+
* its peer's owner drops. A bridge leaves once its join completes and has
|
|
267
|
+
* destroyed the handle; one whose join fails stays.
|
|
268
|
+
*/
|
|
269
|
+
const retained = new Set();
|
|
270
|
+
/**
|
|
271
|
+
* One native peer. Media and transport events never cross into JavaScript on
|
|
272
|
+
* their own: a native notifier thread invokes `onReady` on the JavaScript
|
|
273
|
+
* thread, and the host drains each named queue with synchronous takes, one
|
|
274
|
+
* copy per item into memory the consumer then owns.
|
|
275
|
+
*/
|
|
221
276
|
export class NativeBridge {
|
|
277
|
+
/**
|
|
278
|
+
* Refuse a new peer on a library that holds a retained join. Every peer of a
|
|
279
|
+
* loaded library shares its one libwebrtc factory, so an owner that did not
|
|
280
|
+
* join may have wedged the threads a new owner would need; failing here keeps
|
|
281
|
+
* a caller from stacking wedged owners and allocating remote sessions for them.
|
|
282
|
+
*/
|
|
283
|
+
static requireUsable(path) {
|
|
284
|
+
const api = checked(path);
|
|
285
|
+
for (const bridge of retained)
|
|
286
|
+
if (bridge.api === api)
|
|
287
|
+
throw ReactorError.fromCode("Native", "native WebRTC runtime is degraded: an earlier peer's owner join exceeded its shutdown deadline and is still retained", { outcome: "not-submitted" });
|
|
288
|
+
}
|
|
222
289
|
api;
|
|
290
|
+
notify;
|
|
223
291
|
handle;
|
|
224
292
|
closed = false;
|
|
225
293
|
active = new Set();
|
|
226
|
-
readers = new Set();
|
|
227
294
|
requestBytes = 0;
|
|
228
295
|
shutdownTask;
|
|
229
|
-
|
|
296
|
+
videoHeader = new Uint8Array(VIDEO_HEADER_BYTES);
|
|
297
|
+
audioHeader = new Uint8Array(AUDIO_HEADER_BYTES);
|
|
298
|
+
eventLength = [0];
|
|
299
|
+
event = new Uint8Array(EVENT_BUFFER_BYTES);
|
|
300
|
+
metadata = new Uint8Array(256);
|
|
301
|
+
// The buffer the next take copies into. Frames keep their size, so each is
|
|
302
|
+
// allocated once at the previous frame's size and handed to its consumer.
|
|
303
|
+
nextVideo = new Uint8Array(0);
|
|
304
|
+
nextAudio = new Int16Array(0);
|
|
305
|
+
constructor(path, onReady) {
|
|
306
|
+
NativeBridge.requireUsable(path);
|
|
230
307
|
this.api = checked(path);
|
|
231
|
-
|
|
308
|
+
// Koffi throws its own errors from these calls: a native failure, not a bug.
|
|
309
|
+
const allocationFailed = (cause) => ReactorError.fromCode("Native", "native WebRTC peer allocation failed", {
|
|
310
|
+
outcome: "not-submitted",
|
|
311
|
+
detail: cause,
|
|
312
|
+
});
|
|
313
|
+
try {
|
|
314
|
+
this.notify = this.api.register((ready) => {
|
|
315
|
+
if (!this.closed)
|
|
316
|
+
onReady(ready);
|
|
317
|
+
});
|
|
318
|
+
}
|
|
319
|
+
catch (cause) {
|
|
320
|
+
throw allocationFailed(cause);
|
|
321
|
+
}
|
|
322
|
+
let handle = null;
|
|
323
|
+
try {
|
|
324
|
+
handle = this.api.create(this.notify);
|
|
325
|
+
}
|
|
326
|
+
catch (cause) {
|
|
327
|
+
throw allocationFailed(cause);
|
|
328
|
+
}
|
|
329
|
+
finally {
|
|
330
|
+
if (handle === null)
|
|
331
|
+
this.api.unregister(this.notify);
|
|
332
|
+
}
|
|
232
333
|
if (handle === null)
|
|
233
|
-
throw
|
|
334
|
+
throw ReactorError.fromCode("Native", "native WebRTC peer allocation failed", {
|
|
234
335
|
outcome: "not-submitted",
|
|
235
336
|
});
|
|
236
337
|
this.handle = handle;
|
|
237
338
|
}
|
|
238
339
|
require() {
|
|
239
340
|
if (this.closed || this.handle === undefined)
|
|
240
|
-
throw
|
|
341
|
+
throw ReactorError.fromCode("Closed", "native WebRTC peer is closed", {
|
|
241
342
|
outcome: "not-submitted",
|
|
242
343
|
});
|
|
243
344
|
return this.handle;
|
|
@@ -246,7 +347,7 @@ export class NativeBridge {
|
|
|
246
347
|
const handle = this.require();
|
|
247
348
|
if (this.active.size >= MAX_IN_FLIGHT_CALLS ||
|
|
248
349
|
this.requestBytes + bytes > MAX_IN_FLIGHT_REQUEST_BYTES) {
|
|
249
|
-
throw
|
|
350
|
+
throw ReactorError.fromCode("Overflow", "native foreign-call admission bound exceeded", {
|
|
250
351
|
outcome: "not-submitted",
|
|
251
352
|
});
|
|
252
353
|
}
|
|
@@ -256,7 +357,7 @@ export class NativeBridge {
|
|
|
256
357
|
});
|
|
257
358
|
// Register before dispatching to Koffi. Effect interruption may abandon the
|
|
258
359
|
// waiter, but the lease belongs to actual native completion, including time
|
|
259
|
-
// spent queued on Koffi's executor
|
|
360
|
+
// spent queued on Koffi's executor.
|
|
260
361
|
this.active.add(lease);
|
|
261
362
|
this.requestBytes += bytes;
|
|
262
363
|
try {
|
|
@@ -270,11 +371,11 @@ export class NativeBridge {
|
|
|
270
371
|
}
|
|
271
372
|
async call(operation, request = new Uint8Array()) {
|
|
272
373
|
if (request.byteLength > MAX_REQUEST_BYTES)
|
|
273
|
-
throw
|
|
374
|
+
throw ReactorError.fromCode("Overflow", "native request exceeds 1 MiB", {
|
|
274
375
|
outcome: "not-submitted",
|
|
275
376
|
});
|
|
276
377
|
return this.withHandle(CALL_BUFFER_BYTES + request.byteLength, async (handle) => {
|
|
277
|
-
const response = Buffer.allocUnsafe(CALL_BUFFER_BYTES), responseLength = [0];
|
|
378
|
+
const response = Buffer.allocUnsafe(CALL_BUFFER_BYTES), responseLength = [0], failure = Buffer.alloc(FAILURE_BYTES);
|
|
278
379
|
const input = Buffer.from(request);
|
|
279
380
|
let status;
|
|
280
381
|
try {
|
|
@@ -286,35 +387,38 @@ export class NativeBridge {
|
|
|
286
387
|
response,
|
|
287
388
|
response.byteLength,
|
|
288
389
|
responseLength,
|
|
390
|
+
failure,
|
|
289
391
|
]);
|
|
290
392
|
}
|
|
291
393
|
catch (cause) {
|
|
292
|
-
throw
|
|
394
|
+
throw ReactorError.fromCode("Native", "native WebRTC call completion failed", {
|
|
293
395
|
detail: cause,
|
|
294
396
|
outcome: "unknown",
|
|
295
397
|
});
|
|
296
398
|
}
|
|
399
|
+
if (status !== STATUS_OK)
|
|
400
|
+
throw failureOf(status, failure, `call:${operation}`);
|
|
297
401
|
const length = toNumber(responseLength[0], "call response length");
|
|
298
402
|
if (length > response.byteLength)
|
|
299
|
-
throw
|
|
300
|
-
const bytes = Uint8Array.from(response.subarray(0, length));
|
|
301
|
-
if (status !== STATUS_OK)
|
|
302
|
-
throw operationError(status, bytes, `call:${operation}`);
|
|
403
|
+
throw ReactorError.fromCode("Protocol", "native call response exceeded its declared buffer");
|
|
303
404
|
try {
|
|
304
|
-
|
|
405
|
+
const reply = JSON.parse(new TextDecoder().decode(response.subarray(0, length)));
|
|
406
|
+
return reply;
|
|
305
407
|
}
|
|
306
408
|
catch (cause) {
|
|
307
|
-
throw
|
|
409
|
+
throw ReactorError.fromCode("Protocol", "native call returned invalid JSON", {
|
|
410
|
+
detail: cause,
|
|
411
|
+
});
|
|
308
412
|
}
|
|
309
413
|
});
|
|
310
414
|
}
|
|
311
415
|
async send(channel, bytes) {
|
|
312
416
|
if (bytes.byteLength > MAX_MESSAGE_BYTES)
|
|
313
|
-
throw
|
|
417
|
+
throw ReactorError.fromCode("Overflow", "native data channel message exceeds 262144 bytes", {
|
|
314
418
|
outcome: "not-submitted",
|
|
315
419
|
});
|
|
316
|
-
return this.withHandle(
|
|
317
|
-
const
|
|
420
|
+
return this.withHandle(FAILURE_BYTES + bytes.byteLength, async (handle) => {
|
|
421
|
+
const failure = Buffer.alloc(FAILURE_BYTES);
|
|
318
422
|
const input = Buffer.from(bytes);
|
|
319
423
|
let status;
|
|
320
424
|
try {
|
|
@@ -323,89 +427,116 @@ export class NativeBridge {
|
|
|
323
427
|
channel === "control" ? 0 : 1,
|
|
324
428
|
input,
|
|
325
429
|
input.byteLength,
|
|
326
|
-
|
|
327
|
-
error.byteLength,
|
|
328
|
-
errorLength,
|
|
430
|
+
failure,
|
|
329
431
|
]);
|
|
330
432
|
}
|
|
331
433
|
catch (cause) {
|
|
332
|
-
throw
|
|
434
|
+
throw ReactorError.fromCode("Native", `native ${channel} send completion failed`, {
|
|
333
435
|
detail: cause,
|
|
334
436
|
outcome: "unknown",
|
|
335
437
|
});
|
|
336
438
|
}
|
|
337
|
-
const length = toNumber(errorLength[0], "send error length");
|
|
338
|
-
if (length > error.byteLength)
|
|
339
|
-
throw new ReactorError("Protocol", "native send error exceeded its declared buffer");
|
|
340
439
|
if (status !== STATUS_OK)
|
|
341
|
-
throw
|
|
440
|
+
throw failureOf(status, failure, `send:${channel}`, { channel });
|
|
342
441
|
});
|
|
343
442
|
}
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
if (
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
}
|
|
367
|
-
if (this.closed)
|
|
368
|
-
return { _tag: "Closed" };
|
|
369
|
-
if (status === STATUS_AGAIN)
|
|
370
|
-
return { _tag: "Again" };
|
|
371
|
-
if (status === STATUS_CLOSED)
|
|
372
|
-
return { _tag: "Closed" };
|
|
373
|
-
if (status !== STATUS_BUFFER_TOO_SMALL)
|
|
374
|
-
throw new ReactorError("Native", `native event poll failed with status ${status}`);
|
|
375
|
-
const length = toNumber(needed[0], "packet length");
|
|
376
|
-
if (length < 4 || length > MAX_PACKET_BYTES)
|
|
377
|
-
throw new ReactorError("Overflow", `native packet size ${length} exceeds the local bound`);
|
|
378
|
-
const output = Buffer.allocUnsafe(length), actual = [0];
|
|
379
|
-
try {
|
|
380
|
-
status = await asyncStatus(fn, [handle, 0, output, output.byteLength, actual]);
|
|
381
|
-
}
|
|
382
|
-
catch (cause) {
|
|
383
|
-
throw new ReactorError("Native", "native packet copy could not execute", {
|
|
384
|
-
detail: cause,
|
|
385
|
-
});
|
|
386
|
-
}
|
|
387
|
-
if (this.closed || status === STATUS_CLOSED)
|
|
388
|
-
return { _tag: "Closed" };
|
|
389
|
-
if (status !== STATUS_OK)
|
|
390
|
-
throw new ReactorError("Native", `native packet copy failed with status ${status}`);
|
|
391
|
-
const actualLength = toNumber(actual[0], "copied packet length");
|
|
392
|
-
if (actualLength !== length)
|
|
393
|
-
throw new ReactorError("Protocol", "native packet changed between size and copy polls");
|
|
394
|
-
return { _tag: "Packet", packet: parsePacket(Uint8Array.from(output)) };
|
|
395
|
-
});
|
|
396
|
-
}
|
|
397
|
-
finally {
|
|
398
|
-
this.readers.delete(fn);
|
|
443
|
+
/** Synchronous and nonblocking; call from the readiness callback until empty. */
|
|
444
|
+
takeEvent() {
|
|
445
|
+
const handle = this.handle;
|
|
446
|
+
if (this.closed || handle === undefined)
|
|
447
|
+
return null;
|
|
448
|
+
for (;;) {
|
|
449
|
+
const status = this.api.takeEvent(handle, this.event, this.event.byteLength, this.eventLength);
|
|
450
|
+
if (status === STATUS_AGAIN)
|
|
451
|
+
return undefined;
|
|
452
|
+
if (status === STATUS_CLOSED)
|
|
453
|
+
return null;
|
|
454
|
+
const length = toNumber(this.eventLength[0], "event length");
|
|
455
|
+
if (status === STATUS_OK) {
|
|
456
|
+
if (length > this.event.byteLength)
|
|
457
|
+
throw ReactorError.fromCode("Protocol", "native event exceeded its declared buffer");
|
|
458
|
+
return parsePacket(this.event.subarray(0, length));
|
|
459
|
+
}
|
|
460
|
+
if (status !== STATUS_BUFFER_TOO_SMALL)
|
|
461
|
+
throw ReactorError.fromCode("Native", `native event take failed with status ${status}`);
|
|
462
|
+
if (length <= this.event.byteLength || length > MAX_EVENT_BYTES)
|
|
463
|
+
throw overSized("event", length);
|
|
464
|
+
this.event = new Uint8Array(length);
|
|
399
465
|
}
|
|
400
466
|
}
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
467
|
+
/** Synchronous and nonblocking; the returned bytes belong to the caller. */
|
|
468
|
+
takeVideo() {
|
|
469
|
+
const handle = this.handle;
|
|
470
|
+
if (this.closed || handle === undefined)
|
|
471
|
+
return null;
|
|
472
|
+
const header = new DataView(this.videoHeader.buffer);
|
|
473
|
+
for (;;) {
|
|
474
|
+
const status = this.api.takeVideo(handle, this.videoHeader, this.nextVideo, this.nextVideo.byteLength, this.metadata, this.metadata.byteLength);
|
|
475
|
+
if (status === STATUS_AGAIN)
|
|
476
|
+
return undefined;
|
|
477
|
+
if (status === STATUS_CLOSED)
|
|
478
|
+
return null;
|
|
479
|
+
const dataLength = header.getUint32(8, true), metadataLength = header.getUint32(12, true);
|
|
480
|
+
if (status === STATUS_OK) {
|
|
481
|
+
const data = this.nextVideo;
|
|
482
|
+
this.nextVideo = new Uint8Array(dataLength);
|
|
483
|
+
return {
|
|
484
|
+
track: header.getUint32(32, true),
|
|
485
|
+
width: header.getUint32(0, true),
|
|
486
|
+
height: header.getUint32(4, true),
|
|
487
|
+
frameId: header.getBigUint64(16, true),
|
|
488
|
+
timestampMicros: header.getBigUint64(24, true),
|
|
489
|
+
sequence: header.getBigUint64(40, true),
|
|
490
|
+
// A smaller frame than its predecessor leaves slack; never expose it.
|
|
491
|
+
data: dataLength === data.byteLength ? data : data.slice(0, dataLength),
|
|
492
|
+
metadata: this.metadata.slice(0, metadataLength),
|
|
493
|
+
};
|
|
494
|
+
}
|
|
495
|
+
if (status !== STATUS_BUFFER_TOO_SMALL)
|
|
496
|
+
throw ReactorError.fromCode("Native", `native video take failed with status ${status}`);
|
|
497
|
+
if (dataLength + metadataLength > MAX_VIDEO_BYTES)
|
|
498
|
+
throw overSized("video frame", dataLength + metadataLength);
|
|
499
|
+
const growData = dataLength > this.nextVideo.byteLength, growMetadata = metadataLength > this.metadata.byteLength;
|
|
500
|
+
if (!growData && !growMetadata)
|
|
501
|
+
throw ReactorError.fromCode("Protocol", "native video take refused a fitting frame");
|
|
502
|
+
if (growData)
|
|
503
|
+
this.nextVideo = new Uint8Array(dataLength);
|
|
504
|
+
if (growMetadata)
|
|
505
|
+
this.metadata = new Uint8Array(metadataLength);
|
|
506
|
+
}
|
|
406
507
|
}
|
|
407
|
-
|
|
408
|
-
|
|
508
|
+
/** Synchronous and nonblocking; the returned samples belong to the caller. */
|
|
509
|
+
takeAudio() {
|
|
510
|
+
const handle = this.handle;
|
|
511
|
+
if (this.closed || handle === undefined)
|
|
512
|
+
return null;
|
|
513
|
+
const header = new DataView(this.audioHeader.buffer);
|
|
514
|
+
for (;;) {
|
|
515
|
+
const status = this.api.takeAudio(handle, this.audioHeader, this.nextAudio, this.nextAudio.length);
|
|
516
|
+
if (status === STATUS_AGAIN)
|
|
517
|
+
return undefined;
|
|
518
|
+
if (status === STATUS_CLOSED)
|
|
519
|
+
return null;
|
|
520
|
+
const samples = header.getUint32(8, true);
|
|
521
|
+
if (status === STATUS_OK) {
|
|
522
|
+
const pcm = this.nextAudio;
|
|
523
|
+
this.nextAudio = new Int16Array(samples);
|
|
524
|
+
return {
|
|
525
|
+
sampleRate: header.getUint32(0, true),
|
|
526
|
+
channels: header.getUint32(4, true),
|
|
527
|
+
track: header.getUint32(12, true),
|
|
528
|
+
sequence: header.getBigUint64(16, true),
|
|
529
|
+
samples: samples === pcm.length ? pcm : pcm.slice(0, samples),
|
|
530
|
+
};
|
|
531
|
+
}
|
|
532
|
+
if (status !== STATUS_BUFFER_TOO_SMALL)
|
|
533
|
+
throw ReactorError.fromCode("Native", `native audio take failed with status ${status}`);
|
|
534
|
+
if (samples > MAX_AUDIO_SAMPLES)
|
|
535
|
+
throw overSized("audio block", samples);
|
|
536
|
+
if (samples <= this.nextAudio.length)
|
|
537
|
+
throw ReactorError.fromCode("Protocol", "native audio take refused a fitting block");
|
|
538
|
+
this.nextAudio = new Int16Array(samples);
|
|
539
|
+
}
|
|
409
540
|
}
|
|
410
541
|
close() {
|
|
411
542
|
if (this.closed)
|
|
@@ -425,26 +556,40 @@ export class NativeBridge {
|
|
|
425
556
|
this.shutdownTask = this.finishShutdown(handle);
|
|
426
557
|
return this.shutdownTask;
|
|
427
558
|
}
|
|
559
|
+
/**
|
|
560
|
+
* The host stopped waiting for this bridge's join. The join still owns the
|
|
561
|
+
* handle: destruction and unregistration run only once it completes.
|
|
562
|
+
*/
|
|
563
|
+
retain() {
|
|
564
|
+
const task = this.shutdownTask;
|
|
565
|
+
if (task === undefined || retained.has(this))
|
|
566
|
+
return;
|
|
567
|
+
retained.add(this);
|
|
568
|
+
void task.then(() => retained.delete(this), () => undefined);
|
|
569
|
+
}
|
|
428
570
|
async finishShutdown(handle) {
|
|
429
|
-
// close() fences new host admission
|
|
430
|
-
//
|
|
431
|
-
//
|
|
571
|
+
// close() fences new host admission. Drain all host foreign calls before
|
|
572
|
+
// joining and destroying the native owner. A native owner join cannot see
|
|
573
|
+
// work that is still queued in Koffi.
|
|
432
574
|
await Promise.all(this.active);
|
|
433
|
-
const
|
|
575
|
+
const failure = Buffer.alloc(FAILURE_BYTES);
|
|
434
576
|
let status;
|
|
435
577
|
try {
|
|
436
|
-
|
|
578
|
+
// Asynchronous on purpose: the join waits for a notifier that may be
|
|
579
|
+
// blocked until this thread runs its readiness callback.
|
|
580
|
+
status = await asyncStatus(this.api.shutdown, [handle, failure]);
|
|
437
581
|
}
|
|
438
582
|
catch (cause) {
|
|
439
|
-
throw
|
|
583
|
+
throw ReactorError.fromCode("Shutdown", "native WebRTC owner join could not execute; handle retained", { detail: cause });
|
|
584
|
+
}
|
|
585
|
+
if (status !== STATUS_OK) {
|
|
586
|
+
const error = failureOf(status, failure, "shutdown");
|
|
587
|
+
throw ReactorError.fromCode("Shutdown", error.message, error.context);
|
|
440
588
|
}
|
|
441
|
-
const length = toNumber(errorLength[0], "shutdown error length");
|
|
442
|
-
if (length > error.byteLength)
|
|
443
|
-
throw new ReactorError("Shutdown", "native shutdown error exceeded its declared buffer");
|
|
444
|
-
if (status !== STATUS_OK)
|
|
445
|
-
throw new ReactorError("Shutdown", operationError(status, Uint8Array.from(error.subarray(0, length)), "shutdown").message);
|
|
446
589
|
this.api.destroy(handle);
|
|
447
590
|
this.handle = undefined;
|
|
591
|
+
// The notifier thread is joined: nothing can invoke the callback again.
|
|
592
|
+
this.api.unregister(this.notify);
|
|
448
593
|
}
|
|
449
594
|
}
|
|
450
595
|
export const encodeNativeJson = (value) => Uint8Array.from(new TextEncoder().encode(JSON.stringify(value)));
|