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
|
@@ -0,0 +1,645 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The parent side of the isolated native host. Each peer forks its own child
|
|
3
|
+
* process when the factory makes it and drives the child's `NativePeer` over
|
|
4
|
+
* Effect RPC on one NodeWorker that never respawns. The parent keeps the
|
|
5
|
+
* session's credentials, allocation, correlation and termination; a child that
|
|
6
|
+
* crashes takes only its own connection generation with it.
|
|
7
|
+
*/
|
|
8
|
+
import { fork } from "node:child_process";
|
|
9
|
+
import { fileURLToPath } from "node:url";
|
|
10
|
+
import * as NodeWorker from "@effect/platform-node/NodeWorker";
|
|
11
|
+
import * as Cause from "effect/Cause";
|
|
12
|
+
import * as Context from "effect/Context";
|
|
13
|
+
import * as Deferred from "effect/Deferred";
|
|
14
|
+
import * as Effect from "effect/Effect";
|
|
15
|
+
import * as Exit from "effect/Exit";
|
|
16
|
+
import * as Fiber from "effect/Fiber";
|
|
17
|
+
import * as Layer from "effect/Layer";
|
|
18
|
+
import * as Option from "effect/Option";
|
|
19
|
+
import * as Predicate from "effect/Predicate";
|
|
20
|
+
import * as Queue from "effect/Queue";
|
|
21
|
+
import * as Schema from "effect/Schema";
|
|
22
|
+
import * as Scope from "effect/Scope";
|
|
23
|
+
import * as Stream from "effect/Stream";
|
|
24
|
+
import * as RpcClient from "effect/unstable/rpc/RpcClient";
|
|
25
|
+
import { RpcClientError } from "effect/unstable/rpc/RpcClientError";
|
|
26
|
+
import * as Worker from "effect/unstable/workers/Worker";
|
|
27
|
+
import { WorkerReceiveError, WorkerSendError } from "effect/unstable/workers/WorkerError";
|
|
28
|
+
import { PeerFactory, ReactorError } from "reactor-effect-client";
|
|
29
|
+
import { Observations, parsed } from "reactor-effect-client/host";
|
|
30
|
+
import { iceServers, validateNativeTracks } from "../peer.js";
|
|
31
|
+
import { eventFromWire, exact, fromWire, IsolatedRpcs } from "./protocol.js";
|
|
32
|
+
/**
|
|
33
|
+
* The compiled child entry. The path climbs to the package root, so it
|
|
34
|
+
* resolves identically from `dist/` and, in the package's own tests, from
|
|
35
|
+
* `src/`, which therefore run the built child.
|
|
36
|
+
*/
|
|
37
|
+
const childEntry = fileURLToPath(new URL("../../../dist/_internal/isolated/child.js", import.meta.url));
|
|
38
|
+
/** Per-reader bounds, as the in-process host sets them. */
|
|
39
|
+
const VIDEO_READER = { capacity: 4, maxBytes: 64 * 1024 * 1024 };
|
|
40
|
+
const AUDIO_READER = { capacity: 128, maxBytes: 4 * 1024 * 1024 };
|
|
41
|
+
/** Chunks the RPC client buffers per stream: a frame per chunk keeps credit at one frame. */
|
|
42
|
+
const FRAME_BUFFER = 1;
|
|
43
|
+
const EVENT_BUFFER = 16;
|
|
44
|
+
const SHUTDOWN = "shutdown native WebRTC";
|
|
45
|
+
/**
|
|
46
|
+
* Whether the protocol has handed the current call's request to its child.
|
|
47
|
+
* The client runs the protocol's send in the caller's context, so a call
|
|
48
|
+
* learns there whether a failure came before or after dispatch.
|
|
49
|
+
*/
|
|
50
|
+
class Dispatch extends Context.Service()("reactor-effect-native/isolated/Dispatch") {
|
|
51
|
+
}
|
|
52
|
+
const isClientError = (u) => Predicate.isTagged(u, "RpcClientError");
|
|
53
|
+
/**
|
|
54
|
+
* One child process and what its protocol observed. The host fences on it,
|
|
55
|
+
* and the package's tests read it; it is not a package export.
|
|
56
|
+
*/
|
|
57
|
+
export class Link {
|
|
58
|
+
entry;
|
|
59
|
+
child;
|
|
60
|
+
/**
|
|
61
|
+
* Set once the child can take no request. Every later request fails before
|
|
62
|
+
* dispatch: `Worker.send` would otherwise buffer it without bound for a
|
|
63
|
+
* worker that will never become ready again.
|
|
64
|
+
*/
|
|
65
|
+
fence;
|
|
66
|
+
exit;
|
|
67
|
+
exited = Deferred.makeUnsafe();
|
|
68
|
+
spawns = 0;
|
|
69
|
+
dispatched = 0;
|
|
70
|
+
/** Replies and chunks that arrived for requests nobody awaits any longer. */
|
|
71
|
+
late = 0;
|
|
72
|
+
/** Events and frames that arrived after the peer was retired. */
|
|
73
|
+
retired = 0;
|
|
74
|
+
/** The most chunks of one stream the parent held unacknowledged. */
|
|
75
|
+
maxUnacked = 0;
|
|
76
|
+
/** The most frames one chunk of a track stream carried. */
|
|
77
|
+
maxFramesPerChunk = 0;
|
|
78
|
+
unacked = new Map();
|
|
79
|
+
frameStreams = new Set();
|
|
80
|
+
constructor(entry) {
|
|
81
|
+
this.entry = entry;
|
|
82
|
+
}
|
|
83
|
+
spawn() {
|
|
84
|
+
this.spawns++;
|
|
85
|
+
const child = fork(this.entry, [], {
|
|
86
|
+
serialization: "advanced",
|
|
87
|
+
// Neither the parent's runtime flags nor its environment, which may hold
|
|
88
|
+
// credentials, reach the child.
|
|
89
|
+
execArgv: [],
|
|
90
|
+
env: {},
|
|
91
|
+
stdio: ["ignore", "ignore", "inherit", "ipc"],
|
|
92
|
+
});
|
|
93
|
+
this.child = child;
|
|
94
|
+
child.once("exit", (code, signal) => {
|
|
95
|
+
this.exit = { code, signal };
|
|
96
|
+
this.refuse();
|
|
97
|
+
Deferred.doneUnsafe(this.exited, Exit.void);
|
|
98
|
+
});
|
|
99
|
+
// A closed channel cannot carry another reply; the process must end too.
|
|
100
|
+
child.once("disconnect", () => {
|
|
101
|
+
this.refuse();
|
|
102
|
+
this.kill();
|
|
103
|
+
});
|
|
104
|
+
child.on("error", () => {
|
|
105
|
+
this.refuse();
|
|
106
|
+
// A child that never started emits no exit.
|
|
107
|
+
if (child.pid === undefined)
|
|
108
|
+
Deferred.doneUnsafe(this.exited, Exit.void);
|
|
109
|
+
});
|
|
110
|
+
return child;
|
|
111
|
+
}
|
|
112
|
+
refuse() {
|
|
113
|
+
this.fence ??= new RpcClientError({
|
|
114
|
+
reason: new WorkerSendError({ message: "native WebRTC child process is gone" }),
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
kill() {
|
|
118
|
+
if (this.child !== undefined && this.exit === undefined)
|
|
119
|
+
this.child.kill("SIGKILL");
|
|
120
|
+
}
|
|
121
|
+
request(id, tag) {
|
|
122
|
+
if (tag === "Video" || tag === "Audio")
|
|
123
|
+
this.frameStreams.add(id);
|
|
124
|
+
}
|
|
125
|
+
chunk(id, values) {
|
|
126
|
+
if (this.frameStreams.has(id) && values > this.maxFramesPerChunk)
|
|
127
|
+
this.maxFramesPerChunk = values;
|
|
128
|
+
const held = (this.unacked.get(id) ?? 0) + 1;
|
|
129
|
+
this.unacked.set(id, held);
|
|
130
|
+
if (held > this.maxUnacked)
|
|
131
|
+
this.maxUnacked = held;
|
|
132
|
+
}
|
|
133
|
+
ack(id) {
|
|
134
|
+
const held = this.unacked.get(id);
|
|
135
|
+
if (held !== undefined)
|
|
136
|
+
this.unacked.set(id, held - 1);
|
|
137
|
+
}
|
|
138
|
+
settle(id) {
|
|
139
|
+
this.unacked.delete(id);
|
|
140
|
+
this.frameStreams.delete(id);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
/**
|
|
144
|
+
* A client protocol over one child that is spawned once and never again: a
|
|
145
|
+
* dead child fails every pending request and fences every later one, and no
|
|
146
|
+
* initial message is replayed. Replies route to the client id that `send`
|
|
147
|
+
* named, never a fixed one, so each generation's client receives its own.
|
|
148
|
+
*/
|
|
149
|
+
const protocol = (link) => RpcClient.Protocol.make(Effect.fnUntraced(function* (writeResponse, clientIds) {
|
|
150
|
+
const platform = yield* Worker.WorkerPlatform;
|
|
151
|
+
const worker = yield* platform
|
|
152
|
+
.spawn(0)
|
|
153
|
+
.pipe(Effect.provideService(Worker.Spawner, () => link.spawn()));
|
|
154
|
+
const routes = new Map();
|
|
155
|
+
const broadcast = (response) => Effect.forEach(clientIds, (clientId) => writeResponse(clientId, response), {
|
|
156
|
+
discard: true,
|
|
157
|
+
});
|
|
158
|
+
yield* worker
|
|
159
|
+
.run((response) => {
|
|
160
|
+
if (response._tag !== "Chunk" && response._tag !== "Exit")
|
|
161
|
+
return broadcast(response);
|
|
162
|
+
const id = String(response.requestId);
|
|
163
|
+
const clientId = routes.get(id);
|
|
164
|
+
if (clientId === undefined) {
|
|
165
|
+
link.late++;
|
|
166
|
+
return Effect.void;
|
|
167
|
+
}
|
|
168
|
+
if (response._tag === "Exit") {
|
|
169
|
+
routes.delete(id);
|
|
170
|
+
link.settle(id);
|
|
171
|
+
}
|
|
172
|
+
else
|
|
173
|
+
link.chunk(id, response.values.length);
|
|
174
|
+
return writeResponse(clientId, response);
|
|
175
|
+
})
|
|
176
|
+
.pipe(Effect.onExit(() => Effect.suspend(() => {
|
|
177
|
+
link.refuse();
|
|
178
|
+
link.kill();
|
|
179
|
+
routes.clear();
|
|
180
|
+
return broadcast({
|
|
181
|
+
_tag: "ClientProtocolError",
|
|
182
|
+
error: new RpcClientError({
|
|
183
|
+
reason: new WorkerReceiveError({ message: "native WebRTC child process exited" }),
|
|
184
|
+
}),
|
|
185
|
+
});
|
|
186
|
+
})), Effect.interruptible,
|
|
187
|
+
// Fork the child now, inside `make`, so it starts while the session allocates.
|
|
188
|
+
Effect.forkScoped({ startImmediately: true }));
|
|
189
|
+
const toClientError = (error) => new RpcClientError({ reason: error.reason });
|
|
190
|
+
const send = (clientId, request) => Effect.suspend(() => {
|
|
191
|
+
if (link.fence !== undefined)
|
|
192
|
+
return request._tag === "Request" ? Effect.fail(link.fence) : Effect.void;
|
|
193
|
+
switch (request._tag) {
|
|
194
|
+
case "Request": {
|
|
195
|
+
const id = String(request.id);
|
|
196
|
+
routes.set(id, clientId);
|
|
197
|
+
link.request(id, request.tag);
|
|
198
|
+
return worker.send(request).pipe(Effect.mapError((error) => {
|
|
199
|
+
routes.delete(id);
|
|
200
|
+
return toClientError(error);
|
|
201
|
+
}), Effect.andThen(Effect.serviceOption(Dispatch)), Effect.map((mark) => {
|
|
202
|
+
link.dispatched++;
|
|
203
|
+
if (Option.isSome(mark))
|
|
204
|
+
mark.value.sent = true;
|
|
205
|
+
}));
|
|
206
|
+
}
|
|
207
|
+
case "Ack":
|
|
208
|
+
link.ack(String(request.requestId));
|
|
209
|
+
break;
|
|
210
|
+
case "Interrupt":
|
|
211
|
+
// A reply to an interrupted request is late: nobody awaits it.
|
|
212
|
+
routes.delete(String(request.requestId));
|
|
213
|
+
link.settle(String(request.requestId));
|
|
214
|
+
break;
|
|
215
|
+
default:
|
|
216
|
+
break;
|
|
217
|
+
}
|
|
218
|
+
return Effect.mapError(worker.send(request), toClientError);
|
|
219
|
+
});
|
|
220
|
+
return {
|
|
221
|
+
send,
|
|
222
|
+
supportsAck: true,
|
|
223
|
+
supportsTransferables: false,
|
|
224
|
+
// The child's worker runner encodes with Schema's JSON codec, as every
|
|
225
|
+
// worker protocol does; the byte fields are declarations it passes on.
|
|
226
|
+
codecFor: Schema.toCodecJson,
|
|
227
|
+
};
|
|
228
|
+
}));
|
|
229
|
+
const closedError = (operation) => ReactorError.fromCode("Closed", "native WebRTC peer is closed", {
|
|
230
|
+
operation,
|
|
231
|
+
outcome: "not-submitted",
|
|
232
|
+
});
|
|
233
|
+
const videoFrame = (track, frame) => Object.freeze({
|
|
234
|
+
_tag: "VideoFrame",
|
|
235
|
+
track,
|
|
236
|
+
format: "BGRA",
|
|
237
|
+
width: frame.width,
|
|
238
|
+
height: frame.height,
|
|
239
|
+
frameId: frame.frameId,
|
|
240
|
+
timestampMicros: frame.timestampMicros,
|
|
241
|
+
// The native admission sequence, so a frame the child's handoff evicted
|
|
242
|
+
// is a gap here as it is for the in-process host.
|
|
243
|
+
sequence: frame.sequence,
|
|
244
|
+
data: exact(frame.data),
|
|
245
|
+
metadata: exact(frame.metadata),
|
|
246
|
+
});
|
|
247
|
+
const audioFrame = (track, frame) => Object.freeze({
|
|
248
|
+
_tag: "AudioFrame",
|
|
249
|
+
track,
|
|
250
|
+
sampleRate: frame.sampleRate,
|
|
251
|
+
channels: frame.channels,
|
|
252
|
+
sequence: frame.sequence,
|
|
253
|
+
samples: exact(frame.samples),
|
|
254
|
+
});
|
|
255
|
+
/**
|
|
256
|
+
* One peer in a child process of its own. Its child is spawned as the peer is
|
|
257
|
+
* made, so the child starts while the session allocates; every call waits for
|
|
258
|
+
* the child to open its native peer.
|
|
259
|
+
*/
|
|
260
|
+
export class IsolatedPeer {
|
|
261
|
+
environment;
|
|
262
|
+
nativeTracks = false;
|
|
263
|
+
rawMedia;
|
|
264
|
+
link;
|
|
265
|
+
scope = Scope.makeUnsafe();
|
|
266
|
+
client = Deferred.makeUnsafe();
|
|
267
|
+
opening;
|
|
268
|
+
video = new Map();
|
|
269
|
+
audio = new Map();
|
|
270
|
+
incoming = new Map();
|
|
271
|
+
media;
|
|
272
|
+
emit;
|
|
273
|
+
closed = false;
|
|
274
|
+
failureEmitted = false;
|
|
275
|
+
failure;
|
|
276
|
+
closing;
|
|
277
|
+
finished;
|
|
278
|
+
constructor(environment) {
|
|
279
|
+
this.environment = environment;
|
|
280
|
+
this.link = new Link(environment.entry);
|
|
281
|
+
this.opening = Effect.runForkWith(environment.platform)(this.open);
|
|
282
|
+
this.rawMedia = Object.freeze({
|
|
283
|
+
video: (name) => this.track(name, "video", this.video, (client) => Stream.map(client.Video({ name }, { streamBufferSize: FRAME_BUFFER }), (frame) => {
|
|
284
|
+
const owned = videoFrame(name, frame);
|
|
285
|
+
return [owned, owned.data.byteLength + owned.metadata.byteLength + name.length * 2];
|
|
286
|
+
})).pipe(Stream.unwrap),
|
|
287
|
+
audio: (name) => this.track(name, "audio", this.audio, (client) => Stream.map(client.Audio({ name }, { streamBufferSize: FRAME_BUFFER }), (frame) => {
|
|
288
|
+
const owned = audioFrame(name, frame);
|
|
289
|
+
return [owned, owned.samples.byteLength + name.length * 2];
|
|
290
|
+
})).pipe(Stream.unwrap),
|
|
291
|
+
snapshot: Effect.suspend(() => this.snapshot()),
|
|
292
|
+
});
|
|
293
|
+
}
|
|
294
|
+
/** The child's native peer is open: succeeds once `Open` has replied. */
|
|
295
|
+
get opened() {
|
|
296
|
+
return Effect.asVoid(Deferred.await(this.client));
|
|
297
|
+
}
|
|
298
|
+
get open() {
|
|
299
|
+
const self = this;
|
|
300
|
+
return Effect.gen(function* () {
|
|
301
|
+
const client = yield* RpcClient.make(IsolatedRpcs, { disableTracing: true }).pipe(Effect.provideServiceEffect(RpcClient.Protocol, protocol(self.link)), Scope.provide(self.scope));
|
|
302
|
+
const libraryPath = self.environment.libraryPath;
|
|
303
|
+
yield* client.Open(libraryPath === undefined ? {} : { libraryPath });
|
|
304
|
+
return client;
|
|
305
|
+
}).pipe(Effect.onExit((exit) => Deferred.done(self.client, Exit.isSuccess(exit) ? Exit.succeed(exit.value) : Exit.fail(self.openFailure(exit.cause)))),
|
|
306
|
+
// The outcome belongs to the peer's calls, which await it.
|
|
307
|
+
Effect.ignoreCause);
|
|
308
|
+
}
|
|
309
|
+
/** Nothing remote was dispatched while a child opened its peer, whatever failed. */
|
|
310
|
+
openFailure(cause) {
|
|
311
|
+
const found = Cause.findError(cause);
|
|
312
|
+
if (found._tag === "Success" &&
|
|
313
|
+
!isClientError(found.success) &&
|
|
314
|
+
!Predicate.isTagged(found.success, "WorkerError"))
|
|
315
|
+
return fromWire(found.success);
|
|
316
|
+
if (this.closed && Cause.hasInterruptsOnly(cause))
|
|
317
|
+
return closedError("open native WebRTC");
|
|
318
|
+
return ReactorError.fromCode("Native", "native WebRTC child process failed to start", {
|
|
319
|
+
operation: "open native WebRTC",
|
|
320
|
+
outcome: "not-submitted",
|
|
321
|
+
detail: Cause.squash(cause),
|
|
322
|
+
});
|
|
323
|
+
}
|
|
324
|
+
connected(operation) {
|
|
325
|
+
return Effect.suspend(() => this.closed ? Effect.fail(closedError(operation)) : Deferred.await(this.client));
|
|
326
|
+
}
|
|
327
|
+
/**
|
|
328
|
+
* A failure after the call reached the protocol. The child's own failure
|
|
329
|
+
* keeps its evidence. Otherwise the child or its channel failed, or the
|
|
330
|
+
* wait ended from the RPC side: after dispatch the outcome is unknown,
|
|
331
|
+
* before it the request was not submitted.
|
|
332
|
+
*/
|
|
333
|
+
lost(cause, operation, sent) {
|
|
334
|
+
const found = Cause.findError(cause);
|
|
335
|
+
if (found._tag === "Success" && !isClientError(found.success) && !Cause.isDone(found.success))
|
|
336
|
+
return fromWire(found.success);
|
|
337
|
+
const outcome = sent ? "unknown" : "not-submitted";
|
|
338
|
+
const detail = found._tag === "Success" ? found.success : Cause.squash(cause);
|
|
339
|
+
if (this.closed && this.link.exit === undefined)
|
|
340
|
+
return ReactorError.fromCode("Closed", "native WebRTC peer is closed", {
|
|
341
|
+
operation,
|
|
342
|
+
outcome,
|
|
343
|
+
detail,
|
|
344
|
+
});
|
|
345
|
+
return ReactorError.fromCode("Native", this.link.fence === undefined
|
|
346
|
+
? "native WebRTC child call failed"
|
|
347
|
+
: "native WebRTC child process exited", {
|
|
348
|
+
operation,
|
|
349
|
+
outcome,
|
|
350
|
+
detail: this.link.exit === undefined ? detail : { ...this.link.exit, cause: detail },
|
|
351
|
+
});
|
|
352
|
+
}
|
|
353
|
+
call(operation, run) {
|
|
354
|
+
const self = this;
|
|
355
|
+
return Effect.gen(function* () {
|
|
356
|
+
const client = yield* self.connected(operation);
|
|
357
|
+
const mark = { sent: false };
|
|
358
|
+
const exit = yield* Effect.exit(run(client).pipe(Effect.provideService(Dispatch, mark)));
|
|
359
|
+
if (Exit.isSuccess(exit))
|
|
360
|
+
return exit.value;
|
|
361
|
+
return yield* self.lost(exit.cause, operation, mark.sent);
|
|
362
|
+
});
|
|
363
|
+
}
|
|
364
|
+
readerOverflows() {
|
|
365
|
+
let overflows = 0n;
|
|
366
|
+
for (const feed of this.video.values())
|
|
367
|
+
overflows += feed.overflowCount;
|
|
368
|
+
for (const feed of this.audio.values())
|
|
369
|
+
overflows += feed.overflowCount;
|
|
370
|
+
return overflows;
|
|
371
|
+
}
|
|
372
|
+
snapshot() {
|
|
373
|
+
return this.call("native media snapshot", (client) => client.Snapshot()).pipe(Effect.map((pressure) => Object.freeze({
|
|
374
|
+
...pressure,
|
|
375
|
+
readerOverflows: pressure.readerOverflows + this.readerOverflows(),
|
|
376
|
+
})));
|
|
377
|
+
}
|
|
378
|
+
/**
|
|
379
|
+
* A track's readers share one observation, fed by one RPC stream from the
|
|
380
|
+
* child for this generation, which the first reader opens.
|
|
381
|
+
*/
|
|
382
|
+
track(name, kind, feeds, open) {
|
|
383
|
+
const self = this;
|
|
384
|
+
const bounds = kind === "video" ? VIDEO_READER : AUDIO_READER;
|
|
385
|
+
return Effect.gen(function* () {
|
|
386
|
+
yield* parsed(() => {
|
|
387
|
+
if (self.incoming.get(name) !== kind)
|
|
388
|
+
throw ReactorError.fromCode("InvalidInput", "native media requires a declared receive track of the requested kind", { outcome: "not-submitted" });
|
|
389
|
+
});
|
|
390
|
+
const existing = feeds.get(name);
|
|
391
|
+
if (existing !== undefined)
|
|
392
|
+
return existing.stream(bounds);
|
|
393
|
+
const feed = new Observations();
|
|
394
|
+
feeds.set(name, feed);
|
|
395
|
+
if (self.failure !== undefined)
|
|
396
|
+
feed.fail(self.failure);
|
|
397
|
+
else if (self.closed || self.media === undefined)
|
|
398
|
+
feed.end();
|
|
399
|
+
else
|
|
400
|
+
yield* Effect.forkIn(self.pump(feed, open), self.media, { startImmediately: true });
|
|
401
|
+
return feed.stream(bounds);
|
|
402
|
+
});
|
|
403
|
+
}
|
|
404
|
+
pump(feed, open) {
|
|
405
|
+
const self = this;
|
|
406
|
+
const operation = "native media";
|
|
407
|
+
return Effect.gen(function* () {
|
|
408
|
+
const client = yield* self.connected(operation);
|
|
409
|
+
const mark = { sent: false };
|
|
410
|
+
const exit = yield* Effect.exit(open(client).pipe(Stream.runForEach(([frame, bytes]) => Effect.sync(() => {
|
|
411
|
+
if (self.closed)
|
|
412
|
+
self.link.retired++;
|
|
413
|
+
else
|
|
414
|
+
feed.emit(frame, bytes);
|
|
415
|
+
})), Effect.provideService(Dispatch, mark)));
|
|
416
|
+
// The child ended the track because its peer closed.
|
|
417
|
+
if (Exit.isSuccess(exit))
|
|
418
|
+
feed.end();
|
|
419
|
+
else if (!Cause.hasInterruptsOnly(exit.cause))
|
|
420
|
+
feed.fail(self.lost(exit.cause, operation, mark.sent));
|
|
421
|
+
}).pipe(Effect.catch((error) => Effect.sync(() => feed.fail(error))));
|
|
422
|
+
}
|
|
423
|
+
prepare(servers, tracks, emit) {
|
|
424
|
+
const self = this;
|
|
425
|
+
const operation = "prepare native WebRTC";
|
|
426
|
+
return Effect.gen(function* () {
|
|
427
|
+
// The in-process host's own checks, before anything reaches the child.
|
|
428
|
+
const request = yield* parsed(() => {
|
|
429
|
+
validateNativeTracks(tracks);
|
|
430
|
+
return { servers: iceServers(servers), tracks: [...tracks] };
|
|
431
|
+
});
|
|
432
|
+
for (const track of tracks)
|
|
433
|
+
if (track.direction === "recvonly")
|
|
434
|
+
self.incoming.set(track.name, track.kind);
|
|
435
|
+
self.emit = emit;
|
|
436
|
+
self.media = yield* Effect.scope;
|
|
437
|
+
const client = yield* self.connected(operation);
|
|
438
|
+
const mark = { sent: false };
|
|
439
|
+
const items = yield* client
|
|
440
|
+
.Prepare(request, { asQueue: true, streamBufferSize: EVENT_BUFFER })
|
|
441
|
+
.pipe(Effect.provideService(Dispatch, mark));
|
|
442
|
+
const first = yield* Effect.exit(Queue.take(items));
|
|
443
|
+
if (Exit.isFailure(first))
|
|
444
|
+
return yield* self.lost(first.cause, operation, mark.sent);
|
|
445
|
+
const head = first.value;
|
|
446
|
+
if (head._tag !== "Prepared")
|
|
447
|
+
return yield* ReactorError.fromCode("Protocol", "isolated native child sent an event before its offer");
|
|
448
|
+
yield* Effect.forkScoped(self.deliver(items));
|
|
449
|
+
return Object.freeze({
|
|
450
|
+
sdp: head.sdp,
|
|
451
|
+
mapping: Object.freeze(head.mapping.map((entry) => Object.freeze({ ...entry }))),
|
|
452
|
+
});
|
|
453
|
+
});
|
|
454
|
+
}
|
|
455
|
+
/** The child's peer events, in order, until the peer retires. */
|
|
456
|
+
deliver(items) {
|
|
457
|
+
return Queue.takeAll(items).pipe(Effect.flatMap((batch) => Effect.sync(() => {
|
|
458
|
+
for (const item of batch)
|
|
459
|
+
if (item._tag === "Event")
|
|
460
|
+
this.receive(item.event);
|
|
461
|
+
})), Effect.forever, Effect.catchCause((cause) => Effect.sync(() => {
|
|
462
|
+
if (Cause.hasInterruptsOnly(cause))
|
|
463
|
+
return;
|
|
464
|
+
const found = Cause.findError(cause);
|
|
465
|
+
// The child ended its events after shutdown.
|
|
466
|
+
if (found._tag === "Success" && Cause.isDone(found.success))
|
|
467
|
+
return;
|
|
468
|
+
this.fail(this.lost(cause, "native WebRTC events", true));
|
|
469
|
+
})));
|
|
470
|
+
}
|
|
471
|
+
receive(wire) {
|
|
472
|
+
if (this.closed) {
|
|
473
|
+
this.link.retired++;
|
|
474
|
+
return;
|
|
475
|
+
}
|
|
476
|
+
const event = eventFromWire(wire);
|
|
477
|
+
if (event.type === "error")
|
|
478
|
+
this.fail(event.error);
|
|
479
|
+
else
|
|
480
|
+
this.emit?.(event);
|
|
481
|
+
}
|
|
482
|
+
fail(error) {
|
|
483
|
+
if (this.closed)
|
|
484
|
+
return;
|
|
485
|
+
this.failure = error;
|
|
486
|
+
try {
|
|
487
|
+
if (!this.failureEmitted) {
|
|
488
|
+
this.failureEmitted = true;
|
|
489
|
+
this.emit?.({ type: "error", error });
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
finally {
|
|
493
|
+
this.close();
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
answer(sdp) {
|
|
497
|
+
return this.call("apply native SDP answer", (client) => client.Answer({ sdp }));
|
|
498
|
+
}
|
|
499
|
+
send(channel, bytes) {
|
|
500
|
+
return this.call(`send native ${channel}`, (client) => client.Send({ channel, bytes }));
|
|
501
|
+
}
|
|
502
|
+
direction(name, active) {
|
|
503
|
+
return this.call("set native transceiver direction", (client) => client.Direction({ name, active }));
|
|
504
|
+
}
|
|
505
|
+
maxBitrate(name, bitsPerSecond) {
|
|
506
|
+
return this.call("set native sender bitrate", (client) => client.MaxBitrate({ name, bitsPerSecond }));
|
|
507
|
+
}
|
|
508
|
+
get stats() {
|
|
509
|
+
return this.call("native WebRTC statistics", (client) => client.Stats()).pipe(Effect.map((stats) => Object.freeze([...stats])));
|
|
510
|
+
}
|
|
511
|
+
lease() {
|
|
512
|
+
throw ReactorError.fromCode("UnsupportedCapability", "native WebRTC exposes owned decoded samples, not browser MediaStreamTrack leases", { outcome: "not-submitted" });
|
|
513
|
+
}
|
|
514
|
+
release() {
|
|
515
|
+
/* lease never succeeds on this host. */
|
|
516
|
+
}
|
|
517
|
+
/** Only the absence of a track crosses the process boundary. */
|
|
518
|
+
replace(name, track) {
|
|
519
|
+
if (track !== null)
|
|
520
|
+
return Effect.fail(ReactorError.fromCode("UnsupportedCapability", "native WebRTC does not accept browser MediaStreamTrack publication", { outcome: "not-submitted" }));
|
|
521
|
+
return this.call("replace native track", (client) => client.Replace({ name }));
|
|
522
|
+
}
|
|
523
|
+
/**
|
|
524
|
+
* Retire the peer in this process at once: no later event or frame reaches
|
|
525
|
+
* the session, and every later call fails before dispatch. The child's
|
|
526
|
+
* native peer closes when `shutdown` runs.
|
|
527
|
+
*/
|
|
528
|
+
close() {
|
|
529
|
+
if (this.closed)
|
|
530
|
+
return;
|
|
531
|
+
this.closed = true;
|
|
532
|
+
this.emit = undefined;
|
|
533
|
+
for (const feed of this.video.values()) {
|
|
534
|
+
if (this.failure === undefined)
|
|
535
|
+
feed.end();
|
|
536
|
+
else
|
|
537
|
+
feed.fail(this.failure);
|
|
538
|
+
}
|
|
539
|
+
for (const feed of this.audio.values()) {
|
|
540
|
+
if (this.failure === undefined)
|
|
541
|
+
feed.end();
|
|
542
|
+
else
|
|
543
|
+
feed.fail(this.failure);
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
/**
|
|
547
|
+
* Close, then ask the child to close and join its native peer, bounded by
|
|
548
|
+
* `shutdownTimeout`. A child that has not exited by then is killed, and the
|
|
549
|
+
* shutdown fails with `Shutdown`; so does a join the child reports failed.
|
|
550
|
+
* Either way the child is gone once this completes, and its native state
|
|
551
|
+
* with it. Idempotent: later runs await the first.
|
|
552
|
+
*/
|
|
553
|
+
get shutdown() {
|
|
554
|
+
return Effect.suspend(() => {
|
|
555
|
+
if (this.finished !== undefined)
|
|
556
|
+
return Deferred.await(this.finished);
|
|
557
|
+
const finished = Deferred.makeUnsafe();
|
|
558
|
+
this.finished = finished;
|
|
559
|
+
return this.windDown.pipe(Effect.exit, Effect.flatMap((exit) => Deferred.done(finished, exit)), Effect.andThen(Deferred.await(finished)), Effect.ensuring(Effect.sync(() => this.environment.release(this))), Effect.uninterruptible);
|
|
560
|
+
});
|
|
561
|
+
}
|
|
562
|
+
get windDown() {
|
|
563
|
+
const self = this;
|
|
564
|
+
return Effect.gen(function* () {
|
|
565
|
+
self.close();
|
|
566
|
+
const joined = yield* self.join.pipe(Effect.exit, Effect.interruptible, Effect.timeoutOption(self.environment.shutdownTimeout));
|
|
567
|
+
yield* self.terminate;
|
|
568
|
+
if (Option.isNone(joined))
|
|
569
|
+
return yield* ReactorError.fromCode("Shutdown", "native child shutdown exceeded its deadline; child process killed", { operation: SHUTDOWN });
|
|
570
|
+
if (Exit.isFailure(joined.value))
|
|
571
|
+
return yield* Effect.failCause(joined.value.cause);
|
|
572
|
+
});
|
|
573
|
+
}
|
|
574
|
+
/** The graceful path: the child joins its native peer, then exits when its runner closes. */
|
|
575
|
+
get join() {
|
|
576
|
+
const self = this;
|
|
577
|
+
return Effect.gen(function* () {
|
|
578
|
+
const opened = yield* Effect.exit(Deferred.await(self.client));
|
|
579
|
+
// A child that never opened, or is gone, holds no native peer to join.
|
|
580
|
+
if (Exit.isFailure(opened) || self.link.fence !== undefined)
|
|
581
|
+
return;
|
|
582
|
+
const mark = { sent: false };
|
|
583
|
+
const shut = yield* Effect.exit(opened.value.Shutdown().pipe(Effect.provideService(Dispatch, mark)));
|
|
584
|
+
if (Exit.isFailure(shut)) {
|
|
585
|
+
const error = self.lost(shut.cause, SHUTDOWN, mark.sent);
|
|
586
|
+
// Refused before dispatch: the child was already gone.
|
|
587
|
+
if (!mark.sent && self.link.exit !== undefined)
|
|
588
|
+
return;
|
|
589
|
+
return yield* ReactorError.fromCode("Shutdown", self.link.exit === undefined
|
|
590
|
+
? "native child shutdown failed; child process killed"
|
|
591
|
+
: "native child process exited before its shutdown completed", { operation: SHUTDOWN, detail: error });
|
|
592
|
+
}
|
|
593
|
+
self.closeScope();
|
|
594
|
+
yield* Deferred.await(self.link.exited);
|
|
595
|
+
});
|
|
596
|
+
}
|
|
597
|
+
/** The child is gone, by exit or by kill, and its client and worker are released. */
|
|
598
|
+
get terminate() {
|
|
599
|
+
const self = this;
|
|
600
|
+
return Effect.gen(function* () {
|
|
601
|
+
yield* Fiber.interrupt(self.opening);
|
|
602
|
+
self.link.kill();
|
|
603
|
+
if (self.link.child !== undefined)
|
|
604
|
+
yield* Deferred.await(self.link.exited);
|
|
605
|
+
yield* Fiber.await(self.closeScope());
|
|
606
|
+
});
|
|
607
|
+
}
|
|
608
|
+
closeScope() {
|
|
609
|
+
this.closing ??= Effect.runFork(Scope.close(this.scope, Exit.void));
|
|
610
|
+
return this.closing;
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
/**
|
|
614
|
+
* The isolated factory: preflight by spawning a probe child that loads and
|
|
615
|
+
* verifies the library and opens a native peer, then shuts it down.
|
|
616
|
+
*/
|
|
617
|
+
export const factory = (options) => Effect.gen(function* () {
|
|
618
|
+
const platform = yield* Layer.build(NodeWorker.layerPlatform);
|
|
619
|
+
const live = new Set();
|
|
620
|
+
const environment = {
|
|
621
|
+
platform,
|
|
622
|
+
libraryPath: options.libraryPath,
|
|
623
|
+
shutdownTimeout: options.shutdownTimeout,
|
|
624
|
+
entry: childEntry,
|
|
625
|
+
release: (peer) => live.delete(peer),
|
|
626
|
+
};
|
|
627
|
+
const probe = new IsolatedPeer(environment);
|
|
628
|
+
const opened = yield* Effect.exit(probe.opened);
|
|
629
|
+
yield* probe.shutdown;
|
|
630
|
+
if (Exit.isFailure(opened))
|
|
631
|
+
return yield* Effect.failCause(opened.cause);
|
|
632
|
+
// A peer its owner never shut down still ends with the layer.
|
|
633
|
+
yield* Effect.addFinalizer(() => Effect.forEach([...live], (peer) => Effect.exit(peer.shutdown), {
|
|
634
|
+
concurrency: "unbounded",
|
|
635
|
+
discard: true,
|
|
636
|
+
}));
|
|
637
|
+
return PeerFactory.of({
|
|
638
|
+
make: () => {
|
|
639
|
+
const peer = new IsolatedPeer(environment);
|
|
640
|
+
live.add(peer);
|
|
641
|
+
return peer;
|
|
642
|
+
},
|
|
643
|
+
});
|
|
644
|
+
});
|
|
645
|
+
//# sourceMappingURL=host.js.map
|