@opengeni/runtime 0.11.0 → 0.13.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/dist/{chunk-YUAFQEFY.js → chunk-65UDUFUO.js} +729 -118
- package/dist/chunk-65UDUFUO.js.map +1 -0
- package/dist/chunk-HAN5D27J.js +292 -0
- package/dist/chunk-HAN5D27J.js.map +1 -0
- package/dist/{index-D6yi-JcU.d.ts → index-DGUrY_yR.d.ts} +37 -7
- package/dist/index.d.ts +81 -10
- package/dist/index.js +696 -93
- package/dist/index.js.map +1 -1
- package/dist/mcp-network.d.ts +78 -0
- package/dist/mcp-network.js +62 -0
- package/dist/mcp-network.js.map +1 -0
- package/dist/sandbox/index.d.ts +1 -1
- package/dist/sandbox/index.js +1 -1
- package/package.json +9 -4
- package/src/codex-tool-search.ts +20 -1
- package/src/index.ts +487 -83
- package/src/mcp-network.ts +330 -0
- package/src/sandbox/channel-a.ts +3 -28
- package/src/sandbox/display-stack.ts +860 -23
- package/src/sandbox/exec-banner.ts +67 -0
- package/src/sandbox/index.ts +7 -0
- package/src/sandbox/providers/modal.ts +29 -2
- package/src/sandbox-computer.ts +3 -1
- package/src/usage-telemetry.ts +495 -51
- package/dist/chunk-YUAFQEFY.js.map +0 -1
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
// gVisor harness (V2 PASSED live on Modal: XTEST input read-back under runsc).
|
|
19
19
|
|
|
20
20
|
import { DESKTOP_STREAM_PORT } from "@opengeni/contracts";
|
|
21
|
+
import { parseExecResponseBanner } from "./exec-banner";
|
|
21
22
|
|
|
22
23
|
// Re-export under the canonical name the module spec uses (STREAM_PORT) while
|
|
23
24
|
// keeping DESKTOP_STREAM_PORT as the single source of truth (contracts).
|
|
@@ -31,6 +32,20 @@ export const STREAM_PORT = DESKTOP_STREAM_PORT;
|
|
|
31
32
|
// warm path AND the cold-box paint warm-up without masking a genuine wedge.
|
|
32
33
|
export const DISPLAY_STACK_TIMEOUT_MS = 90_000;
|
|
33
34
|
|
|
35
|
+
// Provider exec APIs use `yieldTimeMs` as an output-yield window, not a process
|
|
36
|
+
// deadline. Poll often enough to surface stage telemetry while the command keeps
|
|
37
|
+
// running, but let the in-box `timeout(1)` own termination (see
|
|
38
|
+
// `boundedDisplayStackCommand`).
|
|
39
|
+
const DISPLAY_STACK_PROVIDER_YIELD_MS = 15_000;
|
|
40
|
+
const DISPLAY_STACK_PROVIDER_POLL_MS = 5_000;
|
|
41
|
+
const DISPLAY_STACK_TERMINATION_GRACE_MS = 5_000;
|
|
42
|
+
// Provider cancellation is transport-level and may never settle. Keep the
|
|
43
|
+
// JavaScript-side cleanup/drain bounded as well; a later caller either gets a
|
|
44
|
+
// positive in-box quiescence proof or remains fail-closed, but it must never
|
|
45
|
+
// wait forever on a provider promise.
|
|
46
|
+
const DISPLAY_STACK_CLEANUP_CALL_MS = 250;
|
|
47
|
+
const DISPLAY_STACK_DRAIN_GRACE_MS = 100;
|
|
48
|
+
|
|
34
49
|
// PAINTABLE-FRAME gate: poll scrot up to this many times, this many seconds apart,
|
|
35
50
|
// waiting for an actually-PAINTED frame before declaring the stack "up" (~30s worst case).
|
|
36
51
|
const PAINT_PROBE_ATTEMPTS = 150;
|
|
@@ -93,7 +108,7 @@ export const DEFAULT_DESKTOP_GEOMETRY: DesktopGeometry = { width: 1280, height:
|
|
|
93
108
|
* value to viewers by the caller; this error is for diagnostics. */
|
|
94
109
|
export class DisplayStackError extends Error {
|
|
95
110
|
readonly exitCode: number;
|
|
96
|
-
readonly stage: "xvfb" | "x11vnc" | "websockify" | "paint" | "unknown";
|
|
111
|
+
readonly stage: "xvfb" | "x11vnc" | "websockify" | "paint" | "lock" | "timeout" | "unknown";
|
|
97
112
|
|
|
98
113
|
constructor(exitCode: number, output: string) {
|
|
99
114
|
const stage =
|
|
@@ -105,7 +120,11 @@ export class DisplayStackError extends Error {
|
|
|
105
120
|
? "websockify"
|
|
106
121
|
: exitCode === 14
|
|
107
122
|
? "paint"
|
|
108
|
-
:
|
|
123
|
+
: exitCode === 15
|
|
124
|
+
? "lock"
|
|
125
|
+
: exitCode === 124 || /OPENGENI_DISPLAY_TIMEOUT/u.test(output)
|
|
126
|
+
? "timeout"
|
|
127
|
+
: "unknown";
|
|
109
128
|
super(
|
|
110
129
|
`desktop display stack failed at stage "${stage}" (exit ${exitCode})${output ? `:\n${output}` : ""}`,
|
|
111
130
|
);
|
|
@@ -132,26 +151,68 @@ type ExecResultLike = {
|
|
|
132
151
|
stdout?: string;
|
|
133
152
|
stderr?: string;
|
|
134
153
|
exitCode?: number | null;
|
|
154
|
+
sessionId?: number;
|
|
155
|
+
session_id?: number;
|
|
135
156
|
};
|
|
136
157
|
type ExecCapableSession = {
|
|
158
|
+
state?: unknown;
|
|
137
159
|
exec?: (args: {
|
|
138
160
|
cmd: string;
|
|
139
161
|
yieldTimeMs?: number;
|
|
140
162
|
maxOutputTokens?: number;
|
|
163
|
+
signal?: AbortSignal;
|
|
141
164
|
}) => Promise<ExecResultLike>;
|
|
142
165
|
execCommand?: (args: {
|
|
143
166
|
cmd: string;
|
|
144
167
|
yieldTimeMs?: number;
|
|
145
168
|
maxOutputTokens?: number;
|
|
169
|
+
signal?: AbortSignal;
|
|
146
170
|
}) => Promise<string>;
|
|
171
|
+
writeStdin?: (args: {
|
|
172
|
+
sessionId: number;
|
|
173
|
+
chars: string;
|
|
174
|
+
yieldTimeMs?: number;
|
|
175
|
+
maxOutputTokens?: number;
|
|
176
|
+
signal?: AbortSignal;
|
|
177
|
+
}) => Promise<ExecResultLike | string>;
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
export type DisplayStackCallerKind = "viewer" | "computer" | "unknown";
|
|
181
|
+
export type DisplayStackClassification = "cold" | "already_ready" | "contention" | "unknown";
|
|
182
|
+
export type DisplayStackTelemetryStatus = "started" | "waiting" | "completed" | "failed";
|
|
183
|
+
|
|
184
|
+
/** Secret-free, bounded-cardinality stage observation. Provider/sandbox IDs are
|
|
185
|
+
* log correlation fields, never metric labels. */
|
|
186
|
+
export type DisplayStackTelemetryEvent = {
|
|
187
|
+
type: "display_stack.stage";
|
|
188
|
+
requestId: string;
|
|
189
|
+
stage: string;
|
|
190
|
+
status: DisplayStackTelemetryStatus;
|
|
191
|
+
elapsedMs: number;
|
|
192
|
+
source: "host" | "sandbox";
|
|
193
|
+
classification: DisplayStackClassification;
|
|
194
|
+
callerKind: DisplayStackCallerKind;
|
|
195
|
+
sandboxId?: string;
|
|
196
|
+
leaseEpoch?: number;
|
|
197
|
+
providerSessionId?: number;
|
|
198
|
+
};
|
|
199
|
+
|
|
200
|
+
export type DisplayStackTelemetryContext = {
|
|
201
|
+
callerKind: DisplayStackCallerKind;
|
|
202
|
+
sandboxId?: string;
|
|
203
|
+
leaseEpoch?: number;
|
|
147
204
|
};
|
|
148
205
|
|
|
149
206
|
export type EnsureDisplayStackOptions = {
|
|
150
207
|
geometry?: DesktopGeometry;
|
|
151
208
|
/** The exposed stream port; defaults to 6080. */
|
|
152
209
|
port?: number;
|
|
153
|
-
/**
|
|
210
|
+
/** Whole-operation wall-clock timeout; defaults to DISPLAY_STACK_TIMEOUT_MS. */
|
|
154
211
|
timeoutMs?: number;
|
|
212
|
+
/** Caller correlation. Supplying it enables the default structured log sink. */
|
|
213
|
+
telemetryContext?: DisplayStackTelemetryContext;
|
|
214
|
+
/** Optional non-blocking telemetry sink (tests/embedders may replace logging). */
|
|
215
|
+
onTelemetry?: (event: DisplayStackTelemetryEvent) => void | Promise<void>;
|
|
155
216
|
};
|
|
156
217
|
|
|
157
218
|
export type EnsureDisplayStackResult = {
|
|
@@ -214,16 +275,30 @@ export function buildDisplayStackScript(options: EnsureDisplayStackOptions = {})
|
|
|
214
275
|
// — an already-up display paints on the first probe). Lives in the runtime-built script
|
|
215
276
|
// (not the baked image up-script) so it ships with the worker/api, no image rebuild —
|
|
216
277
|
// and its size floor holds against the currently-deployed image too.
|
|
278
|
+
const lockedLauncher =
|
|
279
|
+
`ds_started=$1; fw=$2; ds_class=cold; ` +
|
|
280
|
+
`ds_stage() { ds_now=$(date +%s%3N); echo "OPENGENI_DISPLAY_STAGE stage=$1 elapsed_ms=$((ds_now-ds_started)) classification=$ds_class"; }; ` +
|
|
281
|
+
`fa=$(date +%s%3N); fd=$((fa-fw)); [ "$fd" -ge 50 ] && ds_class=contention; ` +
|
|
282
|
+
`ds_stage outer_flock_acquired; ds_stage launcher_issued; ` +
|
|
283
|
+
`env ${env} opengeni-desktop-up; urc=$?; ds_stage launcher_complete; exit "$urc"`;
|
|
217
284
|
const bringUp =
|
|
218
285
|
`if nc -z 127.0.0.1 ${port} >/dev/null 2>&1 && nc -z 127.0.0.1 5900 >/dev/null 2>&1; then ` +
|
|
286
|
+
`ds_class=already_ready; ds_stage precheck_ready; ` +
|
|
219
287
|
`echo "OPENGENI_DESKTOP_UP port=${port} geometry=${geometry.width}x${geometry.height} dpi=${geometry.dpi} (precheck)"; ` +
|
|
220
288
|
`else ` +
|
|
221
|
-
`
|
|
222
|
-
`
|
|
223
|
-
`
|
|
289
|
+
`ds_class=cold; ds_stage precheck_miss; ` +
|
|
290
|
+
`mkdir -p /tmp/opengeni-desktop; ` +
|
|
291
|
+
`ds_stage outer_flock_wait; fw=$(date +%s%3N); ` +
|
|
292
|
+
// Command-mode flock is the supervisor: it retains the lock until the launcher
|
|
293
|
+
// exits, while --close prevents Xvfb/XFCE/x11vnc/websockify from inheriting the
|
|
294
|
+
// lock descriptor when the launcher intentionally detaches them.
|
|
295
|
+
`flock --close --conflict-exit-code 75 --wait 45 /tmp/opengeni-desktop/up.outer.lock ` +
|
|
296
|
+
`bash -c ${shellQuote(lockedLauncher)} _ "$ds_started" "$fw"; frc=$?; ` +
|
|
297
|
+
`if [ "$frc" -eq 75 ]; then ds_stage outer_flock_timeout; echo "OPENGENI_DESKTOP_LOCK_TIMEOUT"; exit 15; fi; ` +
|
|
298
|
+
`if [ "$frc" -ne 0 ]; then exit "$frc"; fi; ` +
|
|
224
299
|
`fi`;
|
|
225
300
|
const paintProbe =
|
|
226
|
-
`p=/tmp/opengeni-desktop/paint-probe.png; prev=0; ` +
|
|
301
|
+
`ds_stage paint_probe_started; p=/tmp/opengeni-desktop/paint-probe.png; prev=0; ` +
|
|
227
302
|
`for i in $(seq 1 ${PAINT_PROBE_ATTEMPTS}); do ` +
|
|
228
303
|
// Capture, then measure the PNG byte-size. `wc -c < "$p"` yields a bare integer; a
|
|
229
304
|
// failed scrot leaves sz=0. A frame at/above PAINT_MIN_BYTES is a real painted desktop.
|
|
@@ -234,14 +309,18 @@ export function buildDisplayStackScript(options: EnsureDisplayStackOptions = {})
|
|
|
234
309
|
// growing (the full desktop, panel + icons included, is up), not merely crossed the
|
|
235
310
|
// floor mid-paint on a staged gVisor boot. ($sz/$prev/$d are bare shell — no ${}
|
|
236
311
|
// braces — so JS leaves them for bash; ${PAINT_*} ARE JS constants and interpolate.)
|
|
237
|
-
`if [ "$sz" -ge ${PAINT_MIN_BYTES} ] && [ "$prev" -ge ${PAINT_MIN_BYTES} ]; then d=$((sz-prev)); [ "$d" -lt 0 ] && d=$((0-d)); [ "$d" -le ${PAINT_SETTLE_DELTA_BYTES} ]
|
|
312
|
+
`if [ "$sz" -ge ${PAINT_MIN_BYTES} ] && [ "$prev" -ge ${PAINT_MIN_BYTES} ]; then d=$((sz-prev)); [ "$d" -lt 0 ] && d=$((0-d)); if [ "$d" -le ${PAINT_SETTLE_DELTA_BYTES} ]; then ds_stage paint_ready; break; fi; fi; ` +
|
|
238
313
|
`prev=$sz; ` +
|
|
239
314
|
// NOTE: NOT_PAINTING goes to STDOUT (not stderr): Modal is execCommand-only, so the
|
|
240
315
|
// caller infers the outcome by string-matching the output — stdout is always captured.
|
|
241
|
-
`if [ "$i" = "${PAINT_PROBE_ATTEMPTS}" ]; then echo "OPENGENI_DESKTOP_NOT_PAINTING scrot below ${PAINT_MIN_BYTES}B or unsettled after warmup (last=$sz)"; exit 14; fi; ` +
|
|
316
|
+
`if [ "$i" = "${PAINT_PROBE_ATTEMPTS}" ]; then ds_stage paint_failed; echo "OPENGENI_DESKTOP_NOT_PAINTING scrot below ${PAINT_MIN_BYTES}B or unsettled after warmup (last=$sz)"; exit 14; fi; ` +
|
|
242
317
|
`sleep ${PAINT_PROBE_INTERVAL_S}; ` +
|
|
243
318
|
`done`;
|
|
244
|
-
|
|
319
|
+
const trace =
|
|
320
|
+
`ds_started=$(date +%s%3N); ds_class=unknown; ` +
|
|
321
|
+
`ds_stage() { ds_now=$(date +%s%3N); echo "OPENGENI_DISPLAY_STAGE stage=$1 elapsed_ms=$((ds_now-ds_started)) classification=$ds_class"; }; ` +
|
|
322
|
+
`ds_stage script_entry`;
|
|
323
|
+
return `${trace}; mkdir -p /tmp/opengeni-desktop; { ${bringUp} ; } && { ${paintProbe} ; }`;
|
|
245
324
|
}
|
|
246
325
|
|
|
247
326
|
function execResultOutput(result: ExecResultLike | string): string {
|
|
@@ -255,16 +334,617 @@ function execResultOutput(result: ExecResultLike | string): string {
|
|
|
255
334
|
|
|
256
335
|
function execResultExitCode(result: ExecResultLike | string): number | null {
|
|
257
336
|
if (typeof result === "string") {
|
|
258
|
-
|
|
337
|
+
const banner = parseExecResponseBanner(result);
|
|
338
|
+
if (banner.kind === "exited") return banner.exitCode;
|
|
339
|
+
// A malformed/truncated SDK response must not fall through to command-body
|
|
340
|
+
// marker inference, because its real terminal status is unknowable.
|
|
341
|
+
return banner.kind === "invalid" ? -1 : null;
|
|
259
342
|
}
|
|
260
343
|
return typeof result.exitCode === "number" ? result.exitCode : null;
|
|
261
344
|
}
|
|
262
345
|
|
|
346
|
+
function execResultSessionId(result: ExecResultLike | string): number | null {
|
|
347
|
+
if (typeof result === "string") {
|
|
348
|
+
const banner = parseExecResponseBanner(result);
|
|
349
|
+
return banner.kind === "running" ? banner.sessionId : null;
|
|
350
|
+
}
|
|
351
|
+
const value = result.sessionId ?? result.session_id;
|
|
352
|
+
return typeof value === "number" ? value : null;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
function shellQuote(value: string): string {
|
|
356
|
+
return `'${value.replaceAll("'", `'"'"'`)}'`;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/** The provider yield is not a deadline. Bound the actual lock-owning process in
|
|
360
|
+
* the box, leaving time for TERM/KILL cleanup and the final exit result to reach
|
|
361
|
+
* the caller before the outer JavaScript deadline. */
|
|
362
|
+
function boundedDisplayStackCommand(
|
|
363
|
+
command: string,
|
|
364
|
+
timeoutMs: number,
|
|
365
|
+
markerPath: string,
|
|
366
|
+
markerToken: string,
|
|
367
|
+
): string {
|
|
368
|
+
const graceMs = Math.min(
|
|
369
|
+
DISPLAY_STACK_TERMINATION_GRACE_MS,
|
|
370
|
+
Math.max(20, Math.floor(timeoutMs / 4)),
|
|
371
|
+
);
|
|
372
|
+
const commandTimeoutMs = Math.max(1, timeoutMs - graceMs);
|
|
373
|
+
const killAfterMs = Math.max(1, Math.floor(graceMs / 2));
|
|
374
|
+
const commandTimeoutSeconds = (commandTimeoutMs / 1_000).toFixed(3);
|
|
375
|
+
const killAfterSeconds = (killAfterMs / 1_000).toFixed(3);
|
|
376
|
+
const managedCommand =
|
|
377
|
+
`marker=${shellQuote(markerPath)}; token=${shellQuote(markerToken)}; ` +
|
|
378
|
+
`cancel="$marker.cancelled"; ` +
|
|
379
|
+
`if [ -e "$cancel" ]; then echo "OPENGENI_DISPLAY_CANCELLED"; exit 124; fi; ` +
|
|
380
|
+
`pid=$$; pgid=$(ps -o pgid= -p "$pid" 2>/dev/null | tr -d ' '); ` +
|
|
381
|
+
`start=$(awk '{print $22}' "/proc/$pid/stat" 2>/dev/null || true); ` +
|
|
382
|
+
`printf '%s %s %s %s\\n' "$pid" "$pgid" "$start" "$token" >"$marker"; ` +
|
|
383
|
+
`if [ -e "$cancel" ]; then rm -f "$marker"; echo "OPENGENI_DISPLAY_CANCELLED"; exit 124; fi; ` +
|
|
384
|
+
// Do not install an EXIT trap: GNU timeout may terminate this supervisor
|
|
385
|
+
// while descendants survive. The marker must remain for the independent
|
|
386
|
+
// cleanup command to find and kill that orphaned process group.
|
|
387
|
+
`bash -c ${shellQuote(command)}; rc=$?; rm -f "$marker"; exit "$rc"`;
|
|
388
|
+
return (
|
|
389
|
+
// Do not use timeout --foreground here. The display command contains flock
|
|
390
|
+
// supervisors and launcher children; default process-group mode is required
|
|
391
|
+
// so a deadline cannot leave a waiter alive to acquire the lock and launch
|
|
392
|
+
// after the caller has already failed.
|
|
393
|
+
`timeout --signal=TERM --kill-after=${killAfterSeconds}s ${commandTimeoutSeconds}s ` +
|
|
394
|
+
`bash -c ${shellQuote(managedCommand)}; rc=$?; ` +
|
|
395
|
+
// Exit 137 is also a normal SIGKILL/OOM/provider-termination result. Only
|
|
396
|
+
// attach timeout attribution to the exit code that this wrapper positively
|
|
397
|
+
// knows GNU timeout emitted for its own deadline. A kill-after escalation
|
|
398
|
+
// may still be reported as 137, but it is not safe to infer that here.
|
|
399
|
+
`if [ "$rc" -eq 124 ]; then ` +
|
|
400
|
+
`echo "OPENGENI_DISPLAY_TIMEOUT elapsed_ms=${commandTimeoutMs}"; fi; exit "$rc"`
|
|
401
|
+
);
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
class DisplayStackWallTimeoutError extends Error {}
|
|
405
|
+
class DisplayStackOperationFencedError extends Error {}
|
|
406
|
+
|
|
407
|
+
type PendingDisplayStackCleanup = {
|
|
408
|
+
markerPath: string;
|
|
409
|
+
markerToken: string;
|
|
410
|
+
};
|
|
411
|
+
|
|
412
|
+
type DisplayStackOperationWaiter = {
|
|
413
|
+
epoch: number;
|
|
414
|
+
settled: boolean;
|
|
415
|
+
resolve: () => void;
|
|
416
|
+
reject: (error: unknown) => void;
|
|
417
|
+
timer?: ReturnType<typeof setTimeout>;
|
|
418
|
+
};
|
|
419
|
+
|
|
420
|
+
type DisplayStackOperationState = {
|
|
421
|
+
/** Incremented before a timed-out operation is reported to its caller. */
|
|
422
|
+
epoch: number;
|
|
423
|
+
/** One provider operation at a time for each physical sandbox. */
|
|
424
|
+
owner: DisplayStackOperationWaiter | null;
|
|
425
|
+
/** Calls that started in the current epoch but are waiting for admission. */
|
|
426
|
+
waiters: DisplayStackOperationWaiter[];
|
|
427
|
+
/** Raw provider calls from fenced operations; only the in-box proof gates retries. */
|
|
428
|
+
inFlight: Set<Promise<unknown>>;
|
|
429
|
+
/** A bounded advisory drain for provider calls that may never settle. */
|
|
430
|
+
drain: Promise<void> | null;
|
|
431
|
+
/** Timed-out in-box processes that still need an independent kill/verify. */
|
|
432
|
+
pendingCleanup: Map<string, PendingDisplayStackCleanup>;
|
|
433
|
+
/** True only after every timed-out process has a positive quiescence proof. */
|
|
434
|
+
physicallyQuiescent: boolean;
|
|
435
|
+
};
|
|
436
|
+
|
|
437
|
+
const stableDisplayStackOperationStates = new Map<string, DisplayStackOperationState>();
|
|
438
|
+
const stableSessionStateOperationStates = new WeakMap<object, DisplayStackOperationState>();
|
|
439
|
+
const displayStackOperationStates = new WeakMap<object, DisplayStackOperationState>();
|
|
440
|
+
|
|
441
|
+
function asRecord(value: unknown): Record<string, unknown> | null {
|
|
442
|
+
return value !== null && typeof value === "object" ? (value as Record<string, unknown>) : null;
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
function nonEmptyString(value: unknown): string | null {
|
|
446
|
+
return typeof value === "string" && value.length > 0 ? value : null;
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
function readSessionState(session: unknown): unknown {
|
|
450
|
+
try {
|
|
451
|
+
return asRecord(session)?.state;
|
|
452
|
+
} catch {
|
|
453
|
+
return undefined;
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
function stableSandboxIdentity(
|
|
458
|
+
session: unknown,
|
|
459
|
+
options: EnsureDisplayStackOptions,
|
|
460
|
+
): string | null {
|
|
461
|
+
const explicit = nonEmptyString(options.telemetryContext?.sandboxId);
|
|
462
|
+
if (explicit) return `sandbox:${explicit}`;
|
|
463
|
+
|
|
464
|
+
const sessionRecord = asRecord(session);
|
|
465
|
+
const state = asRecord(readSessionState(session));
|
|
466
|
+
const providerState = state ? asRecord(state.providerState) : null;
|
|
467
|
+
const candidates = [sessionRecord, state, providerState];
|
|
468
|
+
const fields = [
|
|
469
|
+
"sandboxId",
|
|
470
|
+
"instanceId",
|
|
471
|
+
"agentId",
|
|
472
|
+
"hostId",
|
|
473
|
+
"containerId",
|
|
474
|
+
"id",
|
|
475
|
+
"workspaceRootPath",
|
|
476
|
+
];
|
|
477
|
+
for (const candidate of candidates) {
|
|
478
|
+
if (!candidate) continue;
|
|
479
|
+
for (const field of fields) {
|
|
480
|
+
const value = nonEmptyString(candidate[field]);
|
|
481
|
+
if (value) return `sandbox:${value}`;
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
return null;
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
function newOperationState(): DisplayStackOperationState {
|
|
488
|
+
return {
|
|
489
|
+
epoch: 0,
|
|
490
|
+
owner: null,
|
|
491
|
+
waiters: [],
|
|
492
|
+
inFlight: new Set(),
|
|
493
|
+
drain: null,
|
|
494
|
+
pendingCleanup: new Map(),
|
|
495
|
+
physicallyQuiescent: true,
|
|
496
|
+
};
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
function operationState(
|
|
500
|
+
session: unknown,
|
|
501
|
+
options: EnsureDisplayStackOptions,
|
|
502
|
+
): DisplayStackOperationState | null {
|
|
503
|
+
if ((typeof session !== "object" || session === null) && typeof session !== "function") {
|
|
504
|
+
return null;
|
|
505
|
+
}
|
|
506
|
+
const stableKey = stableSandboxIdentity(session, options);
|
|
507
|
+
if (stableKey) {
|
|
508
|
+
let state = stableDisplayStackOperationStates.get(stableKey);
|
|
509
|
+
if (!state) {
|
|
510
|
+
state = newOperationState();
|
|
511
|
+
stableDisplayStackOperationStates.set(stableKey, state);
|
|
512
|
+
}
|
|
513
|
+
return state;
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
// Modal resumed wrappers share their provider `state` object even when the
|
|
517
|
+
// wrapper instance is reconstructed. This is a useful stable fallback when
|
|
518
|
+
// an older/provider-specific state shape has no named identity field.
|
|
519
|
+
const providerState = readSessionState(session);
|
|
520
|
+
if (providerState && (typeof providerState === "object" || typeof providerState === "function")) {
|
|
521
|
+
const stateKey = providerState as object;
|
|
522
|
+
let state = stableSessionStateOperationStates.get(stateKey);
|
|
523
|
+
if (!state) {
|
|
524
|
+
state = newOperationState();
|
|
525
|
+
stableSessionStateOperationStates.set(stateKey, state);
|
|
526
|
+
}
|
|
527
|
+
return state;
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
const key = session as object;
|
|
531
|
+
let state = displayStackOperationStates.get(key);
|
|
532
|
+
if (!state) {
|
|
533
|
+
state = newOperationState();
|
|
534
|
+
displayStackOperationStates.set(key, state);
|
|
535
|
+
}
|
|
536
|
+
return state;
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
function settleOperationWaiter(waiter: DisplayStackOperationWaiter, error?: unknown): void {
|
|
540
|
+
if (waiter.settled) return;
|
|
541
|
+
waiter.settled = true;
|
|
542
|
+
if (waiter.timer) clearTimeout(waiter.timer);
|
|
543
|
+
if (error === undefined) waiter.resolve();
|
|
544
|
+
else waiter.reject(error);
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
function pumpOperationWaiters(state: DisplayStackOperationState): void {
|
|
548
|
+
if (state.owner) return;
|
|
549
|
+
while (state.waiters.length > 0) {
|
|
550
|
+
const waiter = state.waiters.shift()!;
|
|
551
|
+
if (waiter.settled) continue;
|
|
552
|
+
if (waiter.epoch !== state.epoch) {
|
|
553
|
+
settleOperationWaiter(waiter, new DisplayStackOperationFencedError());
|
|
554
|
+
continue;
|
|
555
|
+
}
|
|
556
|
+
state.owner = waiter;
|
|
557
|
+
settleOperationWaiter(waiter);
|
|
558
|
+
return;
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
function acquireOperation(
|
|
563
|
+
state: DisplayStackOperationState,
|
|
564
|
+
deadline: number,
|
|
565
|
+
): Promise<() => void> {
|
|
566
|
+
return new Promise<() => void>((resolve, reject) => {
|
|
567
|
+
const waiter: DisplayStackOperationWaiter = {
|
|
568
|
+
epoch: state.epoch,
|
|
569
|
+
settled: false,
|
|
570
|
+
resolve: () => {
|
|
571
|
+
resolve(() => {
|
|
572
|
+
if (state.owner !== waiter) return;
|
|
573
|
+
state.owner = null;
|
|
574
|
+
pumpOperationWaiters(state);
|
|
575
|
+
});
|
|
576
|
+
},
|
|
577
|
+
reject,
|
|
578
|
+
};
|
|
579
|
+
state.waiters.push(waiter);
|
|
580
|
+
const remainingMs = deadline - monotonicNow();
|
|
581
|
+
if (remainingMs <= 0) {
|
|
582
|
+
waiter.settled = true;
|
|
583
|
+
state.waiters = state.waiters.filter((candidate) => candidate !== waiter);
|
|
584
|
+
reject(new DisplayStackWallTimeoutError());
|
|
585
|
+
return;
|
|
586
|
+
}
|
|
587
|
+
waiter.timer = setTimeout(() => {
|
|
588
|
+
if (waiter.settled) return;
|
|
589
|
+
waiter.settled = true;
|
|
590
|
+
state.waiters = state.waiters.filter((candidate) => candidate !== waiter);
|
|
591
|
+
reject(new DisplayStackWallTimeoutError());
|
|
592
|
+
}, remainingMs);
|
|
593
|
+
waiter.timer.unref?.();
|
|
594
|
+
pumpOperationWaiters(state);
|
|
595
|
+
});
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
function invalidateQueuedOperations(state: DisplayStackOperationState, epoch: number): void {
|
|
599
|
+
const retained: DisplayStackOperationWaiter[] = [];
|
|
600
|
+
for (const waiter of state.waiters) {
|
|
601
|
+
if (waiter.epoch === epoch)
|
|
602
|
+
settleOperationWaiter(waiter, new DisplayStackOperationFencedError());
|
|
603
|
+
else retained.push(waiter);
|
|
604
|
+
}
|
|
605
|
+
state.waiters = retained;
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
function monotonicNow(): number {
|
|
609
|
+
return performance.now();
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
async function waitForFencedOperation(
|
|
613
|
+
state: DisplayStackOperationState,
|
|
614
|
+
deadline: number,
|
|
615
|
+
): Promise<boolean> {
|
|
616
|
+
const drain = state.drain;
|
|
617
|
+
if (!drain) return true;
|
|
618
|
+
try {
|
|
619
|
+
await beforeDeadline(drain, deadline);
|
|
620
|
+
} catch {
|
|
621
|
+
return false;
|
|
622
|
+
}
|
|
623
|
+
if (state.drain === drain) state.drain = null;
|
|
624
|
+
return true;
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
function trackProviderCall<T>(
|
|
628
|
+
state: DisplayStackOperationState | null,
|
|
629
|
+
promise: Promise<T>,
|
|
630
|
+
): Promise<T> {
|
|
631
|
+
if (!state) return promise;
|
|
632
|
+
state.inFlight.add(promise);
|
|
633
|
+
void promise.then(
|
|
634
|
+
() => state.inFlight.delete(promise),
|
|
635
|
+
() => state.inFlight.delete(promise),
|
|
636
|
+
);
|
|
637
|
+
return promise;
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
function boundedProviderDrain(pending: Promise<unknown>[]): Promise<void> {
|
|
641
|
+
if (pending.length === 0) return Promise.resolve();
|
|
642
|
+
return new Promise<void>((resolve) => {
|
|
643
|
+
let remaining = pending.length;
|
|
644
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
645
|
+
const finish = () => {
|
|
646
|
+
if (timer) clearTimeout(timer);
|
|
647
|
+
resolve();
|
|
648
|
+
};
|
|
649
|
+
timer = setTimeout(finish, DISPLAY_STACK_DRAIN_GRACE_MS);
|
|
650
|
+
timer.unref?.();
|
|
651
|
+
for (const promise of pending) {
|
|
652
|
+
void promise.then(
|
|
653
|
+
() => {
|
|
654
|
+
remaining -= 1;
|
|
655
|
+
if (remaining === 0) finish();
|
|
656
|
+
},
|
|
657
|
+
() => {
|
|
658
|
+
remaining -= 1;
|
|
659
|
+
if (remaining === 0) finish();
|
|
660
|
+
},
|
|
661
|
+
);
|
|
662
|
+
}
|
|
663
|
+
});
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
function fenceOperation(
|
|
667
|
+
state: DisplayStackOperationState | null,
|
|
668
|
+
epoch: number,
|
|
669
|
+
abortController: AbortController,
|
|
670
|
+
cleanup: PendingDisplayStackCleanup,
|
|
671
|
+
): void {
|
|
672
|
+
abortController.abort(new DisplayStackWallTimeoutError());
|
|
673
|
+
if (!state) return;
|
|
674
|
+
|
|
675
|
+
if (state.epoch !== epoch) return;
|
|
676
|
+
|
|
677
|
+
state.pendingCleanup.set(cleanup.markerToken, cleanup);
|
|
678
|
+
state.physicallyQuiescent = false;
|
|
679
|
+
invalidateQueuedOperations(state, epoch);
|
|
680
|
+
|
|
681
|
+
// Advance the ownership epoch BEFORE the timeout error is surfaced. Any
|
|
682
|
+
// completion from this operation is now stale. A retry is gated by the
|
|
683
|
+
// independent in-box cleanup proof, not by an SDK promise that may hang.
|
|
684
|
+
state.epoch += 1;
|
|
685
|
+
const pending = [...state.inFlight];
|
|
686
|
+
const drain = boundedProviderDrain(pending);
|
|
687
|
+
state.drain = state.drain ? Promise.all([state.drain, drain]).then(() => undefined) : drain;
|
|
688
|
+
// A provider transport can retain an unresolved promise forever. Once the
|
|
689
|
+
// bounded advisory window ends, release those promises from the registry;
|
|
690
|
+
// the tombstone and in-box process-group proof remain the authoritative fence.
|
|
691
|
+
void drain.then(() => {
|
|
692
|
+
for (const promise of pending) state.inFlight.delete(promise);
|
|
693
|
+
});
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
function assertCurrentOperation(state: DisplayStackOperationState | null, epoch: number): void {
|
|
697
|
+
if (state && state.epoch !== epoch) throw new DisplayStackOperationFencedError();
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
async function beforeDeadline<T>(
|
|
701
|
+
promise: Promise<T>,
|
|
702
|
+
deadline: number,
|
|
703
|
+
now: () => number = monotonicNow,
|
|
704
|
+
): Promise<T> {
|
|
705
|
+
const remainingMs = deadline - now();
|
|
706
|
+
if (remainingMs <= 0) throw new DisplayStackWallTimeoutError();
|
|
707
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
708
|
+
try {
|
|
709
|
+
return await Promise.race([
|
|
710
|
+
promise,
|
|
711
|
+
new Promise<T>((_resolve, reject) => {
|
|
712
|
+
timer = setTimeout(() => reject(new DisplayStackWallTimeoutError()), remainingMs);
|
|
713
|
+
timer.unref?.();
|
|
714
|
+
}),
|
|
715
|
+
]);
|
|
716
|
+
} finally {
|
|
717
|
+
if (timer) clearTimeout(timer);
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
function displayStackCleanupCommand(markerPath: string, markerToken: string): string {
|
|
722
|
+
const marker = shellQuote(markerPath);
|
|
723
|
+
const token = shellQuote(markerToken);
|
|
724
|
+
// The launcher writes pid, process-group id, /proc start time, and a random
|
|
725
|
+
// token before it starts the lock-owning command. The start-time check keeps
|
|
726
|
+
// a stale marker from killing an unrelated process after PID reuse. The
|
|
727
|
+
// process group is the authority: a provider session can disappear while
|
|
728
|
+
// descendants of the shell continue to hold the display lock.
|
|
729
|
+
const script =
|
|
730
|
+
`marker=${marker}; token=${token}; ` +
|
|
731
|
+
`cancel="$marker.cancelled"; ` +
|
|
732
|
+
`: >"$cancel" || { echo "OPENGENI_DISPLAY_CLEANUP status=invalid"; exit 75; }; ` +
|
|
733
|
+
`if [ ! -r "$marker" ]; then echo "OPENGENI_DISPLAY_CLEANUP status=stopped"; exit 0; fi; ` +
|
|
734
|
+
`read -r pid pgid start actual <"$marker" || { echo "OPENGENI_DISPLAY_CLEANUP status=invalid"; exit 75; }; ` +
|
|
735
|
+
`[ "$actual" = "$token" ] || { echo "OPENGENI_DISPLAY_CLEANUP status=invalid"; exit 75; }; ` +
|
|
736
|
+
`case "$pid" in ''|*[!0-9]*) echo "OPENGENI_DISPLAY_CLEANUP status=invalid"; exit 75;; esac; ` +
|
|
737
|
+
`case "$pgid" in ''|*[!0-9]*) echo "OPENGENI_DISPLAY_CLEANUP status=invalid"; exit 75;; esac; ` +
|
|
738
|
+
`case "$start" in ''|*[!0-9]*) echo "OPENGENI_DISPLAY_CLEANUP status=invalid"; exit 75;; esac; ` +
|
|
739
|
+
`current=$(awk '{print $22}' "/proc/$pid/stat" 2>/dev/null || true); ` +
|
|
740
|
+
`if [ -z "$current" ] || [ "$current" != "$start" ]; then rm -f "$marker"; echo "OPENGENI_DISPLAY_CLEANUP status=stopped"; exit 0; fi; ` +
|
|
741
|
+
`kill -TERM -- "-$pgid" 2>/dev/null || true; ` +
|
|
742
|
+
`for i in $(seq 1 10); do ` +
|
|
743
|
+
`if ! kill -0 -- "-$pgid" 2>/dev/null; then rm -f "$marker"; echo "OPENGENI_DISPLAY_CLEANUP status=stopped"; exit 0; fi; ` +
|
|
744
|
+
`sleep 0.05; done; ` +
|
|
745
|
+
`kill -KILL -- "-$pgid" 2>/dev/null || true; ` +
|
|
746
|
+
`for i in $(seq 1 20); do ` +
|
|
747
|
+
`if ! kill -0 -- "-$pgid" 2>/dev/null; then rm -f "$marker"; echo "OPENGENI_DISPLAY_CLEANUP status=stopped"; exit 0; fi; ` +
|
|
748
|
+
`sleep 0.05; done; ` +
|
|
749
|
+
`echo "OPENGENI_DISPLAY_CLEANUP status=alive"; exit 75`;
|
|
750
|
+
return `timeout --signal=TERM --kill-after=0.2s 1.5s bash -c ${shellQuote(script)}`;
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
async function runProviderCommandToDeadline(
|
|
754
|
+
session: ExecCapableSession,
|
|
755
|
+
command: string,
|
|
756
|
+
deadline: number,
|
|
757
|
+
): Promise<ExecResultLike | string> {
|
|
758
|
+
const controller = new AbortController();
|
|
759
|
+
let result: ExecResultLike | string = await beforeDeadline(
|
|
760
|
+
Promise.resolve(
|
|
761
|
+
typeof session.exec === "function"
|
|
762
|
+
? session.exec({
|
|
763
|
+
cmd: command,
|
|
764
|
+
yieldTimeMs: Math.min(500, DISPLAY_STACK_CLEANUP_CALL_MS),
|
|
765
|
+
maxOutputTokens: 4_000,
|
|
766
|
+
signal: controller.signal,
|
|
767
|
+
})
|
|
768
|
+
: session.execCommand!({
|
|
769
|
+
cmd: command,
|
|
770
|
+
yieldTimeMs: Math.min(500, DISPLAY_STACK_CLEANUP_CALL_MS),
|
|
771
|
+
maxOutputTokens: 4_000,
|
|
772
|
+
signal: controller.signal,
|
|
773
|
+
}),
|
|
774
|
+
),
|
|
775
|
+
deadline,
|
|
776
|
+
);
|
|
777
|
+
|
|
778
|
+
let providerSessionId = execResultSessionId(result);
|
|
779
|
+
while (providerSessionId !== null) {
|
|
780
|
+
if (typeof session.writeStdin !== "function") {
|
|
781
|
+
throw new Error("display cleanup provider yielded without writeStdin");
|
|
782
|
+
}
|
|
783
|
+
const remainingMs = deadline - monotonicNow();
|
|
784
|
+
if (remainingMs <= 1) throw new DisplayStackWallTimeoutError();
|
|
785
|
+
result = await beforeDeadline(
|
|
786
|
+
session.writeStdin({
|
|
787
|
+
sessionId: providerSessionId,
|
|
788
|
+
chars: "",
|
|
789
|
+
yieldTimeMs: Math.max(1, Math.min(250, remainingMs - 1)),
|
|
790
|
+
maxOutputTokens: 4_000,
|
|
791
|
+
signal: controller.signal,
|
|
792
|
+
}),
|
|
793
|
+
deadline,
|
|
794
|
+
);
|
|
795
|
+
providerSessionId = execResultSessionId(result);
|
|
796
|
+
}
|
|
797
|
+
return result;
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
async function interruptProviderProcess(
|
|
801
|
+
session: ExecCapableSession,
|
|
802
|
+
providerSessionId: number | null,
|
|
803
|
+
deadline: number,
|
|
804
|
+
): Promise<void> {
|
|
805
|
+
if (providerSessionId === null || typeof session.writeStdin !== "function") return;
|
|
806
|
+
try {
|
|
807
|
+
// This is an advisory provider-level interrupt. The marker/PGID cleanup
|
|
808
|
+
// below remains authoritative because Modal 0.13.3 ignores AbortSignal.
|
|
809
|
+
await beforeDeadline(
|
|
810
|
+
session.writeStdin({
|
|
811
|
+
sessionId: providerSessionId,
|
|
812
|
+
chars: "\u0003",
|
|
813
|
+
yieldTimeMs: 100,
|
|
814
|
+
maxOutputTokens: 256,
|
|
815
|
+
}),
|
|
816
|
+
deadline,
|
|
817
|
+
);
|
|
818
|
+
} catch {
|
|
819
|
+
// A hung provider write is not evidence either way; in-box cleanup decides.
|
|
820
|
+
}
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
async function cleanupDisplayStackProcess(
|
|
824
|
+
session: ExecCapableSession,
|
|
825
|
+
pending: PendingDisplayStackCleanup,
|
|
826
|
+
deadline: number,
|
|
827
|
+
): Promise<boolean> {
|
|
828
|
+
try {
|
|
829
|
+
const result = await runProviderCommandToDeadline(
|
|
830
|
+
session,
|
|
831
|
+
displayStackCleanupCommand(pending.markerPath, pending.markerToken),
|
|
832
|
+
deadline,
|
|
833
|
+
);
|
|
834
|
+
const output = execResultOutput(result);
|
|
835
|
+
const status = /OPENGENI_DISPLAY_CLEANUP status=(stopped|absent|alive|invalid)/u.exec(
|
|
836
|
+
output,
|
|
837
|
+
)?.[1];
|
|
838
|
+
return status === "stopped";
|
|
839
|
+
} catch {
|
|
840
|
+
return false;
|
|
841
|
+
}
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
async function reconcilePendingCleanup(
|
|
845
|
+
session: ExecCapableSession,
|
|
846
|
+
state: DisplayStackOperationState,
|
|
847
|
+
deadline: number,
|
|
848
|
+
): Promise<boolean> {
|
|
849
|
+
const entries = [...state.pendingCleanup.values()];
|
|
850
|
+
if (entries.length === 0) {
|
|
851
|
+
state.physicallyQuiescent = true;
|
|
852
|
+
return true;
|
|
853
|
+
}
|
|
854
|
+
const results = await Promise.all(
|
|
855
|
+
entries.map(async (pending) => ({
|
|
856
|
+
pending,
|
|
857
|
+
stopped: await cleanupDisplayStackProcess(session, pending, deadline),
|
|
858
|
+
})),
|
|
859
|
+
);
|
|
860
|
+
for (const { pending, stopped } of results) {
|
|
861
|
+
if (stopped) state.pendingCleanup.delete(pending.markerToken);
|
|
862
|
+
}
|
|
863
|
+
state.physicallyQuiescent = state.pendingCleanup.size === 0;
|
|
864
|
+
return state.physicallyQuiescent;
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
function requestId(): string {
|
|
868
|
+
return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
function telemetryReporter(
|
|
872
|
+
options: EnsureDisplayStackOptions,
|
|
873
|
+
started: number,
|
|
874
|
+
now: () => number = monotonicNow,
|
|
875
|
+
) {
|
|
876
|
+
const id = requestId();
|
|
877
|
+
const context = options.telemetryContext;
|
|
878
|
+
const sink =
|
|
879
|
+
options.onTelemetry ??
|
|
880
|
+
(context
|
|
881
|
+
? (event: DisplayStackTelemetryEvent) =>
|
|
882
|
+
console.info(`[display-stack] ${JSON.stringify(event)}`)
|
|
883
|
+
: undefined);
|
|
884
|
+
const emittedSandboxStages = new Set<string>();
|
|
885
|
+
|
|
886
|
+
const emit = (
|
|
887
|
+
stage: string,
|
|
888
|
+
status: DisplayStackTelemetryStatus,
|
|
889
|
+
fields: {
|
|
890
|
+
source?: "host" | "sandbox";
|
|
891
|
+
elapsedMs?: number;
|
|
892
|
+
classification?: DisplayStackClassification;
|
|
893
|
+
providerSessionId?: number;
|
|
894
|
+
} = {},
|
|
895
|
+
) => {
|
|
896
|
+
if (!sink) return;
|
|
897
|
+
const event: DisplayStackTelemetryEvent = {
|
|
898
|
+
type: "display_stack.stage",
|
|
899
|
+
requestId: id,
|
|
900
|
+
stage,
|
|
901
|
+
status,
|
|
902
|
+
elapsedMs: fields.elapsedMs ?? now() - started,
|
|
903
|
+
source: fields.source ?? "host",
|
|
904
|
+
classification: fields.classification ?? "unknown",
|
|
905
|
+
callerKind: context?.callerKind ?? "unknown",
|
|
906
|
+
...(context?.sandboxId ? { sandboxId: context.sandboxId } : {}),
|
|
907
|
+
...(context?.leaseEpoch !== undefined ? { leaseEpoch: context.leaseEpoch } : {}),
|
|
908
|
+
...(fields.providerSessionId !== undefined
|
|
909
|
+
? { providerSessionId: fields.providerSessionId }
|
|
910
|
+
: {}),
|
|
911
|
+
};
|
|
912
|
+
try {
|
|
913
|
+
void Promise.resolve(sink(event)).catch(() => undefined);
|
|
914
|
+
} catch {
|
|
915
|
+
// Telemetry must never affect display readiness.
|
|
916
|
+
}
|
|
917
|
+
};
|
|
918
|
+
|
|
919
|
+
const emitSandboxStages = (output: string) => {
|
|
920
|
+
for (const match of output.matchAll(
|
|
921
|
+
/OPENGENI_(?:DISPLAY|DESKTOP)_STAGE stage=([a-z0-9_]+) elapsed_ms=(\d+)(?: classification=(cold|already_ready|contention|unknown))?/gu,
|
|
922
|
+
)) {
|
|
923
|
+
const [line, stage, elapsed, classification] = match;
|
|
924
|
+
if (!stage || !elapsed || emittedSandboxStages.has(line)) continue;
|
|
925
|
+
emittedSandboxStages.add(line);
|
|
926
|
+
emit(stage, "completed", {
|
|
927
|
+
source: "sandbox",
|
|
928
|
+
elapsedMs: Number(elapsed),
|
|
929
|
+
classification: (classification as DisplayStackClassification | undefined) ?? "unknown",
|
|
930
|
+
});
|
|
931
|
+
}
|
|
932
|
+
};
|
|
933
|
+
|
|
934
|
+
return { emit, emitSandboxStages };
|
|
935
|
+
}
|
|
936
|
+
|
|
263
937
|
// Parse the exit code the up-script signals via its trailing marker. When we ran
|
|
264
938
|
// through `exec` we have the real exitCode; when we only had `execCommand` (a
|
|
265
939
|
// bare string), we infer success from the OPENGENI_DESKTOP_UP marker and infer
|
|
266
940
|
// the failing stage from the stage-failure message the script prints to stderr.
|
|
267
941
|
function inferExitFromOutput(output: string): number {
|
|
942
|
+
if (/OPENGENI_DISPLAY_TIMEOUT/.test(output)) {
|
|
943
|
+
return 124;
|
|
944
|
+
}
|
|
945
|
+
if (/OPENGENI_DESKTOP_LOCK_TIMEOUT/.test(output)) {
|
|
946
|
+
return 15;
|
|
947
|
+
}
|
|
268
948
|
// Check the PAINTABLE-FRAME failure FIRST: on that path the up-script already
|
|
269
949
|
// printed OPENGENI_DESKTOP_UP (bring-up succeeded) and THEN the paint gate failed,
|
|
270
950
|
// so both markers are present — the NOT_PAINTING one is the authoritative outcome.
|
|
@@ -314,22 +994,179 @@ export async function ensureDisplayStack(
|
|
|
314
994
|
const geometry = options.geometry ?? DEFAULT_DESKTOP_GEOMETRY;
|
|
315
995
|
const port = options.port ?? DESKTOP_STREAM_PORT;
|
|
316
996
|
const timeoutMs = options.timeoutMs ?? DISPLAY_STACK_TIMEOUT_MS;
|
|
317
|
-
|
|
997
|
+
if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) {
|
|
998
|
+
throw new RangeError("ensureDisplayStack timeoutMs must be a positive finite number");
|
|
999
|
+
}
|
|
1000
|
+
const started = monotonicNow();
|
|
1001
|
+
const deadline = started + timeoutMs;
|
|
1002
|
+
const telemetry = telemetryReporter(options, started, monotonicNow);
|
|
1003
|
+
const operationToken = requestId();
|
|
1004
|
+
const markerPath = `/tmp/opengeni-desktop/display-stack-${operationToken}.pid`;
|
|
1005
|
+
const cmd = boundedDisplayStackCommand(
|
|
1006
|
+
buildDisplayStackScript({ geometry, port }),
|
|
1007
|
+
timeoutMs,
|
|
1008
|
+
markerPath,
|
|
1009
|
+
operationToken,
|
|
1010
|
+
);
|
|
1011
|
+
const outputParts: string[] = [];
|
|
1012
|
+
const state = operationState(session, options);
|
|
1013
|
+
let release: (() => void) | undefined;
|
|
1014
|
+
if (state) {
|
|
1015
|
+
try {
|
|
1016
|
+
release = await acquireOperation(state, deadline);
|
|
1017
|
+
} catch (error) {
|
|
1018
|
+
if (
|
|
1019
|
+
error instanceof DisplayStackWallTimeoutError ||
|
|
1020
|
+
error instanceof DisplayStackOperationFencedError
|
|
1021
|
+
) {
|
|
1022
|
+
throw new DisplayStackError(
|
|
1023
|
+
124,
|
|
1024
|
+
"OPENGENI_DISPLAY_TIMEOUT display operation admission was fenced",
|
|
1025
|
+
);
|
|
1026
|
+
}
|
|
1027
|
+
throw error;
|
|
1028
|
+
}
|
|
1029
|
+
}
|
|
318
1030
|
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
1031
|
+
try {
|
|
1032
|
+
if (state && !state.physicallyQuiescent) {
|
|
1033
|
+
const cleanupDeadline = monotonicNow() + DISPLAY_STACK_CLEANUP_CALL_MS;
|
|
1034
|
+
const quiesced = await reconcilePendingCleanup(s, state, cleanupDeadline);
|
|
1035
|
+
if (!quiesced) {
|
|
1036
|
+
// Fail closed. A new session wrapper may share this physical sandbox,
|
|
1037
|
+
// but without a positive marker/PGID proof it must not issue overlapping
|
|
1038
|
+
// display work merely because the old SDK promise remains unresolved.
|
|
1039
|
+
throw new DisplayStackError(
|
|
1040
|
+
124,
|
|
1041
|
+
"OPENGENI_DISPLAY_TIMEOUT physical cleanup is still pending; retry is fenced",
|
|
1042
|
+
);
|
|
1043
|
+
}
|
|
1044
|
+
}
|
|
1045
|
+
const epoch = state?.epoch ?? 0;
|
|
1046
|
+
const abortController = new AbortController();
|
|
1047
|
+
let activeProviderSessionId: number | null = null;
|
|
1048
|
+
telemetry.emit("request_entry", "started");
|
|
1049
|
+
telemetry.emit("exec_issued", "started");
|
|
323
1050
|
|
|
324
|
-
|
|
325
|
-
|
|
1051
|
+
try {
|
|
1052
|
+
const initialYieldMs = Math.max(1, Math.min(DISPLAY_STACK_PROVIDER_YIELD_MS, timeoutMs - 1));
|
|
1053
|
+
const initialExec: Promise<ExecResultLike | string> = trackProviderCall<
|
|
1054
|
+
ExecResultLike | string
|
|
1055
|
+
>(
|
|
1056
|
+
state,
|
|
1057
|
+
Promise.resolve(
|
|
1058
|
+
typeof s.exec === "function"
|
|
1059
|
+
? s.exec({
|
|
1060
|
+
cmd,
|
|
1061
|
+
yieldTimeMs: initialYieldMs,
|
|
1062
|
+
maxOutputTokens: 20_000,
|
|
1063
|
+
signal: abortController.signal,
|
|
1064
|
+
})
|
|
1065
|
+
: s.execCommand!({
|
|
1066
|
+
cmd,
|
|
1067
|
+
yieldTimeMs: initialYieldMs,
|
|
1068
|
+
maxOutputTokens: 20_000,
|
|
1069
|
+
signal: abortController.signal,
|
|
1070
|
+
}),
|
|
1071
|
+
),
|
|
1072
|
+
);
|
|
1073
|
+
let result: ExecResultLike | string = await beforeDeadline(
|
|
1074
|
+
initialExec,
|
|
1075
|
+
deadline,
|
|
1076
|
+
monotonicNow,
|
|
1077
|
+
);
|
|
1078
|
+
assertCurrentOperation(state, epoch);
|
|
1079
|
+
let chunk = execResultOutput(result);
|
|
1080
|
+
outputParts.push(chunk);
|
|
1081
|
+
telemetry.emitSandboxStages(chunk);
|
|
1082
|
+
telemetry.emit("provider_first_result", "completed");
|
|
326
1083
|
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
1084
|
+
let providerSessionId = execResultSessionId(result);
|
|
1085
|
+
activeProviderSessionId = providerSessionId;
|
|
1086
|
+
while (providerSessionId !== null) {
|
|
1087
|
+
telemetry.emit("provider_yield", "waiting", { providerSessionId });
|
|
1088
|
+
if (typeof s.writeStdin !== "function") {
|
|
1089
|
+
throw new DisplayStackError(
|
|
1090
|
+
-1,
|
|
1091
|
+
`${outputParts.join("\n")}\nprovider yielded process ${providerSessionId} but exposes no writeStdin poll surface`,
|
|
1092
|
+
);
|
|
1093
|
+
}
|
|
1094
|
+
assertCurrentOperation(state, epoch);
|
|
1095
|
+
const remainingMs = deadline - monotonicNow();
|
|
1096
|
+
if (remainingMs <= 1) throw new DisplayStackWallTimeoutError();
|
|
1097
|
+
const pollYieldMs = Math.max(1, Math.min(DISPLAY_STACK_PROVIDER_POLL_MS, remainingMs - 1));
|
|
1098
|
+
result = await beforeDeadline(
|
|
1099
|
+
trackProviderCall(
|
|
1100
|
+
state,
|
|
1101
|
+
s.writeStdin({
|
|
1102
|
+
sessionId: providerSessionId,
|
|
1103
|
+
chars: "",
|
|
1104
|
+
yieldTimeMs: pollYieldMs,
|
|
1105
|
+
maxOutputTokens: 20_000,
|
|
1106
|
+
signal: abortController.signal,
|
|
1107
|
+
}),
|
|
1108
|
+
),
|
|
1109
|
+
deadline,
|
|
1110
|
+
monotonicNow,
|
|
1111
|
+
);
|
|
1112
|
+
assertCurrentOperation(state, epoch);
|
|
1113
|
+
chunk = execResultOutput(result);
|
|
1114
|
+
outputParts.push(chunk);
|
|
1115
|
+
telemetry.emitSandboxStages(chunk);
|
|
1116
|
+
providerSessionId = execResultSessionId(result);
|
|
1117
|
+
activeProviderSessionId = providerSessionId;
|
|
1118
|
+
}
|
|
1119
|
+
|
|
1120
|
+
const output = outputParts.join("\n");
|
|
1121
|
+
const exitCode = execResultExitCode(result) ?? inferExitFromOutput(output);
|
|
1122
|
+
telemetry.emit("process_complete", exitCode === 0 ? "completed" : "failed");
|
|
1123
|
+
if (exitCode !== 0) {
|
|
1124
|
+
throw new DisplayStackError(exitCode, output);
|
|
1125
|
+
}
|
|
330
1126
|
|
|
331
|
-
|
|
332
|
-
|
|
1127
|
+
const marker = (output.match(/OPENGENI_DESKTOP_UP[^\n]*/) ?? [""])[0];
|
|
1128
|
+
telemetry.emit("readiness_complete", "completed");
|
|
1129
|
+
return { port, geometry, marker };
|
|
1130
|
+
} catch (error) {
|
|
1131
|
+
if (
|
|
1132
|
+
error instanceof DisplayStackWallTimeoutError ||
|
|
1133
|
+
error instanceof DisplayStackOperationFencedError
|
|
1134
|
+
) {
|
|
1135
|
+
const pendingCleanup: PendingDisplayStackCleanup = {
|
|
1136
|
+
markerPath,
|
|
1137
|
+
markerToken: operationToken,
|
|
1138
|
+
};
|
|
1139
|
+
fenceOperation(state, epoch, abortController, pendingCleanup);
|
|
1140
|
+
const cleanupDeadline = monotonicNow() + DISPLAY_STACK_CLEANUP_CALL_MS;
|
|
1141
|
+
telemetry.emit("process_cleanup", "started");
|
|
1142
|
+
await interruptProviderProcess(
|
|
1143
|
+
s,
|
|
1144
|
+
activeProviderSessionId,
|
|
1145
|
+
Math.min(cleanupDeadline, monotonicNow() + 50),
|
|
1146
|
+
);
|
|
1147
|
+
let quiesced = false;
|
|
1148
|
+
if (state) {
|
|
1149
|
+
quiesced = await reconcilePendingCleanup(s, state, cleanupDeadline);
|
|
1150
|
+
if (!quiesced) {
|
|
1151
|
+
await waitForFencedOperation(
|
|
1152
|
+
state,
|
|
1153
|
+
Math.min(cleanupDeadline, monotonicNow() + DISPLAY_STACK_DRAIN_GRACE_MS),
|
|
1154
|
+
);
|
|
1155
|
+
}
|
|
1156
|
+
} else {
|
|
1157
|
+
quiesced = await cleanupDisplayStackProcess(s, pendingCleanup, cleanupDeadline);
|
|
1158
|
+
}
|
|
1159
|
+
telemetry.emit("process_cleanup", quiesced ? "completed" : "failed");
|
|
1160
|
+
const output = `${outputParts.join("\n")}\nOPENGENI_DISPLAY_TIMEOUT wall_ms=${timeoutMs}`;
|
|
1161
|
+
telemetry.emit("wall_deadline", "failed");
|
|
1162
|
+
throw new DisplayStackError(124, output);
|
|
1163
|
+
}
|
|
1164
|
+
telemetry.emit("request_failed", "failed");
|
|
1165
|
+
throw error;
|
|
1166
|
+
}
|
|
1167
|
+
} finally {
|
|
1168
|
+
release?.();
|
|
1169
|
+
}
|
|
333
1170
|
}
|
|
334
1171
|
|
|
335
1172
|
/** Tear the stack down (down-script). Best-effort; never throws on a missing
|