@livedesk/hub 0.1.26 → 0.1.28
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/package.json +2 -2
- package/src/agents/agent-manager.js +27 -11
- package/src/agents/codex-agent-runtime.js +325 -41
- package/src/frame-packet-contract.mjs +427 -0
- package/src/live-capture-transition-retry.mjs +297 -0
- package/src/mode4-atlas-pool.js +17 -3
- package/src/mode4-atlas-worker.js +134 -21
- package/src/mode4-atlas.js +180 -16
- package/src/remote-audio-subscription-contract.mjs +109 -0
- package/src/remote-audio-subscription-contract.test.mjs +72 -0
- package/src/remote-hub.js +6374 -3890
- package/src/server.js +3234 -2202
- package/src/transport/agent-binary-ingress.js +810 -0
- package/src/transport/relay-hub-control.js +372 -56
- package/src/transport/udp-hub-transport.js +215 -51
- package/src/transport/udp-protocol.js +302 -70
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
function finitePositive(value, fallback) {
|
|
2
|
+
const number = Number(value);
|
|
3
|
+
return Number.isFinite(number) && number > 0 ? number : fallback;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
function clamp(value, minimum, maximum) {
|
|
7
|
+
return Math.max(minimum, Math.min(maximum, value));
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Owns the Hub-to-browser retry that follows an exact native capture stop.
|
|
12
|
+
*
|
|
13
|
+
* Contract:
|
|
14
|
+
* - one owner per WebSocket/device intent generation;
|
|
15
|
+
* - the native stop promise is the event source (no polling loop);
|
|
16
|
+
* - retries and total elapsed time are both bounded;
|
|
17
|
+
* - supersede, close, success, and terminal failure synchronously release every timer.
|
|
18
|
+
*/
|
|
19
|
+
export function createLiveCaptureTransitionRetryCoordinator(options = {}) {
|
|
20
|
+
const now = typeof options.now === 'function' ? options.now : Date.now;
|
|
21
|
+
const setTimer = typeof options.setTimer === 'function' ? options.setTimer : setTimeout;
|
|
22
|
+
const clearTimer = typeof options.clearTimer === 'function' ? options.clearTimer : clearTimeout;
|
|
23
|
+
const maxAttempts = Math.max(1, Math.floor(finitePositive(options.maxAttempts, 3)));
|
|
24
|
+
const minimumRetryAfterMs = Math.max(1, finitePositive(options.minimumRetryAfterMs, 25));
|
|
25
|
+
const maximumRetryAfterMs = Math.max(
|
|
26
|
+
minimumRetryAfterMs,
|
|
27
|
+
finitePositive(options.maximumRetryAfterMs, 250)
|
|
28
|
+
);
|
|
29
|
+
const minimumTotalMs = Math.max(1, finitePositive(options.minimumTotalMs, 2_000));
|
|
30
|
+
const maximumTotalMs = Math.max(minimumTotalMs, finitePositive(options.maximumTotalMs, 30_000));
|
|
31
|
+
const completionMarginMs = Math.max(0, Number(options.completionMarginMs ?? 2_000) || 0);
|
|
32
|
+
const clientStates = new WeakMap();
|
|
33
|
+
const activeOwners = new Set();
|
|
34
|
+
|
|
35
|
+
function stateFor(client) {
|
|
36
|
+
if (!client || (typeof client !== 'object' && typeof client !== 'function')) {
|
|
37
|
+
throw new TypeError('A capture transition retry requires an exact client owner.');
|
|
38
|
+
}
|
|
39
|
+
let state = clientStates.get(client);
|
|
40
|
+
if (!state) {
|
|
41
|
+
state = {
|
|
42
|
+
intentGenerations: new Map(),
|
|
43
|
+
owners: new Map()
|
|
44
|
+
};
|
|
45
|
+
clientStates.set(client, state);
|
|
46
|
+
}
|
|
47
|
+
return state;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function ownerIsCurrent(owner) {
|
|
51
|
+
if (!owner || owner.cancelled) {
|
|
52
|
+
return false;
|
|
53
|
+
}
|
|
54
|
+
const state = clientStates.get(owner.client);
|
|
55
|
+
return !!state
|
|
56
|
+
&& state.owners.get(owner.deviceId) === owner
|
|
57
|
+
&& state.intentGenerations.get(owner.deviceId) === owner.intentGeneration;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function releaseOwner(owner, reason = 'completed') {
|
|
61
|
+
if (!owner || owner.cancelled) {
|
|
62
|
+
return false;
|
|
63
|
+
}
|
|
64
|
+
owner.cancelled = true;
|
|
65
|
+
owner.cancelReason = reason;
|
|
66
|
+
owner.waitToken += 1;
|
|
67
|
+
if (owner.timer) {
|
|
68
|
+
clearTimer(owner.timer);
|
|
69
|
+
owner.timer = null;
|
|
70
|
+
}
|
|
71
|
+
const state = clientStates.get(owner.client);
|
|
72
|
+
if (state?.owners.get(owner.deviceId) === owner) {
|
|
73
|
+
state.owners.delete(owner.deviceId);
|
|
74
|
+
}
|
|
75
|
+
activeOwners.delete(owner);
|
|
76
|
+
return true;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function failOwner(owner, error, details = {}) {
|
|
80
|
+
if (!ownerIsCurrent(owner)) {
|
|
81
|
+
return false;
|
|
82
|
+
}
|
|
83
|
+
const onTerminal = owner.onTerminal;
|
|
84
|
+
const terminal = {
|
|
85
|
+
deviceId: owner.deviceId,
|
|
86
|
+
intentGeneration: owner.intentGeneration,
|
|
87
|
+
attemptCount: owner.attemptCount,
|
|
88
|
+
deadlineAtEpochMs: owner.deadlineAtEpochMs,
|
|
89
|
+
error,
|
|
90
|
+
...details
|
|
91
|
+
};
|
|
92
|
+
releaseOwner(owner, error);
|
|
93
|
+
try {
|
|
94
|
+
onTerminal?.(terminal);
|
|
95
|
+
} catch {
|
|
96
|
+
// The retry owner is already terminal. UI notification failure cannot
|
|
97
|
+
// resurrect it or retain a timer.
|
|
98
|
+
}
|
|
99
|
+
return true;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function beginIntent(client, deviceId) {
|
|
103
|
+
const normalizedDeviceId = String(deviceId || '').trim();
|
|
104
|
+
if (!normalizedDeviceId) {
|
|
105
|
+
throw new TypeError('A capture transition retry requires a device id.');
|
|
106
|
+
}
|
|
107
|
+
const state = stateFor(client);
|
|
108
|
+
releaseOwner(state.owners.get(normalizedDeviceId), 'superseded');
|
|
109
|
+
const current = Number(state.intentGenerations.get(normalizedDeviceId) || 0);
|
|
110
|
+
const next = Number.isSafeInteger(current) && current > 0 ? current + 1 : 1;
|
|
111
|
+
state.intentGenerations.set(normalizedDeviceId, next);
|
|
112
|
+
return next;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function complete(client, deviceId, intentGeneration, reason = 'completed') {
|
|
116
|
+
const normalizedDeviceId = String(deviceId || '').trim();
|
|
117
|
+
const state = clientStates.get(client);
|
|
118
|
+
const owner = state?.owners.get(normalizedDeviceId);
|
|
119
|
+
if (!owner
|
|
120
|
+
|| (Number.isSafeInteger(Number(intentGeneration))
|
|
121
|
+
&& Number(intentGeneration) > 0
|
|
122
|
+
&& owner.intentGeneration !== Number(intentGeneration))) {
|
|
123
|
+
return false;
|
|
124
|
+
}
|
|
125
|
+
return releaseOwner(owner, reason);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function cancelClient(client, reason = 'client-closed') {
|
|
129
|
+
const state = clientStates.get(client);
|
|
130
|
+
if (!state) {
|
|
131
|
+
return 0;
|
|
132
|
+
}
|
|
133
|
+
let cancelled = 0;
|
|
134
|
+
for (const owner of [...state.owners.values()]) {
|
|
135
|
+
if (releaseOwner(owner, reason)) {
|
|
136
|
+
cancelled += 1;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
state.intentGenerations.clear();
|
|
140
|
+
clientStates.delete(client);
|
|
141
|
+
return cancelled;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function schedule(client, request = {}) {
|
|
145
|
+
const deviceId = String(request.deviceId || '').trim();
|
|
146
|
+
const intentGeneration = Number(request.intentGeneration || 0);
|
|
147
|
+
const result = request.result && typeof request.result === 'object'
|
|
148
|
+
? request.result
|
|
149
|
+
: {};
|
|
150
|
+
const state = stateFor(client);
|
|
151
|
+
if (!deviceId
|
|
152
|
+
|| !Number.isSafeInteger(intentGeneration)
|
|
153
|
+
|| intentGeneration <= 0
|
|
154
|
+
|| state.intentGenerations.get(deviceId) !== intentGeneration
|
|
155
|
+
|| result.error !== 'CAPTURE_TRANSITION_IN_PROGRESS') {
|
|
156
|
+
return false;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
let owner = state.owners.get(deviceId);
|
|
160
|
+
if (owner && owner.intentGeneration !== intentGeneration) {
|
|
161
|
+
releaseOwner(owner, 'superseded');
|
|
162
|
+
owner = null;
|
|
163
|
+
}
|
|
164
|
+
const transitionTimeoutMs = clamp(
|
|
165
|
+
finitePositive(result.transitionTimeoutMs, 11_000),
|
|
166
|
+
minimumRetryAfterMs,
|
|
167
|
+
maximumTotalMs
|
|
168
|
+
);
|
|
169
|
+
if (!owner) {
|
|
170
|
+
const totalWindowMs = clamp(
|
|
171
|
+
transitionTimeoutMs + completionMarginMs,
|
|
172
|
+
minimumTotalMs,
|
|
173
|
+
maximumTotalMs
|
|
174
|
+
);
|
|
175
|
+
owner = {
|
|
176
|
+
client,
|
|
177
|
+
deviceId,
|
|
178
|
+
intentGeneration,
|
|
179
|
+
attemptCount: 0,
|
|
180
|
+
deadlineAtEpochMs: now() + totalWindowMs,
|
|
181
|
+
timer: null,
|
|
182
|
+
phase: 'created',
|
|
183
|
+
cancelled: false,
|
|
184
|
+
cancelReason: '',
|
|
185
|
+
waitToken: 0,
|
|
186
|
+
retry: request.retry,
|
|
187
|
+
onTerminal: request.onTerminal,
|
|
188
|
+
transitionTimeoutMs
|
|
189
|
+
};
|
|
190
|
+
state.owners.set(deviceId, owner);
|
|
191
|
+
activeOwners.add(owner);
|
|
192
|
+
} else {
|
|
193
|
+
owner.retry = request.retry;
|
|
194
|
+
owner.onTerminal = request.onTerminal;
|
|
195
|
+
owner.transitionTimeoutMs = transitionTimeoutMs;
|
|
196
|
+
if (owner.timer) {
|
|
197
|
+
clearTimer(owner.timer);
|
|
198
|
+
owner.timer = null;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
if (owner.attemptCount >= maxAttempts) {
|
|
203
|
+
failOwner(owner, 'CAPTURE_TRANSITION_ATTEMPT_LIMIT');
|
|
204
|
+
return false;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const retryAfterMs = clamp(
|
|
208
|
+
finitePositive(result.retryAfterMs, minimumRetryAfterMs),
|
|
209
|
+
minimumRetryAfterMs,
|
|
210
|
+
maximumRetryAfterMs
|
|
211
|
+
);
|
|
212
|
+
const waitToken = ++owner.waitToken;
|
|
213
|
+
owner.phase = 'waiting-stop';
|
|
214
|
+
const transitionPromise = result.transitionPromise
|
|
215
|
+
&& typeof result.transitionPromise.then === 'function'
|
|
216
|
+
? result.transitionPromise
|
|
217
|
+
: Promise.resolve({
|
|
218
|
+
captureStopConfirmed: false,
|
|
219
|
+
evidenceUnavailable: true
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
void Promise.resolve(transitionPromise).then(
|
|
223
|
+
settlement => {
|
|
224
|
+
if (!ownerIsCurrent(owner) || owner.waitToken !== waitToken) {
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
const remainingMs = owner.deadlineAtEpochMs - now();
|
|
228
|
+
const captureStopConfirmed = settlement?.captureStopConfirmed === true;
|
|
229
|
+
const requiredRetryBudgetMs = captureStopConfirmed
|
|
230
|
+
? retryAfterMs
|
|
231
|
+
: owner.transitionTimeoutMs + retryAfterMs;
|
|
232
|
+
if (remainingMs < requiredRetryBudgetMs) {
|
|
233
|
+
failOwner(owner, 'CAPTURE_TRANSITION_TIMEOUT', {
|
|
234
|
+
captureStopConfirmed,
|
|
235
|
+
remainingMs: Math.max(0, remainingMs)
|
|
236
|
+
});
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
owner.phase = 'retry-delay';
|
|
240
|
+
owner.timer = setTimer(() => {
|
|
241
|
+
if (!ownerIsCurrent(owner) || owner.waitToken !== waitToken) {
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
244
|
+
owner.timer = null;
|
|
245
|
+
owner.phase = 'retrying';
|
|
246
|
+
owner.attemptCount += 1;
|
|
247
|
+
const retryContext = {
|
|
248
|
+
deviceId: owner.deviceId,
|
|
249
|
+
intentGeneration: owner.intentGeneration,
|
|
250
|
+
attemptCount: owner.attemptCount,
|
|
251
|
+
deadlineAtEpochMs: owner.deadlineAtEpochMs,
|
|
252
|
+
captureStopConfirmed
|
|
253
|
+
};
|
|
254
|
+
void Promise.resolve()
|
|
255
|
+
.then(() => owner.retry?.(retryContext))
|
|
256
|
+
.then(() => {
|
|
257
|
+
if (ownerIsCurrent(owner) && owner.phase === 'retrying') {
|
|
258
|
+
failOwner(owner, 'CAPTURE_TRANSITION_RETRY_NOT_SETTLED');
|
|
259
|
+
}
|
|
260
|
+
})
|
|
261
|
+
.catch(error => {
|
|
262
|
+
failOwner(owner, 'CAPTURE_TRANSITION_RETRY_FAILED', {
|
|
263
|
+
detail: error instanceof Error ? error.message : String(error || '')
|
|
264
|
+
});
|
|
265
|
+
});
|
|
266
|
+
}, Math.min(retryAfterMs, remainingMs));
|
|
267
|
+
owner.timer?.unref?.();
|
|
268
|
+
},
|
|
269
|
+
error => {
|
|
270
|
+
failOwner(owner, 'CAPTURE_TRANSITION_WAIT_FAILED', {
|
|
271
|
+
detail: error instanceof Error ? error.message : String(error || '')
|
|
272
|
+
});
|
|
273
|
+
}
|
|
274
|
+
);
|
|
275
|
+
return true;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function snapshot() {
|
|
279
|
+
const owners = [...activeOwners].filter(ownerIsCurrent);
|
|
280
|
+
return {
|
|
281
|
+
activeOwnerCount: owners.length,
|
|
282
|
+
timerCount: owners.filter(owner => !!owner.timer).length,
|
|
283
|
+
waitingStopCount: owners.filter(owner => owner.phase === 'waiting-stop').length,
|
|
284
|
+
retryingCount: owners.filter(owner => owner.phase === 'retrying').length,
|
|
285
|
+
attemptCount: owners.reduce((total, owner) => total + owner.attemptCount, 0),
|
|
286
|
+
terminal: owners.length === 0
|
|
287
|
+
};
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
return Object.freeze({
|
|
291
|
+
beginIntent,
|
|
292
|
+
schedule,
|
|
293
|
+
complete,
|
|
294
|
+
cancelClient,
|
|
295
|
+
snapshot
|
|
296
|
+
});
|
|
297
|
+
}
|
package/src/mode4-atlas-pool.js
CHANGED
|
@@ -51,6 +51,7 @@ export class Mode4AtlasPool {
|
|
|
51
51
|
: callbacks => new Mode4AtlasSession(callbacks);
|
|
52
52
|
this.onStatus = typeof options.onStatus === 'function' ? options.onStatus : () => {};
|
|
53
53
|
this.entries = new Map();
|
|
54
|
+
this.closingByKey = new Map();
|
|
54
55
|
}
|
|
55
56
|
|
|
56
57
|
acquire(config, callbacks = {}) {
|
|
@@ -65,6 +66,7 @@ export class Mode4AtlasPool {
|
|
|
65
66
|
streamId: `mode4-atlas-shared-${randomUUID()}`
|
|
66
67
|
};
|
|
67
68
|
entry.session = this.createSession({
|
|
69
|
+
startAfter: this.closingByKey.get(key) || null,
|
|
68
70
|
onFrame: output => {
|
|
69
71
|
for (const listener of entry.clients.values()) listener.onFrame?.(output);
|
|
70
72
|
},
|
|
@@ -100,7 +102,14 @@ export class Mode4AtlasPool {
|
|
|
100
102
|
entry.clients.delete(clientId);
|
|
101
103
|
if (entry.clients.size === 0) {
|
|
102
104
|
this.entries.delete(key);
|
|
103
|
-
entry.session.close();
|
|
105
|
+
const closePromise = Promise.resolve(entry.session.close());
|
|
106
|
+
this.closingByKey.set(key, closePromise);
|
|
107
|
+
const retireCloseOwner = () => {
|
|
108
|
+
if (this.closingByKey.get(key) === closePromise) {
|
|
109
|
+
this.closingByKey.delete(key);
|
|
110
|
+
}
|
|
111
|
+
};
|
|
112
|
+
void closePromise.then(retireCloseOwner, retireCloseOwner);
|
|
104
113
|
}
|
|
105
114
|
}
|
|
106
115
|
};
|
|
@@ -116,8 +125,13 @@ export class Mode4AtlasPool {
|
|
|
116
125
|
return this.entries.size;
|
|
117
126
|
}
|
|
118
127
|
|
|
119
|
-
close() {
|
|
120
|
-
|
|
128
|
+
async close() {
|
|
129
|
+
const closePromises = [...this.closingByKey.values()];
|
|
130
|
+
for (const entry of this.entries.values()) {
|
|
131
|
+
closePromises.push(Promise.resolve(entry.session.close()));
|
|
132
|
+
}
|
|
121
133
|
this.entries.clear();
|
|
134
|
+
this.closingByKey.clear();
|
|
135
|
+
await Promise.allSettled(closePromises);
|
|
122
136
|
}
|
|
123
137
|
}
|
|
@@ -36,6 +36,10 @@ let encoderOutputSeen = false;
|
|
|
36
36
|
let encoderGeneration = 0;
|
|
37
37
|
let encoderRestartTimer = null;
|
|
38
38
|
let encoderStartupTimer = null;
|
|
39
|
+
let encoderStopPromise = null;
|
|
40
|
+
let configureRevision = 0;
|
|
41
|
+
let configureChain = Promise.resolve();
|
|
42
|
+
let shutdownPromise = null;
|
|
39
43
|
let inputWaitTimer = null;
|
|
40
44
|
let outputBuffer = Buffer.alloc(0);
|
|
41
45
|
let frameSeq = 0;
|
|
@@ -48,6 +52,9 @@ let inputRevision = 0;
|
|
|
48
52
|
let lastEncodedInputRevision = -1;
|
|
49
53
|
let lastAtlasWriteAtEpochMs = 0;
|
|
50
54
|
const ENCODER_STARTUP_OUTPUT_TIMEOUT_MS = 8_000;
|
|
55
|
+
const ENCODER_STDIN_DRAIN_MS = 250;
|
|
56
|
+
const ENCODER_TERM_EXIT_MS = 500;
|
|
57
|
+
const ENCODER_KILL_EXIT_MS = 750;
|
|
51
58
|
const ATLAS_KEEPALIVE_MS = 1_000;
|
|
52
59
|
|
|
53
60
|
function clamp(value, min, max, fallback) {
|
|
@@ -128,7 +135,7 @@ function buildEncoderCandidates() {
|
|
|
128
135
|
}
|
|
129
136
|
|
|
130
137
|
function startEncoder() {
|
|
131
|
-
if (closed || encoder || encoderCandidates.length === 0) return;
|
|
138
|
+
if (closed || encoder || encoderStopPromise || encoderCandidates.length === 0) return;
|
|
132
139
|
if (encoderCandidateIndex >= encoderCandidates.length) {
|
|
133
140
|
send({ type: 'status', state: 'error', error: 'No Mode 4 H.264 encoder is available.' });
|
|
134
141
|
return;
|
|
@@ -144,9 +151,23 @@ function startEncoder() {
|
|
|
144
151
|
'-bsf:v', 'h264_metadata=aud=insert', '-f', 'h264', 'pipe:1'
|
|
145
152
|
];
|
|
146
153
|
const child = spawn(candidate.path, args, { stdio: ['pipe', 'pipe', 'pipe'], windowsHide: true });
|
|
147
|
-
|
|
154
|
+
const encoderEntry = {
|
|
155
|
+
child,
|
|
156
|
+
candidate,
|
|
157
|
+
inputSeen: false,
|
|
158
|
+
stopping: false,
|
|
159
|
+
intentionalStop: false
|
|
160
|
+
};
|
|
161
|
+
encoder = encoderEntry;
|
|
148
162
|
encoderOutputSeen = false;
|
|
149
163
|
outputBuffer = Buffer.alloc(0);
|
|
164
|
+
send({
|
|
165
|
+
type: 'encoder-owner',
|
|
166
|
+
state: 'active',
|
|
167
|
+
pid: Number(child.pid || 0),
|
|
168
|
+
encoderGeneration,
|
|
169
|
+
encoder: candidate.name
|
|
170
|
+
});
|
|
150
171
|
child.stdout.on('data', appendEncodedBytes);
|
|
151
172
|
child.stdin.on('error', error => {
|
|
152
173
|
if (encoder?.child === child && !isClosedPipeError(error)) {
|
|
@@ -163,8 +184,20 @@ function startEncoder() {
|
|
|
163
184
|
encoderStartupTimer = null;
|
|
164
185
|
const wasCurrent = encoder?.child === child;
|
|
165
186
|
if (wasCurrent) encoder = null;
|
|
166
|
-
|
|
167
|
-
|
|
187
|
+
send({
|
|
188
|
+
type: 'encoder-owner',
|
|
189
|
+
state: 'exited',
|
|
190
|
+
pid: Number(child.pid || 0),
|
|
191
|
+
encoderGeneration,
|
|
192
|
+
encoder: candidate.name,
|
|
193
|
+
code
|
|
194
|
+
});
|
|
195
|
+
if (!closed && !encoderEntry.intentionalStop) {
|
|
196
|
+
flushEncodedUnit();
|
|
197
|
+
} else {
|
|
198
|
+
outputBuffer = Buffer.alloc(0);
|
|
199
|
+
}
|
|
200
|
+
if (closed || encoderEntry.intentionalStop) return;
|
|
168
201
|
send({ type: 'status', state: 'encoder-restart', encoder: candidate.name, code, emitted: encoderOutputSeen });
|
|
169
202
|
clearTimeout(encoderRestartTimer);
|
|
170
203
|
encoderRestartTimer = setTimeout(startEncoder, encoderOutputSeen ? 800 : 120);
|
|
@@ -189,16 +222,71 @@ function armEncoderStartupTimer(child, candidate) {
|
|
|
189
222
|
}, ENCODER_STARTUP_OUTPUT_TIMEOUT_MS);
|
|
190
223
|
}
|
|
191
224
|
|
|
192
|
-
function
|
|
225
|
+
function waitForChildExit(child, timeoutMs) {
|
|
226
|
+
if (!child || child.exitCode !== null || child.signalCode !== null) {
|
|
227
|
+
return Promise.resolve(true);
|
|
228
|
+
}
|
|
229
|
+
return new Promise(resolve => {
|
|
230
|
+
let settled = false;
|
|
231
|
+
let timer = null;
|
|
232
|
+
const finish = exited => {
|
|
233
|
+
if (settled) return;
|
|
234
|
+
settled = true;
|
|
235
|
+
if (timer) clearTimeout(timer);
|
|
236
|
+
child.off('exit', handleExit);
|
|
237
|
+
child.off('close', handleExit);
|
|
238
|
+
resolve(exited);
|
|
239
|
+
};
|
|
240
|
+
const handleExit = () => finish(true);
|
|
241
|
+
child.once('exit', handleExit);
|
|
242
|
+
child.once('close', handleExit);
|
|
243
|
+
timer = setTimeout(() => finish(
|
|
244
|
+
child.exitCode !== null || child.signalCode !== null
|
|
245
|
+
), Math.max(1, Number(timeoutMs || 0)));
|
|
246
|
+
timer.unref?.();
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
async function stopEncoder(reason = 'stop') {
|
|
193
251
|
clearTimeout(encoderRestartTimer);
|
|
194
252
|
encoderRestartTimer = null;
|
|
195
253
|
clearTimeout(encoderStartupTimer);
|
|
196
254
|
encoderStartupTimer = null;
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
255
|
+
if (encoderStopPromise) return encoderStopPromise;
|
|
256
|
+
const encoderEntry = encoder;
|
|
257
|
+
const child = encoderEntry?.child;
|
|
258
|
+
if (!child) return true;
|
|
259
|
+
encoderEntry.stopping = true;
|
|
260
|
+
encoderEntry.intentionalStop = true;
|
|
261
|
+
|
|
262
|
+
const stopPromise = (async () => {
|
|
263
|
+
try { child.stdin.end(); } catch {}
|
|
264
|
+
let exited = await waitForChildExit(child, ENCODER_STDIN_DRAIN_MS);
|
|
265
|
+
if (!exited) {
|
|
266
|
+
try { child.kill('SIGTERM'); } catch {}
|
|
267
|
+
exited = await waitForChildExit(child, ENCODER_TERM_EXIT_MS);
|
|
268
|
+
}
|
|
269
|
+
if (!exited) {
|
|
270
|
+
try { child.kill('SIGKILL'); } catch {}
|
|
271
|
+
exited = await waitForChildExit(child, ENCODER_KILL_EXIT_MS);
|
|
272
|
+
}
|
|
273
|
+
if (!exited) {
|
|
274
|
+
send({
|
|
275
|
+
type: 'status',
|
|
276
|
+
state: 'encoder-stop-failed',
|
|
277
|
+
error: `Mode 4 encoder pid ${Number(child.pid || 0)} did not exit during ${reason}.`,
|
|
278
|
+
pid: Number(child.pid || 0),
|
|
279
|
+
encoderGeneration
|
|
280
|
+
});
|
|
281
|
+
return false;
|
|
282
|
+
}
|
|
283
|
+
if (encoder?.child === child) encoder = null;
|
|
284
|
+
return true;
|
|
285
|
+
})().finally(() => {
|
|
286
|
+
if (encoderStopPromise === stopPromise) encoderStopPromise = null;
|
|
287
|
+
});
|
|
288
|
+
encoderStopPromise = stopPromise;
|
|
289
|
+
return stopPromise;
|
|
202
290
|
}
|
|
203
291
|
|
|
204
292
|
function findStartCodes(buffer) {
|
|
@@ -374,7 +462,8 @@ function ingestFrame(message) {
|
|
|
374
462
|
|
|
375
463
|
function writeAtlasFrame() {
|
|
376
464
|
if (latestTiles.size === 0) return;
|
|
377
|
-
if (!encoder) {
|
|
465
|
+
if (!encoder || encoder.stopping) {
|
|
466
|
+
if (encoder?.stopping) return;
|
|
378
467
|
startEncoder();
|
|
379
468
|
return;
|
|
380
469
|
}
|
|
@@ -438,7 +527,8 @@ function startComposeClock() {
|
|
|
438
527
|
tickTimer = setTimeout(tick, intervalMs);
|
|
439
528
|
}
|
|
440
529
|
|
|
441
|
-
function configure(value) {
|
|
530
|
+
async function configure(value, revision) {
|
|
531
|
+
if (closed) return;
|
|
442
532
|
const next = normalizeConfig(value);
|
|
443
533
|
const encoderChanged = next.width !== config.width || next.height !== config.height || next.fps !== config.fps;
|
|
444
534
|
config = next;
|
|
@@ -457,7 +547,8 @@ function configure(value) {
|
|
|
457
547
|
}, 3000) : null;
|
|
458
548
|
rebuildLayout();
|
|
459
549
|
if (encoderChanged || !encoder) {
|
|
460
|
-
stopEncoder();
|
|
550
|
+
const stopped = await stopEncoder('reconfigure');
|
|
551
|
+
if (!stopped || closed || revision !== configureRevision) return;
|
|
461
552
|
encoderCandidates = buildEncoderCandidates();
|
|
462
553
|
encoderCandidateIndex = 0;
|
|
463
554
|
if (latestTiles.size > 0) startEncoder();
|
|
@@ -465,22 +556,44 @@ function configure(value) {
|
|
|
465
556
|
startComposeClock();
|
|
466
557
|
}
|
|
467
558
|
|
|
559
|
+
function queueConfigure(value) {
|
|
560
|
+
const revision = ++configureRevision;
|
|
561
|
+
configureChain = configureChain
|
|
562
|
+
.then(() => configure(value, revision))
|
|
563
|
+
.catch(error => {
|
|
564
|
+
if (!closed) {
|
|
565
|
+
send({
|
|
566
|
+
type: 'status',
|
|
567
|
+
state: 'configure-error',
|
|
568
|
+
error: error instanceof Error ? error.message : String(error)
|
|
569
|
+
});
|
|
570
|
+
}
|
|
571
|
+
});
|
|
572
|
+
return configureChain;
|
|
573
|
+
}
|
|
574
|
+
|
|
468
575
|
process.on('message', message => {
|
|
469
|
-
if (message?.type === 'configure')
|
|
576
|
+
if (message?.type === 'configure') void queueConfigure(message);
|
|
470
577
|
else if (message?.type === 'frame') ingestFrame(message);
|
|
471
|
-
else if (message?.type === 'close') shutdown();
|
|
578
|
+
else if (message?.type === 'close') void shutdown();
|
|
472
579
|
});
|
|
473
580
|
|
|
474
581
|
function shutdown() {
|
|
475
|
-
if (
|
|
582
|
+
if (shutdownPromise) return shutdownPromise;
|
|
476
583
|
closed = true;
|
|
477
584
|
clearTimeout(tickTimer);
|
|
478
585
|
clearTimeout(inputWaitTimer);
|
|
479
|
-
|
|
480
|
-
|
|
586
|
+
clearTimeout(encoderRestartTimer);
|
|
587
|
+
clearTimeout(encoderStartupTimer);
|
|
588
|
+
shutdownPromise = (async () => {
|
|
589
|
+
await configureChain.catch(() => {});
|
|
590
|
+
const encoderExited = await stopEncoder('worker-shutdown');
|
|
591
|
+
process.exit(encoderExited ? 0 : 1);
|
|
592
|
+
})();
|
|
593
|
+
return shutdownPromise;
|
|
481
594
|
}
|
|
482
595
|
|
|
483
|
-
process.on('disconnect', shutdown);
|
|
484
|
-
process.on('SIGTERM', shutdown);
|
|
485
|
-
process.on('SIGINT', shutdown);
|
|
596
|
+
process.on('disconnect', () => { void shutdown(); });
|
|
597
|
+
process.on('SIGTERM', () => { void shutdown(); });
|
|
598
|
+
process.on('SIGINT', () => { void shutdown(); });
|
|
486
599
|
send({ type: 'status', state: 'ready' });
|