@parall/daemon 1.34.0 → 1.36.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/bundle/manifest.json +13 -9
- package/bundle/parall-browser-pod.js +40665 -0
- package/bundle/parall-claude-agent.js +564 -91
- package/bundle/parall-codex-agent.js +559 -90
- package/bundle/parall-daemon.js +1000 -66
- package/dist/browser-pod.d.ts +294 -0
- package/dist/browser-pod.d.ts.map +1 -0
- package/dist/browser-pod.js +765 -0
- package/dist/clip-runtime/browser-profile-manager.d.ts +10 -0
- package/dist/clip-runtime/browser-profile-manager.d.ts.map +1 -1
- package/dist/clip-runtime/browser-profile-manager.js +38 -26
- package/dist/clip-runtime/browser-state-store.d.ts +157 -0
- package/dist/clip-runtime/browser-state-store.d.ts.map +1 -0
- package/dist/clip-runtime/browser-state-store.js +370 -0
- package/dist/clip-runtime/browser-viewer-streamer.d.ts +222 -0
- package/dist/clip-runtime/browser-viewer-streamer.d.ts.map +1 -0
- package/dist/clip-runtime/browser-viewer-streamer.js +691 -0
- package/dist/clip-runtime/clip-provider.d.ts +56 -0
- package/dist/clip-runtime/clip-provider.d.ts.map +1 -1
- package/dist/clip-runtime/clip-provider.js +141 -17
- package/dist/clip-runtime/subprocess.d.ts +11 -0
- package/dist/clip-runtime/subprocess.d.ts.map +1 -0
- package/dist/clip-runtime/subprocess.js +33 -0
- package/dist/supervisor.d.ts +10 -2
- package/dist/supervisor.d.ts.map +1 -1
- package/dist/supervisor.js +61 -19
- package/package.json +11 -8
|
@@ -0,0 +1,765 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* `parall-browser-pod` — the V2 hosted-browser pod entrypoint.
|
|
4
|
+
*
|
|
5
|
+
* This is the production replacement for the Go echo-stub (server/cmd/browser-stub):
|
|
6
|
+
* a single-profile pod that runs the REAL browser stack (Chromium under Xvfb +
|
|
7
|
+
* bb-browser, started lazily by BrowserProfileManager) and registers to the Clip
|
|
8
|
+
* Service hub as the "browser" provider for exactly ONE profile.
|
|
9
|
+
*
|
|
10
|
+
* It is deliberately NOT the daemon supervisor. The daemon (`index.ts`) is a
|
|
11
|
+
* Machine: it authenticates with a 90-day `mck_`, polls `/machines/me/*`, and
|
|
12
|
+
* supervises per-agent subprocesses. A hosted browser pod has none of that — it
|
|
13
|
+
* is platform infrastructure with a short-lived, controller-minted platform
|
|
14
|
+
* assignment token (`pba_`) scoped to `(org_id, profile_id, pod_id)`, and serves
|
|
15
|
+
* a single profile until its lease is released. Per the V2 design (§3.3, §6) this
|
|
16
|
+
* is an **image/entrypoint-level** slim mode: it reuses the shared clip-runtime
|
|
17
|
+
* building blocks (ClipProvider / ClipProcessManager / BrowserProfileManager)
|
|
18
|
+
* without deleting anything from the shared package, so the BYOC daemon path and
|
|
19
|
+
* its `mck_` identity are untouched.
|
|
20
|
+
*
|
|
21
|
+
* Contract (mirrors server/cmd/browser-stub/main.go, the reference implementation):
|
|
22
|
+
*
|
|
23
|
+
* Env (injected by browser-profile-controller's pod spec):
|
|
24
|
+
* PRLL_ASSIGNMENT_TOKEN the pba_ platform assignment token (required)
|
|
25
|
+
* PRLL_CLIP_RPC in-cluster clip-service URL, e.g. http://clip-service:8095 (required)
|
|
26
|
+
* PRLL_PROFILE_ID the profile this pod serves (required)
|
|
27
|
+
* PRLL_POD_ID this pod's name → provider_name; must match the token's pod_id (required)
|
|
28
|
+
* PRLL_ORG_ID owning org (logging only; the hub derives org from the token)
|
|
29
|
+
* PRLL_HEALTH_PORT liveness/readiness listen port (default 8080)
|
|
30
|
+
* PRLL_BROWSER_STATE_DIR root for on-disk browser state (default $HOME/.parall-agent)
|
|
31
|
+
*
|
|
32
|
+
* Health (the contract the controller's K8s probes consume):
|
|
33
|
+
* GET /livez -> 200 always (process is alive; a liveness restart trigger).
|
|
34
|
+
* GET /readyz -> 200 only while registered to the hub AND not draining, else 503.
|
|
35
|
+
* Readiness is ROUTE-readiness, not mere liveness: a started-but-not-yet-
|
|
36
|
+
* registered (or disconnected, or token/lease-invalidated) pod reads
|
|
37
|
+
* NotReady, so the controller never treats pod existence as routable. The
|
|
38
|
+
* hub fences the ProviderStream on the pba_ token + live lease, so loss of
|
|
39
|
+
* either drops the registration → isConnected() → false → NotReady.
|
|
40
|
+
*
|
|
41
|
+
* Shutdown (SIGTERM, on pod deletion / lease release): fail readiness FIRST so
|
|
42
|
+
* K8s/controller stop routing, THEN tear down the ProviderStream (hub
|
|
43
|
+
* unregisters) and the bb-browser daemon — all within terminationGracePeriod.
|
|
44
|
+
* The authoritative lease state transition (lease -> released, quota freed) is
|
|
45
|
+
* still owned by the controller/api-server, never inferred from the pod exiting.
|
|
46
|
+
*/
|
|
47
|
+
import { timingSafeEqual } from 'node:crypto';
|
|
48
|
+
import { realpathSync } from 'node:fs';
|
|
49
|
+
import * as http from 'node:http';
|
|
50
|
+
import * as os from 'node:os';
|
|
51
|
+
import * as path from 'node:path';
|
|
52
|
+
import { pathToFileURL } from 'node:url';
|
|
53
|
+
import { createLogger } from '@parall/agent-core';
|
|
54
|
+
import { BrowserProfileManager } from './clip-runtime/browser-profile-manager.js';
|
|
55
|
+
import { BrowserStateStore } from './clip-runtime/browser-state-store.js';
|
|
56
|
+
import { ClipProvider } from './clip-runtime/clip-provider.js';
|
|
57
|
+
import { ClipProcessManager } from './clip-runtime/process-manager.js';
|
|
58
|
+
/**
|
|
59
|
+
* Platform assignment token prefix (SSOT: server/pkg/platformauth/token.go). A
|
|
60
|
+
* hosted browser pod authenticates ONLY with a controller-minted `pba_` token —
|
|
61
|
+
* never an `mck_` (Machine) or `agk_` (agent) key. We fail fast on the wrong
|
|
62
|
+
* prefix, symmetric with the daemon's `mck_` guard in config.ts, to catch a
|
|
63
|
+
* misconfigured pod spec before it opens a doomed session loop.
|
|
64
|
+
*/
|
|
65
|
+
const ASSIGNMENT_TOKEN_PREFIX = 'pba_';
|
|
66
|
+
const DEFAULT_HEALTH_PORT = 8080;
|
|
67
|
+
/** Default cadence for periodic S3 checkpoints while the pod runs (design §3.2). */
|
|
68
|
+
const DEFAULT_CHECKPOINT_INTERVAL_MS = 60_000;
|
|
69
|
+
/** Parse a TCP port from env, falling back when absent or out of the 1–65535 range. */
|
|
70
|
+
function parsePort(value, fallback) {
|
|
71
|
+
if (!value)
|
|
72
|
+
return fallback;
|
|
73
|
+
const n = Number(value);
|
|
74
|
+
return Number.isInteger(n) && n > 0 && n < 65536 ? n : fallback;
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Resolve and validate the pod's runtime config from the environment. Throws on
|
|
78
|
+
* any missing required var (fail-fast: a pod missing its token / clip-rpc /
|
|
79
|
+
* profile / pod id can never register, so we crash now instead of starting a
|
|
80
|
+
* doomed reconnect loop).
|
|
81
|
+
*/
|
|
82
|
+
export function resolveBrowserPodConfig(env = process.env) {
|
|
83
|
+
const assignmentToken = env.PRLL_ASSIGNMENT_TOKEN?.trim() || '';
|
|
84
|
+
const clipRpcUrl = env.PRLL_CLIP_RPC?.trim() || '';
|
|
85
|
+
const profileId = env.PRLL_PROFILE_ID?.trim() || '';
|
|
86
|
+
const podId = env.PRLL_POD_ID?.trim() || '';
|
|
87
|
+
const orgId = env.PRLL_ORG_ID?.trim() || '';
|
|
88
|
+
const missing = [];
|
|
89
|
+
if (!assignmentToken)
|
|
90
|
+
missing.push('PRLL_ASSIGNMENT_TOKEN');
|
|
91
|
+
if (!clipRpcUrl)
|
|
92
|
+
missing.push('PRLL_CLIP_RPC');
|
|
93
|
+
if (!profileId)
|
|
94
|
+
missing.push('PRLL_PROFILE_ID');
|
|
95
|
+
if (!podId)
|
|
96
|
+
missing.push('PRLL_POD_ID');
|
|
97
|
+
if (missing.length > 0) {
|
|
98
|
+
throw new Error(`browser-pod: missing required env: ${missing.join(', ')}`);
|
|
99
|
+
}
|
|
100
|
+
if (!assignmentToken.startsWith(ASSIGNMENT_TOKEN_PREFIX)) {
|
|
101
|
+
throw new Error(`browser-pod: PRLL_ASSIGNMENT_TOKEN is not a platform assignment token ` +
|
|
102
|
+
`(expected "${ASSIGNMENT_TOKEN_PREFIX}" prefix). Hosted browser pods authenticate ` +
|
|
103
|
+
`with a controller-minted assignment token, never an mck_/agk_ key.`);
|
|
104
|
+
}
|
|
105
|
+
const home = env.HOME?.trim() || os.homedir();
|
|
106
|
+
const stateDir = env.PRLL_BROWSER_STATE_DIR?.trim() || path.join(home, '.parall-agent');
|
|
107
|
+
const homeDir = path.join(stateDir, 'bb-browser');
|
|
108
|
+
return {
|
|
109
|
+
assignmentToken,
|
|
110
|
+
clipRpcUrl,
|
|
111
|
+
profileId,
|
|
112
|
+
podId,
|
|
113
|
+
orgId,
|
|
114
|
+
healthPort: parsePort(env.PRLL_HEALTH_PORT, DEFAULT_HEALTH_PORT),
|
|
115
|
+
stateDir,
|
|
116
|
+
homeDir,
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* Resolve a warm pod's identity-less config. Unlike resolveBrowserPodConfig this
|
|
121
|
+
* requires PRLL_POOL_TOKEN (not PRLL_ASSIGNMENT_TOKEN) and NO profile/org — those
|
|
122
|
+
* arrive later via /assign. PRLL_CLIP_RPC is injected at pool creation (stable per
|
|
123
|
+
* cluster) so the assigned path needs no extra wiring.
|
|
124
|
+
*/
|
|
125
|
+
export function resolvePoolPodConfig(env = process.env) {
|
|
126
|
+
const poolToken = env.PRLL_POOL_TOKEN?.trim() || '';
|
|
127
|
+
const podId = env.PRLL_POD_ID?.trim() || '';
|
|
128
|
+
const clipRpcUrl = env.PRLL_CLIP_RPC?.trim() || '';
|
|
129
|
+
const missing = [];
|
|
130
|
+
if (!poolToken)
|
|
131
|
+
missing.push('PRLL_POOL_TOKEN');
|
|
132
|
+
if (!podId)
|
|
133
|
+
missing.push('PRLL_POD_ID');
|
|
134
|
+
if (!clipRpcUrl)
|
|
135
|
+
missing.push('PRLL_CLIP_RPC');
|
|
136
|
+
if (missing.length > 0) {
|
|
137
|
+
throw new Error(`browser-pod (pool): missing required env: ${missing.join(', ')}`);
|
|
138
|
+
}
|
|
139
|
+
if (!poolToken.startsWith(ASSIGNMENT_TOKEN_PREFIX)) {
|
|
140
|
+
throw new Error(`browser-pod (pool): PRLL_POOL_TOKEN is not a platform token (expected "${ASSIGNMENT_TOKEN_PREFIX}" prefix).`);
|
|
141
|
+
}
|
|
142
|
+
return {
|
|
143
|
+
poolToken,
|
|
144
|
+
podId,
|
|
145
|
+
clipRpcUrl,
|
|
146
|
+
healthPort: parsePort(env.PRLL_HEALTH_PORT, DEFAULT_HEALTH_PORT),
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
/**
|
|
150
|
+
* Validate + normalize an /assign body (already JSON-parsed). Rejects a missing /
|
|
151
|
+
* non-pba_ assignment token, a missing profile/lease, and PARTIAL S3 creds (a
|
|
152
|
+
* half-set would build a misconfigured S3 client — worse than stateless). State is
|
|
153
|
+
* optional (stateless profile); when present it must be complete.
|
|
154
|
+
*/
|
|
155
|
+
export function parseAssignRequest(body) {
|
|
156
|
+
if (typeof body !== 'object' || body === null) {
|
|
157
|
+
return { ok: false, error: 'body must be a JSON object' };
|
|
158
|
+
}
|
|
159
|
+
const b = body;
|
|
160
|
+
const str = (v) => (typeof v === 'string' ? v.trim() : '');
|
|
161
|
+
const assignmentToken = str(b.assignment_token);
|
|
162
|
+
const profileId = str(b.profile_id);
|
|
163
|
+
const orgId = str(b.org_id);
|
|
164
|
+
const leaseId = str(b.lease_id);
|
|
165
|
+
const generation = typeof b.generation === 'number' && Number.isFinite(b.generation) ? b.generation : 0;
|
|
166
|
+
if (!assignmentToken)
|
|
167
|
+
return { ok: false, error: 'assignment_token required' };
|
|
168
|
+
if (!assignmentToken.startsWith(ASSIGNMENT_TOKEN_PREFIX)) {
|
|
169
|
+
return { ok: false, error: `assignment_token must be a "${ASSIGNMENT_TOKEN_PREFIX}" token` };
|
|
170
|
+
}
|
|
171
|
+
if (!profileId)
|
|
172
|
+
return { ok: false, error: 'profile_id required' };
|
|
173
|
+
if (!leaseId)
|
|
174
|
+
return { ok: false, error: 'lease_id required' };
|
|
175
|
+
let state;
|
|
176
|
+
if (b.state !== undefined && b.state !== null) {
|
|
177
|
+
if (typeof b.state !== 'object')
|
|
178
|
+
return { ok: false, error: 'state must be an object' };
|
|
179
|
+
const s = b.state;
|
|
180
|
+
const bucket = str(s.bucket);
|
|
181
|
+
const accessKeyId = str(s.access_key_id);
|
|
182
|
+
const secretAccessKey = str(s.secret_access_key);
|
|
183
|
+
const sessionToken = str(s.session_token);
|
|
184
|
+
if (!bucket || !accessKeyId || !secretAccessKey || !sessionToken) {
|
|
185
|
+
return {
|
|
186
|
+
ok: false,
|
|
187
|
+
error: 'state requires bucket + access_key_id + secret_access_key + session_token',
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
state = {
|
|
191
|
+
bucket,
|
|
192
|
+
region: str(s.region),
|
|
193
|
+
endpoint: str(s.endpoint) || undefined,
|
|
194
|
+
accessKeyId,
|
|
195
|
+
secretAccessKey,
|
|
196
|
+
sessionToken,
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
return { ok: true, value: { assignmentToken, profileId, orgId, leaseId, generation, state } };
|
|
200
|
+
}
|
|
201
|
+
/**
|
|
202
|
+
* Apply an /assign payload to `env` (process.env in prod) so the assigned path
|
|
203
|
+
* reads identity + S3 creds EXACTLY as the controller injects them into a cold
|
|
204
|
+
* pod's spec. The AWS_* creds must land in process.env: the S3 client
|
|
205
|
+
* (browser-state-store buildS3Client) reads the default AWS provider chain.
|
|
206
|
+
*/
|
|
207
|
+
export function applyAssignmentEnv(env, req) {
|
|
208
|
+
env.PRLL_ASSIGNMENT_TOKEN = req.assignmentToken;
|
|
209
|
+
env.PRLL_PROFILE_ID = req.profileId;
|
|
210
|
+
if (req.orgId)
|
|
211
|
+
env.PRLL_ORG_ID = req.orgId;
|
|
212
|
+
if (req.state) {
|
|
213
|
+
env.AWS_ACCESS_KEY_ID = req.state.accessKeyId;
|
|
214
|
+
env.AWS_SECRET_ACCESS_KEY = req.state.secretAccessKey;
|
|
215
|
+
env.AWS_SESSION_TOKEN = req.state.sessionToken;
|
|
216
|
+
if (req.state.region)
|
|
217
|
+
env.AWS_REGION = req.state.region;
|
|
218
|
+
env.PRLL_BROWSER_STATE_BUCKET = req.state.bucket;
|
|
219
|
+
env.PRLL_STATE_GENERATION = String(req.generation);
|
|
220
|
+
if (req.state.endpoint)
|
|
221
|
+
env.PRLL_BROWSER_STATE_ENDPOINT = req.state.endpoint;
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
/** Constant-time bearer comparison against the injected pool token. The pod cannot
|
|
225
|
+
* verify the JWT signature (no shared secret), so it string-compares the bearer to
|
|
226
|
+
* its own PRLL_POOL_TOKEN — the controller reads that same value back from the pod
|
|
227
|
+
* spec when it POSTs /assign. */
|
|
228
|
+
function poolTokenMatches(bearer, expected) {
|
|
229
|
+
if (!bearer)
|
|
230
|
+
return false;
|
|
231
|
+
const a = Buffer.from(bearer);
|
|
232
|
+
const b = Buffer.from(expected);
|
|
233
|
+
return a.length === b.length && timingSafeEqual(a, b);
|
|
234
|
+
}
|
|
235
|
+
function errString(err) {
|
|
236
|
+
return err instanceof Error ? (err.stack ?? err.message) : String(err);
|
|
237
|
+
}
|
|
238
|
+
/**
|
|
239
|
+
* Build the warm pod's /assign controller. The cardinal rules:
|
|
240
|
+
* - auth: bearer MUST equal the pool token (constant-time).
|
|
241
|
+
* - single-assignment: a warm pod serves ONE profile for life — a repeat for the
|
|
242
|
+
* same lease is idempotent (200), a different lease is a conflict (409). The
|
|
243
|
+
* lease is claimed SYNCHRONOUSLY so a duplicate/concurrent POST conflicts.
|
|
244
|
+
* - eventual readiness: identity is applied then the real runtime is started in
|
|
245
|
+
* the BACKGROUND (hydrate + connect take seconds); /readyz is the gate, exactly
|
|
246
|
+
* as a cold pod whose Create returns fast and becomes Ready later.
|
|
247
|
+
* `startRuntime` is injected so the assign flow is testable without a real hub.
|
|
248
|
+
*/
|
|
249
|
+
export function createPoolAssign(opts) {
|
|
250
|
+
let runtime = null;
|
|
251
|
+
let leaseId = null;
|
|
252
|
+
const handler = async (bearer, body) => {
|
|
253
|
+
if (!poolTokenMatches(bearer, opts.poolToken)) {
|
|
254
|
+
return { status: 401, message: 'unauthorized' };
|
|
255
|
+
}
|
|
256
|
+
const parsed = parseAssignRequest(body);
|
|
257
|
+
if (!parsed.ok)
|
|
258
|
+
return { status: 400, message: parsed.error };
|
|
259
|
+
const req = parsed.value;
|
|
260
|
+
if (leaseId !== null) {
|
|
261
|
+
if (leaseId === req.leaseId)
|
|
262
|
+
return { status: 200, message: 'already assigned (idempotent)' };
|
|
263
|
+
return { status: 409, message: 'pod already assigned to a different lease' };
|
|
264
|
+
}
|
|
265
|
+
leaseId = req.leaseId; // claim synchronously: a duplicate/concurrent POST now conflicts
|
|
266
|
+
applyAssignmentEnv(opts.env, req);
|
|
267
|
+
opts.log.info(`assigned: profile=${req.profileId} lease=${req.leaseId} gen=${req.generation} state=${req.state ? 'on' : 'off'}`);
|
|
268
|
+
void (async () => {
|
|
269
|
+
try {
|
|
270
|
+
runtime = await opts.startRuntime(opts.env);
|
|
271
|
+
opts.log.info(`runtime started for lease=${req.leaseId}`);
|
|
272
|
+
}
|
|
273
|
+
catch (err) {
|
|
274
|
+
opts.log.error(`runtime start failed for lease=${req.leaseId}: ${errString(err)} — /readyz stays 503; controller releases on the registration deadline`);
|
|
275
|
+
}
|
|
276
|
+
})();
|
|
277
|
+
return { status: 200, message: 'assignment accepted' };
|
|
278
|
+
};
|
|
279
|
+
return {
|
|
280
|
+
handler,
|
|
281
|
+
isReady: () => runtime?.isReady() ?? false,
|
|
282
|
+
drain: async () => {
|
|
283
|
+
if (runtime)
|
|
284
|
+
await runtime.drain();
|
|
285
|
+
},
|
|
286
|
+
assignedLeaseId: () => leaseId,
|
|
287
|
+
};
|
|
288
|
+
}
|
|
289
|
+
/**
|
|
290
|
+
* BrowserPodRuntime owns the pod's readiness state and the ordered teardown. It
|
|
291
|
+
* is deliberately decoupled from the concrete ClipProvider / BrowserProfileManager
|
|
292
|
+
* (only the narrow PodProvider / PodBrowser surfaces) so the readiness gate and
|
|
293
|
+
* drain ORDER are unit-testable without a real browser or hub.
|
|
294
|
+
*/
|
|
295
|
+
export class BrowserPodRuntime {
|
|
296
|
+
provider;
|
|
297
|
+
browser;
|
|
298
|
+
log;
|
|
299
|
+
checkpointer;
|
|
300
|
+
checkpointIntervalMs;
|
|
301
|
+
draining = false;
|
|
302
|
+
drainPromise = null;
|
|
303
|
+
checkpointTimer = null;
|
|
304
|
+
constructor(provider, browser, log,
|
|
305
|
+
// Optional S3 state checkpointer (PR4). Absent => stateless pod (no
|
|
306
|
+
// hydrate/checkpoint); the drain order then matches the pre-PR4 contract.
|
|
307
|
+
checkpointer, checkpointIntervalMs = DEFAULT_CHECKPOINT_INTERVAL_MS) {
|
|
308
|
+
this.provider = provider;
|
|
309
|
+
this.browser = browser;
|
|
310
|
+
this.log = log;
|
|
311
|
+
this.checkpointer = checkpointer;
|
|
312
|
+
this.checkpointIntervalMs = checkpointIntervalMs;
|
|
313
|
+
}
|
|
314
|
+
/**
|
|
315
|
+
* Route-readiness: registered to the hub AND not draining. `draining` is checked
|
|
316
|
+
* first so the readiness gate flips false the instant a drain begins — before
|
|
317
|
+
* the (async) ProviderStream teardown completes. The hub fences the stream on
|
|
318
|
+
* the pba_ token + live lease, so isConnected() already encodes "token/lease
|
|
319
|
+
* still valid".
|
|
320
|
+
*/
|
|
321
|
+
isReady() {
|
|
322
|
+
return !this.draining && this.provider.isConnected();
|
|
323
|
+
}
|
|
324
|
+
/** Open the ProviderStream, register as the "browser" provider, and start the
|
|
325
|
+
* periodic checkpoint loop (if a checkpointer was supplied). */
|
|
326
|
+
async start() {
|
|
327
|
+
await this.provider.connect();
|
|
328
|
+
this.startCheckpointLoop();
|
|
329
|
+
}
|
|
330
|
+
/** Periodic S3 checkpoints while serving. Best-effort: a checkpoint error is
|
|
331
|
+
* logged but never throws (it must not kill the pod); the timer is unref'd so it
|
|
332
|
+
* never keeps the process alive on its own. */
|
|
333
|
+
startCheckpointLoop() {
|
|
334
|
+
if (!this.checkpointer || this.checkpointIntervalMs <= 0)
|
|
335
|
+
return;
|
|
336
|
+
const cp = this.checkpointer;
|
|
337
|
+
this.checkpointTimer = setInterval(() => {
|
|
338
|
+
if (this.draining)
|
|
339
|
+
return;
|
|
340
|
+
void cp.checkpointPeriodic().catch((err) => {
|
|
341
|
+
this.log.warn(`periodic checkpoint error: ${String(err)}`);
|
|
342
|
+
});
|
|
343
|
+
}, this.checkpointIntervalMs);
|
|
344
|
+
this.checkpointTimer.unref?.();
|
|
345
|
+
}
|
|
346
|
+
/**
|
|
347
|
+
* Graceful drain (SIGTERM / lease release): fail readiness FIRST, stop the
|
|
348
|
+
* periodic checkpoint loop, then tear down the hub stream and the browser, and
|
|
349
|
+
* finally take ONE last checkpoint AFTER bb-browser is stopped (a consistent
|
|
350
|
+
* snapshot of the settled state — design §3.2). Idempotent — a second SIGTERM
|
|
351
|
+
* joins the first.
|
|
352
|
+
*/
|
|
353
|
+
async drain() {
|
|
354
|
+
if (this.drainPromise)
|
|
355
|
+
return this.drainPromise;
|
|
356
|
+
this.draining = true; // readiness -> false BEFORE we close the stream
|
|
357
|
+
this.log.info('draining (readiness -> false)');
|
|
358
|
+
if (this.checkpointTimer) {
|
|
359
|
+
clearInterval(this.checkpointTimer);
|
|
360
|
+
this.checkpointTimer = null;
|
|
361
|
+
}
|
|
362
|
+
this.drainPromise = (async () => {
|
|
363
|
+
try {
|
|
364
|
+
await this.provider.disconnect();
|
|
365
|
+
}
|
|
366
|
+
catch (err) {
|
|
367
|
+
this.log.warn(`provider disconnect during drain failed: ${String(err)}`);
|
|
368
|
+
}
|
|
369
|
+
try {
|
|
370
|
+
await this.browser.stop();
|
|
371
|
+
}
|
|
372
|
+
catch (err) {
|
|
373
|
+
this.log.warn(`browser stop during drain failed: ${String(err)}`);
|
|
374
|
+
}
|
|
375
|
+
// Final checkpoint AFTER bb-browser has torn down — Chromium has flushed and
|
|
376
|
+
// released its files, so the snapshot is consistent. A failure here is bounded
|
|
377
|
+
// loss (the delta since the last successful checkpoint), logged below —
|
|
378
|
+
// durability is best-effort + on-close, with no controller-side verify/repair
|
|
379
|
+
// (design §3.2 "simplified durability").
|
|
380
|
+
if (this.checkpointer) {
|
|
381
|
+
try {
|
|
382
|
+
await this.checkpointer.checkpointFinal();
|
|
383
|
+
}
|
|
384
|
+
catch (err) {
|
|
385
|
+
this.log.error(`final checkpoint during drain failed: ${String(err)}`);
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
})();
|
|
389
|
+
return this.drainPromise;
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
/**
|
|
393
|
+
* Create the liveness/readiness HTTP server. `/livez` is mere process liveness;
|
|
394
|
+
* `/readyz` is route-readiness driven by `isReady`. Mirrors the Go stub's health
|
|
395
|
+
* contract so the controller's probes are runtime-agnostic.
|
|
396
|
+
*/
|
|
397
|
+
export function createHealthServer(opts) {
|
|
398
|
+
const server = http.createServer((req, res) => {
|
|
399
|
+
const url = req.url ?? '';
|
|
400
|
+
if (url === '/livez') {
|
|
401
|
+
res.writeHead(opts.isLive() ? 200 : 503).end();
|
|
402
|
+
return;
|
|
403
|
+
}
|
|
404
|
+
if (url === '/readyz') {
|
|
405
|
+
res.writeHead(opts.isReady() ? 200 : 503).end();
|
|
406
|
+
return;
|
|
407
|
+
}
|
|
408
|
+
if (opts.onAssign && url === '/assign' && req.method === 'POST') {
|
|
409
|
+
handleAssignRequest(req, res, opts.onAssign, opts.log);
|
|
410
|
+
return;
|
|
411
|
+
}
|
|
412
|
+
res.writeHead(404).end();
|
|
413
|
+
});
|
|
414
|
+
// Bound header read so a slow/half-open probe connection can't pin a slot.
|
|
415
|
+
server.headersTimeout = 5_000;
|
|
416
|
+
server.requestTimeout = 10_000;
|
|
417
|
+
return server;
|
|
418
|
+
}
|
|
419
|
+
/** Cap the /assign body — the payload is a small JSON blob (token + creds); a
|
|
420
|
+
* larger body is malformed/hostile and must not buffer unbounded. */
|
|
421
|
+
const MAX_ASSIGN_BODY_BYTES = 64 * 1024;
|
|
422
|
+
/** Read + JSON-parse a bounded POST /assign body, dispatch to the handler, and
|
|
423
|
+
* write its outcome. All failures map to an HTTP status (never crash the server). */
|
|
424
|
+
function handleAssignRequest(req, res, onAssign, log) {
|
|
425
|
+
const chunks = [];
|
|
426
|
+
let size = 0;
|
|
427
|
+
let aborted = false;
|
|
428
|
+
const fail = (status) => {
|
|
429
|
+
if (aborted)
|
|
430
|
+
return;
|
|
431
|
+
aborted = true;
|
|
432
|
+
res.writeHead(status).end();
|
|
433
|
+
};
|
|
434
|
+
req.on('data', (c) => {
|
|
435
|
+
if (aborted)
|
|
436
|
+
return;
|
|
437
|
+
size += c.length;
|
|
438
|
+
if (size > MAX_ASSIGN_BODY_BYTES) {
|
|
439
|
+
fail(413);
|
|
440
|
+
req.destroy();
|
|
441
|
+
return;
|
|
442
|
+
}
|
|
443
|
+
chunks.push(c);
|
|
444
|
+
});
|
|
445
|
+
req.on('error', () => fail(400));
|
|
446
|
+
req.on('end', () => {
|
|
447
|
+
if (aborted)
|
|
448
|
+
return;
|
|
449
|
+
let body;
|
|
450
|
+
try {
|
|
451
|
+
body = JSON.parse(Buffer.concat(chunks).toString('utf8') || '{}');
|
|
452
|
+
}
|
|
453
|
+
catch {
|
|
454
|
+
res
|
|
455
|
+
.writeHead(400, { 'content-type': 'application/json' })
|
|
456
|
+
.end(JSON.stringify({ ok: false, error: 'invalid JSON' }));
|
|
457
|
+
return;
|
|
458
|
+
}
|
|
459
|
+
void onAssign(bearerFromHeader(req.headers.authorization), body)
|
|
460
|
+
.then((outcome) => {
|
|
461
|
+
res.writeHead(outcome.status, { 'content-type': 'application/json' }).end(JSON.stringify({
|
|
462
|
+
ok: outcome.status >= 200 && outcome.status < 300,
|
|
463
|
+
message: outcome.message,
|
|
464
|
+
}));
|
|
465
|
+
})
|
|
466
|
+
.catch((err) => {
|
|
467
|
+
log.error(`/assign handler error: ${errString(err)}`);
|
|
468
|
+
res
|
|
469
|
+
.writeHead(500, { 'content-type': 'application/json' })
|
|
470
|
+
.end(JSON.stringify({ ok: false }));
|
|
471
|
+
});
|
|
472
|
+
});
|
|
473
|
+
}
|
|
474
|
+
/** Strip a leading "Bearer " from an Authorization header (case-sensitive scheme,
|
|
475
|
+
* matching the hub). Returns the raw credential, or undefined when absent. */
|
|
476
|
+
function bearerFromHeader(authorization) {
|
|
477
|
+
if (!authorization)
|
|
478
|
+
return undefined;
|
|
479
|
+
return authorization.startsWith('Bearer ') ? authorization.slice(7) : authorization;
|
|
480
|
+
}
|
|
481
|
+
/**
|
|
482
|
+
* Build the S3 state store from env, or return null (stateless pod) ONLY when no
|
|
483
|
+
* bucket is configured (the feature is off). When a bucket IS set, state was
|
|
484
|
+
* REQUESTED, so the org id (part of the S3 key + the pod's STS prefix scope) and the
|
|
485
|
+
* activation generation (the single-writer fence) are required: a missing/invalid
|
|
486
|
+
* value is a controller-injection misconfiguration and FAILS CLOSED (throws) rather
|
|
487
|
+
* than silently registering a non-persisted profile — silent stateless serving when
|
|
488
|
+
* state was configured is data loss. The thrown error propagates to pod startup, so
|
|
489
|
+
* the pod never reaches readiness and the controller releases it on the deadline.
|
|
490
|
+
*/
|
|
491
|
+
export function buildStateStore(config, env, log) {
|
|
492
|
+
const bucket = env.PRLL_BROWSER_STATE_BUCKET?.trim();
|
|
493
|
+
if (!bucket)
|
|
494
|
+
return null; // no bucket = intentionally stateless
|
|
495
|
+
if (!config.orgId) {
|
|
496
|
+
throw new Error(`browser state enabled (bucket=${bucket}) but PRLL_ORG_ID is empty — refusing to run a non-persisted profile (fail closed)`);
|
|
497
|
+
}
|
|
498
|
+
const genRaw = env.PRLL_STATE_GENERATION?.trim();
|
|
499
|
+
if (!genRaw) {
|
|
500
|
+
// Missing: never silently default to generation 0 — that would un-fence the pod
|
|
501
|
+
// (gen 0 is the lowest, so any real pod clobbers it). State configured but invalid
|
|
502
|
+
// = refuse to run BEFORE the provider opens (this throw propagates to startup).
|
|
503
|
+
throw new Error(`browser state enabled (bucket=${bucket}) but PRLL_STATE_GENERATION is unset — refusing to run a non-persisted profile (fail closed)`);
|
|
504
|
+
}
|
|
505
|
+
const gen = Number(genRaw);
|
|
506
|
+
if (!Number.isSafeInteger(gen) || gen < 0) {
|
|
507
|
+
throw new Error(`browser state enabled (bucket=${bucket}) but PRLL_STATE_GENERATION="${genRaw}" is not a non-negative safe integer — refusing to run a non-persisted profile (fail closed)`);
|
|
508
|
+
}
|
|
509
|
+
return new BrowserStateStore({
|
|
510
|
+
bucket,
|
|
511
|
+
region: env.AWS_REGION?.trim() || env.PRLL_BROWSER_STATE_REGION?.trim() || '',
|
|
512
|
+
endpoint: env.PRLL_BROWSER_STATE_ENDPOINT?.trim() || undefined,
|
|
513
|
+
orgId: config.orgId,
|
|
514
|
+
profileId: config.profileId,
|
|
515
|
+
generation: gen,
|
|
516
|
+
homeDir: config.homeDir,
|
|
517
|
+
log,
|
|
518
|
+
});
|
|
519
|
+
}
|
|
520
|
+
/** Resolve the periodic checkpoint cadence (ms) from env, clamped to ≥1s. */
|
|
521
|
+
export function resolveCheckpointIntervalMs(env) {
|
|
522
|
+
const raw = env.PRLL_BROWSER_STATE_CHECKPOINT_INTERVAL_MS?.trim();
|
|
523
|
+
if (!raw)
|
|
524
|
+
return DEFAULT_CHECKPOINT_INTERVAL_MS;
|
|
525
|
+
const n = Number(raw);
|
|
526
|
+
return Number.isFinite(n) && n >= 1_000 ? n : DEFAULT_CHECKPOINT_INTERVAL_MS;
|
|
527
|
+
}
|
|
528
|
+
/**
|
|
529
|
+
* Whether it is SAFE to checkpoint after a hydrate. Checkpoint ONLY when the prior
|
|
530
|
+
* snapshot was loaded (hydrated) or there was genuinely none (a new profile —
|
|
531
|
+
* head returned null, so no flags set). If a snapshot EXISTED but could not be
|
|
532
|
+
* loaded (checksum mismatch, or a download/extract/HEAD error), checkpointing must
|
|
533
|
+
* be disabled: an empty homeDir would otherwise overwrite the good state, and the
|
|
534
|
+
* generation fence permits it (this pod's gen is legitimately higher than the
|
|
535
|
+
* snapshot's). Returns false in that case so the pod runs checkpoint-less, leaving
|
|
536
|
+
* the existing snapshot untouched rather than clobbering it (simplified durability:
|
|
537
|
+
* the prior state is preserved in place — there is no controller-side repair).
|
|
538
|
+
*/
|
|
539
|
+
export function shouldCheckpointAfterHydrate(result) {
|
|
540
|
+
return !(result.checksumMismatch === true || result.error !== undefined);
|
|
541
|
+
}
|
|
542
|
+
/** Adapt a BrowserStateStore to the narrow PodStateCheckpointer the runtime drives. */
|
|
543
|
+
function makeCheckpointer(store) {
|
|
544
|
+
return {
|
|
545
|
+
checkpointPeriodic: async () => {
|
|
546
|
+
await store.checkpoint({ final: false });
|
|
547
|
+
},
|
|
548
|
+
checkpointFinal: async () => {
|
|
549
|
+
await store.checkpoint({ final: true });
|
|
550
|
+
},
|
|
551
|
+
};
|
|
552
|
+
}
|
|
553
|
+
/**
|
|
554
|
+
* Wire the real clip-runtime stack for a hosted browser pod from a resolved config
|
|
555
|
+
* and return the {@link BrowserPodRuntime} — WITHOUT creating the health server,
|
|
556
|
+
* binding a port, or parking on a signal. Splitting this out lets the warm-pool
|
|
557
|
+
* path (runPoolPod) reuse the EXACT assigned-pod wiring after a `/assign` arrives,
|
|
558
|
+
* while keeping a single control/health server bound on healthPort (the pool path
|
|
559
|
+
* already owns it). hydrate() runs here — before the provider opens — because
|
|
560
|
+
* bb-browser launches lazily on first invoke, so the snapshot must be on disk first.
|
|
561
|
+
*/
|
|
562
|
+
export async function preparePodRuntime(config, env, log) {
|
|
563
|
+
// Real browser runtime — starts bb-browser + Chromium lazily on first invoke,
|
|
564
|
+
// ensures the single profile's account, persists under homeDir (BB_BROWSER_HOME).
|
|
565
|
+
const browser = new BrowserProfileManager({ homeDir: config.homeDir, log });
|
|
566
|
+
// The process manager hosts no installable clips in pod mode — its only job is
|
|
567
|
+
// to advertise the synthetic "browser" capability (getProviderClips appends it
|
|
568
|
+
// because a BrowserProfileManager is present) and route inbound browser invokes
|
|
569
|
+
// to bb-browser. The clip/data dirs are required but unused (no subprocess).
|
|
570
|
+
const clipManager = new ClipProcessManager({
|
|
571
|
+
clipsDir: path.join(config.stateDir, 'clips'),
|
|
572
|
+
dataDir: path.join(config.stateDir, 'clip-data'),
|
|
573
|
+
browserProfileManager: browser,
|
|
574
|
+
});
|
|
575
|
+
// Hosted live-viewer handler (design §3.4): clip-service relays viewer commands
|
|
576
|
+
// down the ProviderStream and the pod replies over it. Serialize them — they
|
|
577
|
+
// mutate the single profile's bb-viewer streamer + account state, so a client
|
|
578
|
+
// retrying stream.start must not interleave with the in-flight one (the BYOC
|
|
579
|
+
// supervisor serializes per profile for the same reason). One pod = one
|
|
580
|
+
// profile, so a single chain suffices.
|
|
581
|
+
let viewerChain = Promise.resolve();
|
|
582
|
+
const onViewerCommand = (cmd) => {
|
|
583
|
+
const run = viewerChain
|
|
584
|
+
.catch(() => { }) // keep the chain moving past a prior failure
|
|
585
|
+
.then(() => browser.handleViewerCommand(cmd.profileId, cmd.sessionId, cmd.command, cmd.input, cmd.turn));
|
|
586
|
+
viewerChain = run.catch(() => { });
|
|
587
|
+
return run;
|
|
588
|
+
};
|
|
589
|
+
// ClipProvider passes authKey straight through as the ProviderStream bearer —
|
|
590
|
+
// it does not assume mck_ — so the pba_ assignment token authenticates the pod.
|
|
591
|
+
// providerName is cosmetic for routing (the hub keys the session by the token's
|
|
592
|
+
// profile_id), but we use the pod id so logs/registration map to this pod.
|
|
593
|
+
const provider = new ClipProvider({
|
|
594
|
+
serviceUrl: config.clipRpcUrl,
|
|
595
|
+
authKey: config.assignmentToken,
|
|
596
|
+
orgId: config.orgId,
|
|
597
|
+
providerName: config.podId,
|
|
598
|
+
clipManager,
|
|
599
|
+
log,
|
|
600
|
+
onViewerCommand,
|
|
601
|
+
});
|
|
602
|
+
// S3 state externalization (PR4, design §3.2). Disabled (stateless pod) when no
|
|
603
|
+
// bucket is configured. HYDRATE here — before the provider opens and any invoke
|
|
604
|
+
// can land — because bb-browser launches lazily on first invoke, so the snapshot
|
|
605
|
+
// must already be on disk. hydrate() never throws (a read failure → fresh start).
|
|
606
|
+
const stateStore = buildStateStore(config, env, log);
|
|
607
|
+
let checkpointer;
|
|
608
|
+
if (stateStore) {
|
|
609
|
+
const result = await stateStore.hydrate();
|
|
610
|
+
// Data-loss guard: only attach a checkpointer when it is safe to overwrite the
|
|
611
|
+
// snapshot (we loaded it, or there was genuinely none). If a snapshot existed
|
|
612
|
+
// but could not be hydrated, run checkpoint-less to preserve it — see
|
|
613
|
+
// shouldCheckpointAfterHydrate.
|
|
614
|
+
if (shouldCheckpointAfterHydrate(result)) {
|
|
615
|
+
checkpointer = makeCheckpointer(stateStore);
|
|
616
|
+
}
|
|
617
|
+
else {
|
|
618
|
+
log.error('browser-state: prior snapshot present but un-hydratable — running WITHOUT checkpoint to preserve the existing snapshot in place');
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
else {
|
|
622
|
+
log.info('browser state externalization disabled (no PRLL_BROWSER_STATE_BUCKET) — running stateless');
|
|
623
|
+
}
|
|
624
|
+
return new BrowserPodRuntime(provider, browser, log, checkpointer, resolveCheckpointIntervalMs(env));
|
|
625
|
+
}
|
|
626
|
+
/**
|
|
627
|
+
* Wire the real clip-runtime stack for a hosted browser pod and run until the
|
|
628
|
+
* abort signal fires (SIGTERM). Returns after the ordered drain completes. This is
|
|
629
|
+
* the ASSIGNED path: PRLL_ASSIGNMENT_TOKEN + profile are already in env (the
|
|
630
|
+
* controller's cold-create pod spec). The warm-pool path (runPoolPod) reaches the
|
|
631
|
+
* same runtime via preparePodRuntime once a `/assign` delivers the identity.
|
|
632
|
+
*/
|
|
633
|
+
export async function runBrowserPod(env, signal, log) {
|
|
634
|
+
const config = resolveBrowserPodConfig(env);
|
|
635
|
+
log.info(`starting: profile=${config.profileId} pod=${config.podId} ` +
|
|
636
|
+
`org=${config.orgId || '(unset)'} clip_rpc=${config.clipRpcUrl} health_port=${config.healthPort}`);
|
|
637
|
+
const runtime = await preparePodRuntime(config, env, log);
|
|
638
|
+
const health = createHealthServer({
|
|
639
|
+
isLive: () => true,
|
|
640
|
+
isReady: () => runtime.isReady(),
|
|
641
|
+
log,
|
|
642
|
+
});
|
|
643
|
+
await new Promise((resolve, reject) => {
|
|
644
|
+
health.once('error', reject);
|
|
645
|
+
health.listen(config.healthPort, () => {
|
|
646
|
+
health.removeListener('error', reject);
|
|
647
|
+
log.info(`health server listening on :${config.healthPort}`);
|
|
648
|
+
resolve();
|
|
649
|
+
});
|
|
650
|
+
});
|
|
651
|
+
try {
|
|
652
|
+
await runtime.start();
|
|
653
|
+
log.info('provider stream opened — awaiting hub registration');
|
|
654
|
+
// Run until SIGTERM. ClipProvider keeps the stream alive (heartbeat +
|
|
655
|
+
// reconnect) internally; we just park here until told to drain.
|
|
656
|
+
await new Promise((resolve) => {
|
|
657
|
+
if (signal.aborted) {
|
|
658
|
+
resolve();
|
|
659
|
+
return;
|
|
660
|
+
}
|
|
661
|
+
signal.addEventListener('abort', () => resolve(), { once: true });
|
|
662
|
+
});
|
|
663
|
+
}
|
|
664
|
+
finally {
|
|
665
|
+
await runtime.drain();
|
|
666
|
+
await new Promise((resolve) => health.close(() => resolve()));
|
|
667
|
+
log.info('shut down');
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
/**
|
|
671
|
+
* Run a WARM pod: bind ONE control/health server (livez/readyz/assign) on
|
|
672
|
+
* healthPort and park — no Chromium, no profile, no hub registration — until a
|
|
673
|
+
* `POST /assign` delivers an identity, at which point the pod transitions into the
|
|
674
|
+
* exact assigned-pod runtime (preparePodRuntime, hydrating S3 like a cold pod). On
|
|
675
|
+
* SIGTERM, drain the started runtime (if any) then close the server. The single
|
|
676
|
+
* server is the reason runPoolPod reuses preparePodRuntime rather than runBrowserPod
|
|
677
|
+
* (which would bind its own health port).
|
|
678
|
+
*/
|
|
679
|
+
export async function runPoolPod(env, signal, log) {
|
|
680
|
+
const pool = resolvePoolPodConfig(env);
|
|
681
|
+
log.info(`pool mode: pod=${pool.podId} clip_rpc=${pool.clipRpcUrl} health_port=${pool.healthPort} — awaiting /assign`);
|
|
682
|
+
const assign = createPoolAssign({
|
|
683
|
+
poolToken: pool.poolToken,
|
|
684
|
+
env,
|
|
685
|
+
log,
|
|
686
|
+
// The assigned path, unchanged: env now carries the identity + S3 creds, so
|
|
687
|
+
// resolveBrowserPodConfig + preparePodRuntime behave exactly as a cold pod.
|
|
688
|
+
startRuntime: async (e) => {
|
|
689
|
+
const config = resolveBrowserPodConfig(e);
|
|
690
|
+
log.info(`pool→assigned: profile=${config.profileId} pod=${config.podId} org=${config.orgId || '(unset)'}`);
|
|
691
|
+
const runtime = await preparePodRuntime(config, e, log);
|
|
692
|
+
await runtime.start();
|
|
693
|
+
return runtime;
|
|
694
|
+
},
|
|
695
|
+
});
|
|
696
|
+
const control = createHealthServer({
|
|
697
|
+
isLive: () => true,
|
|
698
|
+
isReady: () => assign.isReady(),
|
|
699
|
+
log,
|
|
700
|
+
onAssign: assign.handler,
|
|
701
|
+
});
|
|
702
|
+
await new Promise((resolve, reject) => {
|
|
703
|
+
control.once('error', reject);
|
|
704
|
+
control.listen(pool.healthPort, () => {
|
|
705
|
+
control.removeListener('error', reject);
|
|
706
|
+
log.info(`pool control server listening on :${pool.healthPort}`);
|
|
707
|
+
resolve();
|
|
708
|
+
});
|
|
709
|
+
});
|
|
710
|
+
try {
|
|
711
|
+
await new Promise((resolve) => {
|
|
712
|
+
if (signal.aborted) {
|
|
713
|
+
resolve();
|
|
714
|
+
return;
|
|
715
|
+
}
|
|
716
|
+
signal.addEventListener('abort', () => resolve(), { once: true });
|
|
717
|
+
});
|
|
718
|
+
}
|
|
719
|
+
finally {
|
|
720
|
+
await assign.drain();
|
|
721
|
+
await new Promise((resolve) => control.close(() => resolve()));
|
|
722
|
+
log.info('pool pod shut down');
|
|
723
|
+
}
|
|
724
|
+
}
|
|
725
|
+
/** Process entrypoint: wire SIGINT/SIGTERM → abort, then run the pod to drain.
|
|
726
|
+
* Mode is selected by which token is present: PRLL_ASSIGNMENT_TOKEN → assigned
|
|
727
|
+
* (cold-create), else PRLL_POOL_TOKEN → warm pool. The controller's pod spec
|
|
728
|
+
* injects exactly one. */
|
|
729
|
+
async function main() {
|
|
730
|
+
const log = createLogger('browser-pod');
|
|
731
|
+
const abort = new AbortController();
|
|
732
|
+
const onSignal = (sig) => {
|
|
733
|
+
log.info(`received ${sig} — initiating shutdown`);
|
|
734
|
+
abort.abort();
|
|
735
|
+
};
|
|
736
|
+
process.on('SIGINT', () => onSignal('SIGINT'));
|
|
737
|
+
process.on('SIGTERM', () => onSignal('SIGTERM'));
|
|
738
|
+
try {
|
|
739
|
+
if (process.env.PRLL_ASSIGNMENT_TOKEN?.trim()) {
|
|
740
|
+
await runBrowserPod(process.env, abort.signal, log);
|
|
741
|
+
}
|
|
742
|
+
else if (process.env.PRLL_POOL_TOKEN?.trim()) {
|
|
743
|
+
await runPoolPod(process.env, abort.signal, log);
|
|
744
|
+
}
|
|
745
|
+
else {
|
|
746
|
+
throw new Error('browser-pod: set PRLL_ASSIGNMENT_TOKEN (assigned pod) or PRLL_POOL_TOKEN (warm pool pod)');
|
|
747
|
+
}
|
|
748
|
+
}
|
|
749
|
+
catch (err) {
|
|
750
|
+
log.error(`fatal: ${err instanceof Error ? (err.stack ?? err.message) : String(err)}`);
|
|
751
|
+
process.exitCode = 1;
|
|
752
|
+
}
|
|
753
|
+
}
|
|
754
|
+
// Run only when invoked as the entrypoint (bundled bin), not when imported by a
|
|
755
|
+
// test. We are the entry iff import.meta.url points at the same file as argv[1].
|
|
756
|
+
// CRUCIAL: resolve argv[1] through realpathSync first. The container runs this via
|
|
757
|
+
// the npm `parall-browser-pod` bin, which is a SYMLINK — Node sets argv[1] to the
|
|
758
|
+
// symlink path but resolves import.meta.url to the real file, so a raw comparison is
|
|
759
|
+
// always false and main() never runs (the pod then exits 0 immediately, doing
|
|
760
|
+
// nothing). realpathSync collapses the symlink so the entry check holds; when this
|
|
761
|
+
// module is merely imported (tests) argv[1] is the test runner, so it stays false.
|
|
762
|
+
const entry = process.argv[1];
|
|
763
|
+
if (entry && import.meta.url === pathToFileURL(realpathSync(entry)).href) {
|
|
764
|
+
void main();
|
|
765
|
+
}
|