@volter/world-runtime 3.0.163 → 3.0.164
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/src/ancestry.js +2 -2
- package/dist/src/app-url.d.ts +5 -1
- package/dist/src/app-url.js +14 -1
- package/dist/src/branch.js +28 -8
- package/dist/src/browser-files.d.ts +5 -0
- package/dist/src/browser-files.js +14 -0
- package/dist/src/browsers.d.ts +2 -3
- package/dist/src/browsers.js +4 -4
- package/dist/src/catalog.d.ts +2 -1
- package/dist/src/catalog.js +3 -2
- package/dist/src/changeset.js +1 -1
- package/dist/src/cli-commands.js +71 -18
- package/dist/src/cli-load-clock.d.ts +1 -0
- package/dist/src/cli-load-clock.js +2 -0
- package/dist/src/cli.js +5 -1
- package/dist/src/clickhouse-backing.d.ts +2 -0
- package/dist/src/clickhouse-backing.js +145 -0
- package/dist/src/command-lifetime.d.mts +1 -1
- package/dist/src/command-lifetime.mjs +5 -2
- package/dist/src/configs.js +1 -1
- package/dist/src/consumers.js +1 -1
- package/dist/src/covers.d.ts +4 -2
- package/dist/src/covers.js +8 -4
- package/dist/src/go-mysql-binding.d.ts +19 -0
- package/dist/src/go-mysql-binding.js +51 -0
- package/dist/src/guest-processes.d.mts +1 -1
- package/dist/src/guest-processes.mjs +22 -14
- package/dist/src/host-cli.js +8 -1
- package/dist/src/host-ready.d.ts +16 -2
- package/dist/src/host-ready.js +66 -39
- package/dist/src/host-worker.js +7 -1
- package/dist/src/host.d.ts +26 -0
- package/dist/src/host.js +93 -3
- package/dist/src/infra-definition.d.ts +35 -0
- package/dist/src/infra-definition.js +146 -0
- package/dist/src/infra.d.ts +3 -0
- package/dist/src/infra.js +375 -105
- package/dist/src/init.d.ts +46 -0
- package/dist/src/init.js +445 -18
- package/dist/src/lifecycle-record.d.ts +5 -1
- package/dist/src/lifecycle-record.js +27 -4
- package/dist/src/local-branches.js +1 -1
- package/dist/src/mariadb-bootstrap.d.ts +32 -0
- package/dist/src/mariadb-bootstrap.js +90 -0
- package/dist/src/mariadb-supplier.d.ts +23 -0
- package/dist/src/mariadb-supplier.js +215 -0
- package/dist/src/mongodb-backing.d.ts +4 -0
- package/dist/src/mongodb-backing.js +71 -1
- package/dist/src/native-mariadb-backing.d.ts +13 -0
- package/dist/src/native-mariadb-backing.js +512 -0
- package/dist/src/native-postgres-backing.d.ts +4 -10
- package/dist/src/native-postgres-backing.js +428 -43
- package/dist/src/native-server-inputs.d.ts +16 -0
- package/dist/src/native-server-inputs.js +204 -0
- package/dist/src/origin.js +3 -3
- package/dist/src/original-inputs.d.ts +14 -0
- package/dist/src/original-inputs.js +84 -0
- package/dist/src/pack-facts.d.ts +9 -1
- package/dist/src/pglite-backing.d.ts +41 -17
- package/dist/src/pglite-backing.js +213 -79
- package/dist/src/pglite-host.mjs +482 -10
- package/dist/src/process-groups.d.mts +6 -1
- package/dist/src/process-groups.d.ts +2 -1
- package/dist/src/process-groups.js +1 -1
- package/dist/src/process-groups.mjs +82 -15
- package/dist/src/process-signal.d.mts +2 -2
- package/dist/src/process-signal.mjs +29 -9
- package/dist/src/project-inspect.d.ts +12 -0
- package/dist/src/project-inspect.js +41 -4
- package/dist/src/proxy-env.d.ts +34 -0
- package/dist/src/proxy-env.js +114 -0
- package/dist/src/redirect-proxy.d.ts +1 -31
- package/dist/src/redirect-proxy.js +3 -112
- package/dist/src/redis-backing.d.ts +6 -4
- package/dist/src/redis-backing.js +20 -168
- package/dist/src/runtime.d.ts +1 -0
- package/dist/src/runtime.js +503 -218
- package/dist/src/schema.d.ts +1 -0
- package/dist/src/schema.js +5 -3
- package/dist/src/served-world.js +3 -3
- package/dist/src/twin-backing.d.ts +47 -0
- package/dist/src/twin-backing.js +343 -0
- package/dist/src/up-cli.js +56 -3
- package/package.json +3 -3
- package/src/ancestry.ts +2 -2
- package/src/app-url.ts +9 -2
- package/src/branch.ts +26 -8
- package/src/browser-files.ts +15 -0
- package/src/browsers.ts +4 -4
- package/src/catalog.ts +4 -3
- package/src/changeset.ts +1 -1
- package/src/cli-commands.ts +85 -18
- package/src/cli-load-clock.ts +2 -0
- package/src/cli.ts +5 -1
- package/src/clickhouse-backing.ts +70 -0
- package/src/command-lifetime.d.mts +1 -1
- package/src/command-lifetime.mjs +5 -2
- package/src/configs.ts +1 -1
- package/src/consumers.ts +1 -1
- package/src/covers.ts +9 -4
- package/src/go-mysql-binding.ts +49 -0
- package/src/guest-processes.d.mts +1 -1
- package/src/guest-processes.mjs +22 -14
- package/src/host-cli.ts +7 -1
- package/src/host-ready.ts +62 -31
- package/src/host-worker.ts +6 -2
- package/src/host.ts +88 -6
- package/src/infra-definition.ts +135 -0
- package/src/infra.ts +250 -45
- package/src/init.ts +416 -17
- package/src/lifecycle-record.ts +18 -4
- package/src/local-branches.ts +1 -1
- package/src/mariadb-bootstrap.ts +107 -0
- package/src/mariadb-supplier.ts +199 -0
- package/src/mongodb-backing.ts +59 -1
- package/src/native-mariadb-backing.ts +418 -0
- package/src/native-postgres-backing.ts +353 -41
- package/src/native-server-inputs.ts +169 -0
- package/src/origin.ts +3 -3
- package/src/original-inputs.ts +72 -0
- package/src/pack-facts.ts +1 -1
- package/src/pglite-backing.ts +192 -80
- package/src/pglite-host.mjs +482 -10
- package/src/process-groups.d.mts +6 -1
- package/src/process-groups.mjs +82 -15
- package/src/process-groups.ts +2 -1
- package/src/process-signal.d.mts +2 -2
- package/src/process-signal.mjs +29 -9
- package/src/project-inspect.ts +37 -4
- package/src/proxy-env.ts +128 -0
- package/src/redirect-proxy.ts +3 -125
- package/src/redis-backing.ts +19 -136
- package/src/runtime.ts +342 -107
- package/src/schema.ts +5 -4
- package/src/served-world.ts +3 -3
- package/src/twin-backing.ts +300 -0
- package/src/up-cli.ts +52 -3
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
// ClickHouse's managed ownership questions: HTTP ping, this child's kernel boot answer,
|
|
2
|
+
// and a native Hello/Ping. No SQL is executed by readiness.
|
|
3
|
+
// Packet fields: clickhouse-docs/docs/native-protocol/{client,server,basics}.md;
|
|
4
|
+
// revision54429 uses the reached clickhouse-driver0.2.10 layout retained with the pack.
|
|
5
|
+
import { connect } from 'node:net';
|
|
6
|
+
import { WORLD_BOOT_PATH } from '@volter/world-core/runtime';
|
|
7
|
+
import type { ManagedProtocol } from './twin-backing.ts';
|
|
8
|
+
import type { InfraService } from './infra-definition.ts';
|
|
9
|
+
|
|
10
|
+
async function answer(port: number, path: string): Promise<string | undefined> {
|
|
11
|
+
const controller = new AbortController(), timer = setTimeout(() => controller.abort(), 1000);
|
|
12
|
+
try {
|
|
13
|
+
const response = await fetch(`http://127.0.0.1:${port}${path}`, { signal: controller.signal, redirect: 'error' });
|
|
14
|
+
if (!response.ok || !response.body) return undefined;
|
|
15
|
+
// This private proof contains a UUID boot nonce and a JavaScript-safe PID; ping is four bytes.
|
|
16
|
+
const reader = response.body.getReader(); let text = '', bytes = 0;
|
|
17
|
+
try { while (true) { const part = await reader.read(); if (part.done) break; bytes += part.value.length; if (bytes > 256) return undefined; text += new TextDecoder().decode(part.value); } }
|
|
18
|
+
finally { await reader.cancel().catch(() => undefined); }
|
|
19
|
+
return text;
|
|
20
|
+
} catch { return undefined; } finally { clearTimeout(timer); }
|
|
21
|
+
}
|
|
22
|
+
function number(value: number): number[] { const result: number[] = []; do { const byte = value % 128; value = Math.floor(value / 128); result.push(byte | (value ? 128 : 0)); } while (value); return result; }
|
|
23
|
+
function string(value: string): number[] { const bytes = new TextEncoder().encode(value); return [...number(bytes.length), ...bytes]; }
|
|
24
|
+
function nativeAnswers(service: InfraService): Promise<boolean> {
|
|
25
|
+
const port = service.sides?.native?.hostPort; if (!port) return Promise.resolve(false);
|
|
26
|
+
const users = service.configuration?.users as Record<string, string> | undefined;
|
|
27
|
+
const [user, password] = users && Object.hasOwn(users, 'default') ? ['default', users.default!] : Object.entries(users ?? { default: '' })[0] ?? [];
|
|
28
|
+
if (typeof user !== 'string' || typeof password !== 'string') return Promise.resolve(false);
|
|
29
|
+
const hello = Uint8Array.from([0, ...string('World ownership'), 1, 0, ...number(54429), ...string('default'), ...string(user), ...string(password)]);
|
|
30
|
+
return new Promise(resolve => {
|
|
31
|
+
const socket = connect({ host: '127.0.0.1', port, timeout: 1000 }); let bytes = new Uint8Array(), phase = 'hello', settled = false;
|
|
32
|
+
const end = (valid: boolean) => { if (settled) return; settled = true; clearTimeout(timer); socket.destroy(); resolve(valid); };
|
|
33
|
+
// One complete ownership question has one deadline; incoming fragments never extend it.
|
|
34
|
+
const timer = setTimeout(() => end(false), 1000);
|
|
35
|
+
const missing = Symbol('incomplete');
|
|
36
|
+
socket.once('connect', () => socket.write(hello));
|
|
37
|
+
socket.on('data', chunk => {
|
|
38
|
+
// The declaring pack fixes its Hello name, timezone and display name;64KiB is beyond that packet domain.
|
|
39
|
+
if (bytes.length + chunk.length > 65536) { end(false); return; }
|
|
40
|
+
const joined = new Uint8Array(bytes.length + chunk.length); joined.set(bytes); joined.set(chunk, bytes.length); bytes = joined;
|
|
41
|
+
let at = 0;
|
|
42
|
+
const read = (): number => {
|
|
43
|
+
let value = 0n;
|
|
44
|
+
for (let shift = 0; shift < 70; shift += 7) { if (at >= bytes.length) throw missing; const byte = bytes[at++]!; value |= BigInt(byte & 127) << BigInt(shift); if (!(byte & 128)) { if (value > BigInt(Number.MAX_SAFE_INTEGER)) throw new Error('Invalid native integer'); return Number(value); } }
|
|
45
|
+
throw new Error('Invalid native integer');
|
|
46
|
+
};
|
|
47
|
+
const text = (): string => { const length = read(); if (length > bytes.length - at) throw missing; const result = new TextDecoder().decode(bytes.subarray(at, at + length)); at += length; return result; };
|
|
48
|
+
try {
|
|
49
|
+
if (phase === 'hello') {
|
|
50
|
+
if (read() !== 0 || text() !== 'ClickHouse') { end(false); return; }
|
|
51
|
+
read(); read(); const revision = read(); if (revision !== 54429) { end(false); return; }
|
|
52
|
+
text(); text(); read(); bytes = bytes.slice(at); phase = 'pong'; socket.write(Uint8Array.of(4));
|
|
53
|
+
if (!bytes.length) return; at = 0;
|
|
54
|
+
}
|
|
55
|
+
end(read() === 4);
|
|
56
|
+
} catch (error) { if (error !== missing) end(false); }
|
|
57
|
+
});
|
|
58
|
+
socket.once('error', () => end(false)); socket.once('timeout', () => end(false)); socket.once('close', () => end(false));
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
export const CLICKHOUSE_PROTOCOL: ManagedProtocol = {
|
|
62
|
+
name: 'ClickHouse', bootProof: true,
|
|
63
|
+
async answers(port) { return await answer(port, '/ping') === 'Ok.\n'; },
|
|
64
|
+
async serves(port, pid, service, boot) {
|
|
65
|
+
if (!service || !boot || await answer(port, '/ping') !== 'Ok.\n') return false;
|
|
66
|
+
const body = await answer(port, WORLD_BOOT_PATH); if (!body) return false;
|
|
67
|
+
try { const held = JSON.parse(body); if (held.boot !== boot || held.pid !== pid) return false; } catch { return false; }
|
|
68
|
+
return nativeAnswers(service);
|
|
69
|
+
},
|
|
70
|
+
};
|
|
@@ -4,7 +4,7 @@ export const COMMAND_GRACE_MS: number;
|
|
|
4
4
|
export const COMMAND_CONFIRM_MS: number;
|
|
5
5
|
export const commandProcessGroup: boolean;
|
|
6
6
|
export class CommandRetirementError extends Error {}
|
|
7
|
-
export function commandLifetime(child: ChildProcess, abort?: AbortSignal, preserveOnSuccess?: boolean): {
|
|
7
|
+
export function commandLifetime(child: ChildProcess, abort?: AbortSignal, preserveOnSuccess?: boolean, graceMs?: number): {
|
|
8
8
|
identity: Promise<SpawnIdentity | undefined>;
|
|
9
9
|
cancel(signal: NodeJS.Signals): void;
|
|
10
10
|
readonly forwarded: NodeJS.Signals | undefined;
|
package/src/command-lifetime.mjs
CHANGED
|
@@ -9,7 +9,10 @@ export class CommandRetirementError extends Error {}
|
|
|
9
9
|
|
|
10
10
|
/** The child owns a private POSIX group. A bounded retirement requires both closed output and
|
|
11
11
|
* a vanished group. Unknown retirement keeps the caller's ownership record and reservation. */
|
|
12
|
-
|
|
12
|
+
/** `graceMs` is how long a cancelled child is awaited before SIGKILL. A command gets the default; a STORE (a
|
|
13
|
+
* database, a twin that holds state) is given its lifecycle's budget, since what it does between the signal and
|
|
14
|
+
* its exit is write its state down, and a SIGKILL in the middle of that leaves a store nothing may take. */
|
|
15
|
+
export function commandLifetime(child, abort, preserveOnSuccess = false, graceMs = COMMAND_GRACE_MS) {
|
|
13
16
|
const identity = child.pid && commandProcessGroup ? childIdentity(child) : Promise.resolve(undefined);
|
|
14
17
|
let signalFailure;
|
|
15
18
|
let pendingSignals = 0;
|
|
@@ -62,7 +65,7 @@ export function commandLifetime(child, abort, preserveOnSuccess = false) {
|
|
|
62
65
|
}
|
|
63
66
|
};
|
|
64
67
|
const watch = () => {
|
|
65
|
-
if (!deadline) deadline = Date.now() +
|
|
68
|
+
if (!deadline) deadline = Date.now() + graceMs;
|
|
66
69
|
timer ??= setInterval(check, 20);
|
|
67
70
|
check();
|
|
68
71
|
};
|
package/src/configs.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { copyFileSync, constants, existsSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
|
|
2
2
|
import { basename, dirname, join, resolve } from 'node:path';
|
|
3
|
-
import { getActiveWorldStore, stateDirName } from '@volter/world-core/runtime';
|
|
3
|
+
import { getActiveWorldStore, stateDirName } from '@volter/world-core/runtime-state';
|
|
4
4
|
import { assertWorldConfig, LEGACY_WORLD_SELECTION, worldConfigDocument, type WorldChangesetRules, type WorldConfig } from './schema.ts';
|
|
5
5
|
|
|
6
6
|
export function resolveConfigPath(config: string, root = process.cwd()): string {
|
package/src/consumers.ts
CHANGED
|
@@ -2,7 +2,7 @@ import { randomUUID } from 'node:crypto';
|
|
|
2
2
|
import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, writeFileSync } from 'node:fs';
|
|
3
3
|
import { hostname } from 'node:os';
|
|
4
4
|
import { join } from 'node:path';
|
|
5
|
-
import { stateDirName, withFileLock } from '@volter/world-core/runtime';
|
|
5
|
+
import { stateDirName, withFileLock } from '@volter/world-core/runtime-state';
|
|
6
6
|
|
|
7
7
|
export type WorldConsumer = {
|
|
8
8
|
id: string;
|
package/src/covers.ts
CHANGED
|
@@ -627,6 +627,8 @@ export function worldTwinInventory(world: string, root: string): WorldTwinInvent
|
|
|
627
627
|
export type CoverageOptions = {
|
|
628
628
|
root?: string;
|
|
629
629
|
allowUnknown?: boolean;
|
|
630
|
+
/** Application environment names already read from its declared stack. */
|
|
631
|
+
declaredEnvironmentNames?: readonly string[];
|
|
630
632
|
/**
|
|
631
633
|
* vendor → reason: vendors the operator ACKNOWLEDGES need no twin in this world — e.g. an SDK
|
|
632
634
|
* used verifier-only (svix's Webhook verifies incoming signatures locally and never calls out).
|
|
@@ -685,11 +687,11 @@ function usesOf(signals: Map<string, string[]>): Map<string, Array<{ usage: Worl
|
|
|
685
687
|
|
|
686
688
|
/** Detect every vendor (and every unmapped external-service-shaped signal) an application repo
|
|
687
689
|
* betrays: mapped npm dependencies across the root + all workspace members, vendor-shaped env
|
|
688
|
-
* names in committed `.env*` files, the SMTP/raw-protocol signal, and literal vendor URLs passed
|
|
690
|
+
* names in committed `.env*` files or its declared stack, the SMTP/raw-protocol signal, and literal vendor URLs passed
|
|
689
691
|
* directly to fetch in production source. URL attribution reads the injector's VENDOR_HOSTS table
|
|
690
692
|
* rather than re-encoding host rules here. Insertion order follows the sorted dependency, env-name,
|
|
691
693
|
* and source lists, so callers that preserve it are deterministic. */
|
|
692
|
-
export function detectRepoVendors(repoPath: string, services: Array<WorldServiceConfig | WorldServiceInstance> = []): RepoVendorSignals {
|
|
694
|
+
export function detectRepoVendors(repoPath: string, services: Array<WorldServiceConfig | WorldServiceInstance> = [], declaredEnvironmentNames: readonly string[] = []): RepoVendorSignals {
|
|
693
695
|
// Application aliases are names the selected backing actually injects, never global vendor adoption.
|
|
694
696
|
const backingEnvVendors = new Map<string, string>();
|
|
695
697
|
for (const service of services) {
|
|
@@ -703,7 +705,10 @@ export function detectRepoVendors(repoPath: string, services: Array<WorldService
|
|
|
703
705
|
// A Volter package a pack names as its vendor's SDK is one (`@volter/identity`, the Volter identity
|
|
704
706
|
// service's client, is the volteridentity twin's), as any vendor's SDK is.
|
|
705
707
|
const dependencies = projectDependencies(repo).filter((dep) => Object.hasOwn(SDK_TWINS, dep) || (!dep.startsWith('@volter/') && !dep.startsWith('pypi:volter')));
|
|
706
|
-
const
|
|
708
|
+
for (const name of declaredEnvironmentNames) {
|
|
709
|
+
if (typeof name !== 'string' || !/^[A-Za-z_][A-Za-z0-9_]*$/.test(name)) throw new Error('Declared vendor environment name must be an identifier');
|
|
710
|
+
}
|
|
711
|
+
const envNames = [...new Set([...projectEnvNames(repo), ...declaredEnvironmentNames])].sort();
|
|
707
712
|
const npmRegistries = projectNpmRegistries(repo);
|
|
708
713
|
|
|
709
714
|
// vendor → detection sources
|
|
@@ -871,7 +876,7 @@ export function coverWorld(world: string, repoPath: string, options: CoverageOpt
|
|
|
871
876
|
const repo = resolve(repoPath);
|
|
872
877
|
const inventory = worldTwinInventory(world, root);
|
|
873
878
|
|
|
874
|
-
const { detected, unknown: unknownSources, uses, unknownUses } = detectRepoVendors(repo, inventory.services);
|
|
879
|
+
const { detected, unknown: unknownSources, uses, unknownUses } = detectRepoVendors(repo, inventory.services, options.declaredEnvironmentNames);
|
|
875
880
|
|
|
876
881
|
// Does ANY injector vendor key exist for this vendor? Decides whether an UNINTERCEPTABLE
|
|
877
882
|
// finding is acknowledgeable: a key that exists means the twin COULD be intercepted and the
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/** Source-declared Go MySQL connection bindings; architecture, managed infrastructure. */
|
|
2
|
+
export type GoMysqlBinding = { dsnFormat: 'go-mysql'; endpoint: 'explicit-port' | 'default-port'; dsnPrefix: '' | 'mysql://'; dsnProtocol: '' | 'tcp' };
|
|
3
|
+
|
|
4
|
+
/** Only versioned, explicitly typed data selects a driver grammar. */
|
|
5
|
+
export function goMysqlBinding(entry: Record<string, unknown>, version: unknown, name: string, declaredPort: number): Partial<GoMysqlBinding> {
|
|
6
|
+
if (entry.dsnFormat === undefined) {
|
|
7
|
+
if (entry.endpoint !== undefined || entry.dsnPrefix !== undefined || entry.dsnProtocol !== undefined) throw new Error(`volter init: ${name} has DSN fields without a declared format`);
|
|
8
|
+
return {};
|
|
9
|
+
}
|
|
10
|
+
if ((version !== 6 && version !== 7) || entry.dsnFormat !== 'go-mysql' || entry.scheme !== 'mysql' || entry.withPort !== undefined
|
|
11
|
+
|| (entry.endpoint !== 'explicit-port' && entry.endpoint !== 'default-port') || (entry.dsnPrefix !== '' && entry.dsnPrefix !== 'mysql://')
|
|
12
|
+
|| (entry.dsnProtocol !== '' && entry.dsnProtocol !== 'tcp') || !Number.isInteger(entry.containerPort) || Number(entry.containerPort) < 1 || Number(entry.containerPort) > 65535
|
|
13
|
+
|| (entry.endpoint === 'default-port' && entry.containerPort !== declaredPort)
|
|
14
|
+
|| (entry.dsnPrefix === 'mysql://' && entry.endpoint === 'explicit-port')
|
|
15
|
+
|| (entry.query !== undefined && (typeof entry.query !== 'string' || /[\u0000-\u001f\u007f]/u.test(entry.query)))) {
|
|
16
|
+
throw new Error(`volter init: ${name} needs a valid stack-needs version6/7 Go MySQL binding`);
|
|
17
|
+
}
|
|
18
|
+
return { dsnFormat: 'go-mysql', endpoint: entry.endpoint as GoMysqlBinding['endpoint'],
|
|
19
|
+
dsnPrefix: entry.dsnPrefix as GoMysqlBinding['dsnPrefix'], dsnProtocol: entry.dsnProtocol as GoMysqlBinding['dsnProtocol'] };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function renderGoMysqlBinding(entry: GoMysqlBinding & { name: string; containerPort?: number; query?: string }, connection: URL, port: number): string {
|
|
23
|
+
if (connection.protocol !== 'mysql:' || entry.containerPort === undefined) throw new Error(`volter init: ${entry.name} needs a supplied MySQL endpoint and declared vendor port`);
|
|
24
|
+
if (entry.endpoint === 'default-port' && port !== entry.containerPort) throw new Error(`volter init: ${entry.name} needs a per-service host serving its declared default port; the shared-host high-port allocation cannot supply it`);
|
|
25
|
+
const user = decodeURIComponent(connection.username), password = decodeURIComponent(connection.password), database = decodeURIComponent(connection.pathname.slice(1));
|
|
26
|
+
if (!user || !password || !database || /[:\u0000-\u001f\u007f]/u.test(user) || /[\u0000-\u001f\u007f]/u.test(password)) throw new Error(`volter init: ${entry.name} cannot represent the supplied MySQL credentials in its declared driver grammar`);
|
|
27
|
+
if (entry.dsnPrefix === 'mysql://' && (entry.endpoint === 'explicit-port'
|
|
28
|
+
|| !/^[A-Za-z0-9\-._~!$&'()*+,;=:%@]*$/u.test(`${user}:${password}`) || /%(?![A-Fa-f0-9]{2})/u.test(`${user}:${password}`))) {
|
|
29
|
+
throw new Error(`volter init: ${entry.name} cannot preserve its raw credentials/port through the declared mysql:// URL preparse`);
|
|
30
|
+
}
|
|
31
|
+
const address = `${connection.hostname}${entry.endpoint === 'explicit-port' ? `:${port}` : ''}`;
|
|
32
|
+
return `${entry.dsnPrefix}${user}:${password}@${entry.dsnProtocol}(${address})/${encodeURIComponent(database)}${entry.query !== undefined ? `?${entry.query}` : ''}`;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
/** Match a declared DSN against its owned listener, never infer a grammar. */
|
|
37
|
+
export function goMysqlEndpoint(value: string, binding: GoMysqlBinding, declaredPort: number): { host: string; port: number } | undefined {
|
|
38
|
+
if (/[\u0000-\u001f\u007f]/u.test(value) || !value.startsWith(binding.dsnPrefix)) return undefined;
|
|
39
|
+
const body = value.slice(binding.dsnPrefix.length), slash = body.lastIndexOf('/');
|
|
40
|
+
if (slash < 0) return undefined;
|
|
41
|
+
const authority = body.slice(0, slash), at = authority.lastIndexOf('@');
|
|
42
|
+
const transport = authority.slice(at + 1), begin = `${binding.dsnProtocol}(`;
|
|
43
|
+
if (!transport.startsWith(begin) || !transport.endsWith(')')) return undefined;
|
|
44
|
+
const endpoint = /^(?:\[([^\]\s]+)\]|([^:\s/?#()]+))(?::([0-9]{1,5}))?$/u.exec(transport.slice(begin.length, -1));
|
|
45
|
+
if (!endpoint || (binding.endpoint === 'default-port' && endpoint[3] !== undefined)
|
|
46
|
+
|| (binding.endpoint === 'explicit-port' && endpoint[3] === undefined)) return undefined;
|
|
47
|
+
const port = endpoint[3] === undefined ? declaredPort : Number(endpoint[3]);
|
|
48
|
+
return port >= 1 && port <= 65535 ? { host: endpoint[1] ?? endpoint[2]!, port } : undefined;
|
|
49
|
+
}
|
|
@@ -5,5 +5,5 @@ export interface GuestProcessLaunch { path: string; record: Omit<GuestProcessRec
|
|
|
5
5
|
export function guestProcessDirectory(instanceDir: string): string;
|
|
6
6
|
export function beginGuestProcess(directory: string, instance: string, guest: string, kind: GuestProcessRecord['kind']): GuestProcessLaunch;
|
|
7
7
|
export function retainGuestProcess(receipt: GuestProcessLaunch, identity: SpawnIdentity): void;
|
|
8
|
-
export function readGuestProcesses(instanceDir: string, expectedInstance?: string): GuestProcessReceipt[];
|
|
8
|
+
export function readGuestProcesses(instanceDir: string, expectedInstance?: string, onError?: (error: unknown) => void): GuestProcessReceipt[];
|
|
9
9
|
export function releaseGuestProcesses(receipts: Array<{ path: string }>): void;
|
package/src/guest-processes.mjs
CHANGED
|
@@ -16,7 +16,9 @@ export function retainGuestProcess(receipt, identity) {
|
|
|
16
16
|
receipt.record = { ...receipt.record, launching: false, ...identity };
|
|
17
17
|
publishProcessReceipt(receipt.path, JSON.stringify(receipt.record));
|
|
18
18
|
}
|
|
19
|
-
|
|
19
|
+
// Teardown can retain each refusal while joining the other independently valid receipts.
|
|
20
|
+
// Other readers remain strict unless they explicitly collect these errors.
|
|
21
|
+
export function readGuestProcesses(instanceDir, expectedInstance, onError) {
|
|
20
22
|
const directory = guestProcessDirectory(instanceDir);
|
|
21
23
|
if (!existsSync(directory)) return [];
|
|
22
24
|
if (lstatSync(directory).isSymbolicLink()) throw new Error('Guest receipt directory is a symbolic link; ownership retained');
|
|
@@ -26,19 +28,25 @@ export function readGuestProcesses(instanceDir, expectedInstance) {
|
|
|
26
28
|
try { expectedInstance = JSON.parse(readFileSync(join(instanceDir, 'instance.json'), 'utf8')).instance; }
|
|
27
29
|
catch { throw new Error('Guest instance identity unreadable; ownership retained'); }
|
|
28
30
|
}
|
|
29
|
-
return names.
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
const
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
31
|
+
return names.flatMap(name => {
|
|
32
|
+
try {
|
|
33
|
+
const path = join(directory, name);
|
|
34
|
+
if (!/^[0-9a-f-]+\.json$/.test(name) || !lstatSync(path).isFile() || lstatSync(path).isSymbolicLink()) throw new Error('Unresolved guest receipt publication; ownership retained');
|
|
35
|
+
const receipt = readProcessReceipt(path, text => {
|
|
36
|
+
const r = JSON.parse(text);
|
|
37
|
+
if (r.instance !== expectedInstance || typeof r.guest !== 'string' || !['agent', 'command', 'pty'].includes(r.kind)
|
|
38
|
+
|| r.launching || !Number.isInteger(r.pid) || r.pid < 1 || (r.start !== undefined && (typeof r.start !== 'string' || !r.start))) {
|
|
39
|
+
throw new Error('Guest launch or spawn identity unresolved');
|
|
40
|
+
}
|
|
41
|
+
return r;
|
|
42
|
+
});
|
|
43
|
+
if (receipt.error || !receipt.value) throw receipt.error ?? new Error('Guest receipt disappeared during inspection; ownership retained');
|
|
44
|
+
return [{ path, record: receipt.value }];
|
|
45
|
+
} catch (error) {
|
|
46
|
+
if (!onError) throw error;
|
|
47
|
+
onError(error);
|
|
48
|
+
return [];
|
|
49
|
+
}
|
|
42
50
|
});
|
|
43
51
|
}
|
|
44
52
|
// The caller must have confirmed each recorded group absent before releasing these receipts.
|
package/src/host-cli.ts
CHANGED
|
@@ -15,6 +15,9 @@ import { dirname, join } from 'node:path';
|
|
|
15
15
|
import { startColocatedHost, type ColocatedTwinSpec, type HostIsolation } from './host.ts';
|
|
16
16
|
import { publishHostReady } from './host-ready.ts';
|
|
17
17
|
|
|
18
|
+
// When this entry runs: the host's own modules are loaded; each twin's are loaded by its factory (S2 R38).
|
|
19
|
+
const began = Date.now();
|
|
20
|
+
const startedAt: Record<string, number> = {};
|
|
18
21
|
const argv = process.argv.slice(2);
|
|
19
22
|
const opt = (name: string, fb = ''): string => { const i = argv.indexOf(name); return i >= 0 && argv[i + 1] ? argv[i + 1]! : fb; };
|
|
20
23
|
|
|
@@ -82,6 +85,7 @@ function recordGaveUp(twin: string, exits: number, detail: string): void {
|
|
|
82
85
|
const host = await startColocatedHost(specs, {
|
|
83
86
|
isolation,
|
|
84
87
|
onEvent: (e) => {
|
|
88
|
+
if (e.kind === 'started') startedAt[e.twin] = Date.now();
|
|
85
89
|
process.stdout.write(`event twin=${e.twin} kind=${e.kind}${e.detail ? ` detail=${e.detail}` : ''}\n`);
|
|
86
90
|
if (e.kind === 'gaveup') recordGaveUp(e.twin, e.exits ?? 0, e.detail ?? '');
|
|
87
91
|
},
|
|
@@ -91,9 +95,11 @@ process.on('SIGTERM', shutdown);
|
|
|
91
95
|
process.on('SIGINT', shutdown);
|
|
92
96
|
const readyFile = opt('--ready-file');
|
|
93
97
|
if (readyFile) {
|
|
94
|
-
try { publishHostReady(readyFile, process.env.VOLTER_WORLD_BOOT_ID ?? '', host.ports); }
|
|
98
|
+
try { publishHostReady(readyFile, process.env.VOLTER_WORLD_BOOT_ID ?? '', host.ports, { began, started: startedAt }, host.streams); }
|
|
95
99
|
catch (error) { await host.stop(); throw error; }
|
|
96
100
|
}
|
|
101
|
+
// the twins' cold first requests, taken in the host's idle time after its receipt rather than by the application's first calls
|
|
102
|
+
void host.warm();
|
|
97
103
|
for (const [id, port] of Object.entries(host.ports)) process.stdout.write(`ready id=${id} port=${port}\n`);
|
|
98
104
|
process.stdout.write(`host ready (${specs.length} twins, isolation=${isolation})\n`);
|
|
99
105
|
await keepProcessAlive();
|
package/src/host-ready.ts
CHANGED
|
@@ -1,12 +1,22 @@
|
|
|
1
|
-
import { mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
|
|
1
|
+
import { type FSWatcher, mkdirSync, readFileSync, renameSync, watch, writeFileSync } from 'node:fs';
|
|
2
2
|
import { dirname } from 'node:path';
|
|
3
3
|
|
|
4
|
+
/** When the host's own start reached each step, in epoch milliseconds: its entry running (its static modules loaded),
|
|
5
|
+
* and each twin's factory returning. */
|
|
6
|
+
export type HostStartTimes = { began: number; started: Record<string, number> };
|
|
7
|
+
|
|
8
|
+
/** What a receipt says of the host's start, for the runtime's progress line, and which of its twins' main ports are byte
|
|
9
|
+
* streams (host.ts servesByteStream), for their admission; absent from a host that predates it. */
|
|
10
|
+
export type HostReadyTimes = { at?: number; streams?: string[] } & Partial<HostStartTimes>;
|
|
11
|
+
|
|
4
12
|
/** Only the host, after all starts have returned and ports were checked, publishes this receipt. */
|
|
5
|
-
export function publishHostReady(path: string, boot: string, ports: Record<string, number
|
|
13
|
+
export function publishHostReady(path: string, boot: string, ports: Record<string, number>, times?: HostStartTimes, streams?: readonly string[]): void {
|
|
6
14
|
if (!boot) throw new Error('A co-located host readiness receipt requires its World boot identity.');
|
|
7
15
|
mkdirSync(dirname(path), { recursive: true });
|
|
8
16
|
const temporary = `${path}.${process.pid}.tmp`;
|
|
9
|
-
|
|
17
|
+
// `at`, when it was published, lets the runtime say how long its read came after, and `times` where the host's own
|
|
18
|
+
// start went before it (S2 R28, R38).
|
|
19
|
+
writeFileSync(temporary, `${JSON.stringify({ version: 1, boot, ports, at: Date.now(), ...times, ...(streams?.length ? { streams } : {}) })}\n`, { mode: 0o600 });
|
|
10
20
|
renameSync(temporary, path);
|
|
11
21
|
}
|
|
12
22
|
|
|
@@ -16,35 +26,56 @@ export function publishHostReady(path: string, boot: string, ports: Record<strin
|
|
|
16
26
|
* one: each change in `progress` (the host log's size, which grows with every
|
|
17
27
|
* twin it starts) begins the bound again, so a host still starting its twins
|
|
18
28
|
* on a busy machine is waited for, and one silent for `timeoutMs` is not.
|
|
29
|
+
* Resolves with what the receipt says of the host's start: when it was published, its steps before, and which declared
|
|
30
|
+
* twins' main ports are byte streams.
|
|
19
31
|
*/
|
|
20
|
-
export async function waitForHostReady(path: string, boot: string, ports: ReadonlyMap<string, number>, timeoutMs: number, abort: AbortSignal, progress: () => number = () => 0): Promise<
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
32
|
+
export async function waitForHostReady(path: string, boot: string, ports: ReadonlyMap<string, number>, timeoutMs: number, abort: AbortSignal, progress: () => number = () => 0): Promise<HostReadyTimes> {
|
|
33
|
+
// A change in the receipt's directory (the host's rename is one) wakes the wait at once, where the 25 ms timer
|
|
34
|
+
// alone left a published receipt unread for up to a tick (S2 R28). The timer stays: a filesystem that gives no
|
|
35
|
+
// change events is read as before. Any other write in the directory wakes it too, and costs one read.
|
|
36
|
+
let wake: (() => void) | undefined;
|
|
37
|
+
let watcher: FSWatcher | undefined;
|
|
38
|
+
try {
|
|
39
|
+
watcher = watch(dirname(path), { persistent: false }, () => wake?.());
|
|
40
|
+
watcher.on('error', () => { watcher?.close(); watcher = undefined; });
|
|
41
|
+
} catch { /* no change events here: the timer alone */ }
|
|
42
|
+
try {
|
|
43
|
+
let started = Date.now();
|
|
44
|
+
let seen = progress();
|
|
45
|
+
for (;;) {
|
|
46
|
+
abort.throwIfAborted();
|
|
47
|
+
let bytes: Buffer | undefined;
|
|
48
|
+
try { bytes = readFileSync(path); }
|
|
49
|
+
catch (error) { if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; }
|
|
50
|
+
if (bytes) {
|
|
51
|
+
if (bytes.byteLength > 256 * 1024) throw new Error('The co-located host readiness receipt exceeds its bounds.');
|
|
52
|
+
const value = JSON.parse(bytes.toString('utf8')) as { version?: unknown; boot?: unknown; ports?: unknown; at?: unknown; began?: unknown; started?: unknown; streams?: unknown } | null;
|
|
53
|
+
if (!value || value.version !== 1 || value.boot !== boot || !value.ports || typeof value.ports !== 'object' || Array.isArray(value.ports)) {
|
|
54
|
+
throw new Error('The co-located host readiness receipt does not belong to this boot.');
|
|
55
|
+
}
|
|
56
|
+
const answered = value.ports as Record<string, unknown>;
|
|
57
|
+
if (Object.keys(answered).length !== ports.size || [...ports].some(([id, port]) => !Object.hasOwn(answered, id) || answered[id] !== port)) {
|
|
58
|
+
throw new Error('The co-located host readiness receipt does not cover its declared ports.');
|
|
59
|
+
}
|
|
60
|
+
const when = (time: unknown): number | undefined => typeof time === 'number' && Number.isFinite(time) ? time : undefined;
|
|
61
|
+
// only the declared twins' times: an id the runtime did not declare never reaches its progress line
|
|
62
|
+
const twinStarts = value.started && typeof value.started === 'object' && !Array.isArray(value.started)
|
|
63
|
+
? Object.fromEntries(Object.entries(value.started as Record<string, unknown>).flatMap(([id, time]) => !ports.has(id) || when(time) === undefined ? [] : [[id, time as number]]))
|
|
64
|
+
: undefined;
|
|
65
|
+
const streams = Array.isArray(value.streams) ? value.streams.filter((id): id is string => typeof id === 'string' && ports.has(id)) : [];
|
|
66
|
+
return { at: when(value.at), began: when(value.began), ...(twinStarts ? { started: twinStarts } : {}), ...(streams.length ? { streams } : {}) };
|
|
37
67
|
}
|
|
38
|
-
|
|
68
|
+
const now = progress();
|
|
69
|
+
if (now !== seen) { seen = now; started = Date.now(); }
|
|
70
|
+
if (Date.now() - started >= timeoutMs) throw new Error(`The co-located host made no progress for ${timeoutMs} ms before completing every declared start.`);
|
|
71
|
+
await new Promise<void>((resolve, reject) => {
|
|
72
|
+
const settle = (): void => { clearTimeout(timer); wake = undefined; abort.removeEventListener('abort', cancel); };
|
|
73
|
+
const cancel = (): void => { settle(); reject(abort.reason); };
|
|
74
|
+
const timer = setTimeout(() => { settle(); resolve(); }, Math.min(25, Math.max(1, timeoutMs - (Date.now() - started))));
|
|
75
|
+
wake = () => { settle(); resolve(); };
|
|
76
|
+
abort.addEventListener('abort', cancel, { once: true });
|
|
77
|
+
if (abort.aborted) cancel();
|
|
78
|
+
});
|
|
39
79
|
}
|
|
40
|
-
|
|
41
|
-
if (now !== seen) { seen = now; started = Date.now(); }
|
|
42
|
-
if (Date.now() - started >= timeoutMs) throw new Error(`The co-located host made no progress for ${timeoutMs} ms before completing every declared start.`);
|
|
43
|
-
await new Promise<void>((resolve, reject) => {
|
|
44
|
-
const cancel = (): void => { clearTimeout(timer); abort.removeEventListener('abort', cancel); reject(abort.reason); };
|
|
45
|
-
const timer = setTimeout(() => { abort.removeEventListener('abort', cancel); resolve(); }, Math.min(25, Math.max(1, timeoutMs - (Date.now() - started))));
|
|
46
|
-
abort.addEventListener('abort', cancel, { once: true });
|
|
47
|
-
if (abort.aborted) cancel();
|
|
48
|
-
});
|
|
49
|
-
}
|
|
80
|
+
} finally { watcher?.close(); }
|
|
50
81
|
}
|
package/src/host-worker.ts
CHANGED
|
@@ -4,7 +4,7 @@ import { getPack, registerPack, type TwinPack } from '@volter/world-core/runtime
|
|
|
4
4
|
// host does NOT restart it: it gives up on the worker (fires a `gaveup` event → doctor
|
|
5
5
|
// red) without disturbing sibling twins. Posts {type:'ready'} once listening.
|
|
6
6
|
import { parentPort, workerData } from 'node:worker_threads';
|
|
7
|
-
import type
|
|
7
|
+
import { servesByteStream, servesDiscovery, warmInProcess, type ColocatedTwinSpec } from './host.ts';
|
|
8
8
|
import { importModule } from './import-module.ts';
|
|
9
9
|
|
|
10
10
|
const spec = workerData as ColocatedTwinSpec;
|
|
@@ -23,6 +23,10 @@ if (typeof factory !== 'function') {
|
|
|
23
23
|
|
|
24
24
|
// Awaited: an async factory (fly) must post `ready` only once its listener is bound.
|
|
25
25
|
const server = await factory({ ...(spec.sideOptions ?? {}), port: spec.port, root: spec.root, readOnly: spec.readOnly, ...(spec.database !== undefined ? { database: spec.database } : {}) });
|
|
26
|
-
parentPort?.postMessage({ type: 'ready', port: server.port });
|
|
26
|
+
parentPort?.postMessage({ type: 'ready', port: server.port, byteStream: servesByteStream(mod, descriptor), discovery: servesDiscovery(mod, descriptor) });
|
|
27
|
+
// the host's warm-up, taken here where this twin's server and its handler are (host.ts warmInProcess)
|
|
28
|
+
parentPort?.on('message', (msg: { type?: string }) => {
|
|
29
|
+
if (msg?.type === 'warm') void warmInProcess(server.port).then(() => parentPort?.postMessage({ type: 'warmed' }));
|
|
30
|
+
});
|
|
27
31
|
|
|
28
32
|
// The Bun.serve listener keeps this worker's event loop alive; nothing else to do.
|
package/src/host.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { errorMessage } from './error-message.ts';
|
|
2
|
-
import { getPack, registerPack, type TwinPack } from '@volter/world-core/runtime';
|
|
2
|
+
import { getPack, registerPack, servedHandlerOf, type TwinPack } from '@volter/world-core/runtime';
|
|
3
3
|
// Co-located twin host — run many twins in ONE process instead of one OS process
|
|
4
4
|
// per twin. The twin core is a pure, transport-agnostic handler (see a pack's
|
|
5
5
|
// `handle<Vendor>TwinRequest`), so nothing requires a process per twin; this host
|
|
@@ -53,6 +53,12 @@ export type ColocatedHost = {
|
|
|
53
53
|
isolation: HostIsolation;
|
|
54
54
|
/** Resolved listening port per twin id (equals the requested port). */
|
|
55
55
|
ports: Record<string, number>;
|
|
56
|
+
/** The twins whose main port is a byte stream (servesByteStream). */
|
|
57
|
+
streams: string[];
|
|
58
|
+
/** The twins whose main port answers the discovery door (GET /twin: the manifest declares `discovery`), warmed by it. */
|
|
59
|
+
discoverable: string[];
|
|
60
|
+
/** Warm every discoverable twin (warmInProcess, in its own thread under worker isolation); never needed for readiness. */
|
|
61
|
+
warm: () => Promise<void>;
|
|
56
62
|
stop: () => Promise<void>;
|
|
57
63
|
};
|
|
58
64
|
|
|
@@ -65,7 +71,58 @@ export type ColocatedHost = {
|
|
|
65
71
|
type TwinServer = { port: number; stop: () => void | Promise<void> };
|
|
66
72
|
type TwinServerFactory = (opts: { port?: number; root?: string; readOnly?: boolean; scenarioPath?: string; database?: string; [side: string]: unknown }) => TwinServer | Promise<TwinServer>;
|
|
67
73
|
|
|
68
|
-
|
|
74
|
+
/** Whether a twin's main port is a byte stream: a raw-tcp pack exporting exactly one `create<Name>TwinStream` is taken
|
|
75
|
+
* to serve that stream there (world-core serveStream: an SMTP relay greets, a Redis server answers RESP). Such a port
|
|
76
|
+
* cannot answer an HTTP request, so the runtime admits it by its TCP answer (runtime.ts, startColocatedServices). Read
|
|
77
|
+
* off the module's own exports, as its serve factory is (twin-standard pack-facts.ts serveExport). A pack whose main
|
|
78
|
+
* port serves HTTP or gRPC must not declare raw-tcp with a stream export (create-pack refuses it). */
|
|
79
|
+
export function servesByteStream(mod: Record<string, unknown>, descriptor: TwinPack): boolean {
|
|
80
|
+
return descriptor.transport === 'raw-tcp'
|
|
81
|
+
&& Object.keys(mod).filter((key) => /^create\w*TwinStream$/.test(key) && typeof mod[key] === 'function').length === 1;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Whether a twin's main port answers the discovery door: its manifest declares `discovery`, and its port is HTTP. */
|
|
85
|
+
export function servesDiscovery(mod: Record<string, unknown>, descriptor: TwinPack): boolean {
|
|
86
|
+
const manifest = mod.manifest as { discovery?: unknown } | undefined;
|
|
87
|
+
return manifest?.discovery !== undefined && !servesByteStream(mod, descriptor);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** The most twins warmed at once (S2 R41). */
|
|
91
|
+
const WARM_CONCURRENCY = 2;
|
|
92
|
+
/** A warm-up's bound: one that has not finished by then is left, its twin as it is. */
|
|
93
|
+
const WARM_TIMEOUT_MS = 2000;
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Each twin's first request pays its pipeline's cold start (decoding, routing, the pack's front, first-run code), which
|
|
97
|
+
* otherwise falls on the application's first call to it. Its size for an HTTP twin is unmeasured (a byte-stream twin's
|
|
98
|
+
* first SMTP greeting took 57 ms against 1.8 ms later, e946aa4c run 3 on the mini, but that twin is skipped here); the
|
|
99
|
+
* R38 timing lap sizes it. The host takes it in its own idle time once its
|
|
100
|
+
* receipt is published: one GET of the twin's discovery door, handed in this process to the handler serving its port
|
|
101
|
+
* (world-core servedHandlerOf), never on the wire and never in its request journal. Bounded, its answer and any failure
|
|
102
|
+
* ignored; a request the application sends first is served as it would have been, and simply is that twin's warm-up.
|
|
103
|
+
* The discovery door reads no stored state, so a twin's first state read stays cold.
|
|
104
|
+
*/
|
|
105
|
+
export async function warmInProcess(port: number): Promise<void> {
|
|
106
|
+
const handler = servedHandlerOf(port);
|
|
107
|
+
if (!handler) return;
|
|
108
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
109
|
+
try {
|
|
110
|
+
await Promise.race([
|
|
111
|
+
(async () => { const response = await handler(new Request(`http://127.0.0.1:${port}/twin`)); await response?.arrayBuffer(); })(),
|
|
112
|
+
new Promise<void>((resolve) => { timer = setTimeout(resolve, WARM_TIMEOUT_MS); timer.unref?.(); }),
|
|
113
|
+
]);
|
|
114
|
+
} catch { /* a warm-up that fails leaves its twin as it was */ } finally { if (timer !== undefined) clearTimeout(timer); }
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Each of `ids` warmed by `warm`, at most WARM_CONCURRENCY at once. */
|
|
118
|
+
async function warmEach(ids: readonly string[], warm: (id: string) => Promise<void>): Promise<void> {
|
|
119
|
+
const queue = [...ids];
|
|
120
|
+
await Promise.all(Array.from({ length: Math.min(WARM_CONCURRENCY, queue.length) }, async () => {
|
|
121
|
+
for (let id = queue.shift(); id !== undefined; id = queue.shift()) await warm(id);
|
|
122
|
+
}));
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
async function loadFactory(spec: ColocatedTwinSpec): Promise<{ factory: TwinServerFactory; byteStream: boolean; discovery: boolean }> {
|
|
69
126
|
const mod = await importModule(spec.module);
|
|
70
127
|
const descriptor = mod.pack as TwinPack | undefined ?? getPack(spec.id);
|
|
71
128
|
if (!descriptor || descriptor.vendor !== spec.id) throw new Error(`Twin ${spec.id}: the loaded module declares no matching pack descriptor`);
|
|
@@ -74,7 +131,7 @@ async function loadFactory(spec: ColocatedTwinSpec): Promise<TwinServerFactory>
|
|
|
74
131
|
if (typeof factory !== 'function') {
|
|
75
132
|
throw new Error(`Twin "${spec.id}": ${spec.module} has no factory export "${spec.export}"`);
|
|
76
133
|
}
|
|
77
|
-
return factory as TwinServerFactory;
|
|
134
|
+
return { factory: factory as TwinServerFactory, byteStream: servesByteStream(mod, descriptor), discovery: servesDiscovery(mod, descriptor) };
|
|
78
135
|
}
|
|
79
136
|
|
|
80
137
|
const workerEntry = (): URL => pathToFileURL(siblingScript(import.meta.url, 'host-worker'));
|
|
@@ -100,6 +157,8 @@ export async function startColocatedHost(
|
|
|
100
157
|
const onEvent = options.onEvent ?? (() => {});
|
|
101
158
|
const ports: Record<string, number> = {};
|
|
102
159
|
for (const spec of specs) ports[spec.id] = spec.port;
|
|
160
|
+
const streams: string[] = [];
|
|
161
|
+
const discoverable: string[] = [];
|
|
103
162
|
|
|
104
163
|
if (isolation === 'shared') {
|
|
105
164
|
// Keep one twin's stray async rejection / uncaught throw from killing every other twin in
|
|
@@ -128,7 +187,9 @@ export async function startColocatedHost(
|
|
|
128
187
|
};
|
|
129
188
|
try {
|
|
130
189
|
await parallelStartup(specs, async (spec) => {
|
|
131
|
-
const factory = await loadFactory(spec);
|
|
190
|
+
const { factory, byteStream, discovery } = await loadFactory(spec);
|
|
191
|
+
if (byteStream) streams.push(spec.id);
|
|
192
|
+
if (discovery) discoverable.push(spec.id);
|
|
132
193
|
const server = await factory({ ...(spec.sideOptions ?? {}), port: spec.port, root: spec.root, readOnly: spec.readOnly, ...(spec.scenarioPath !== undefined ? { scenarioPath: spec.scenarioPath } : {}), ...(spec.database !== undefined ? { database: spec.database } : {}) });
|
|
133
194
|
// Record the handle before validating it, so rollback also owns a bad factory result.
|
|
134
195
|
servers.push(server);
|
|
@@ -148,6 +209,9 @@ export async function startColocatedHost(
|
|
|
148
209
|
return {
|
|
149
210
|
isolation,
|
|
150
211
|
ports,
|
|
212
|
+
streams,
|
|
213
|
+
discoverable,
|
|
214
|
+
warm: () => warmEach(discoverable, (id) => warmInProcess(ports[id]!)),
|
|
151
215
|
stop: async () => {
|
|
152
216
|
// Every stop runs, even when a sibling hangs or throws. Host shutdown
|
|
153
217
|
// still resolves within its budget so the owning runtime can retire it.
|
|
@@ -169,13 +233,18 @@ export async function startColocatedHost(
|
|
|
169
233
|
// below would emit a 2nd `spawn` for the same twin, which the isolation test asserts against.
|
|
170
234
|
onEvent({ twin: spec.id, kind: 'spawn', detail: 'worker started' });
|
|
171
235
|
let ready = false;
|
|
172
|
-
worker.on('message', (msg: { type?: string; port?: number }) => {
|
|
236
|
+
worker.on('message', (msg: { type?: string; port?: number; byteStream?: boolean; discovery?: boolean }) => {
|
|
173
237
|
if (msg?.type === 'ready') {
|
|
174
238
|
if (typeof msg.port !== 'number' || !Number.isInteger(msg.port) || msg.port < 1 || msg.port > 65535 || (spec.port !== 0 && msg.port !== spec.port)) {
|
|
175
239
|
rejectReady(new Error(`Twin "${spec.id}" bound port ${msg.port}, requested ${spec.port}.`)); return;
|
|
176
240
|
}
|
|
177
241
|
ports[spec.id] = msg.port;
|
|
178
|
-
|
|
242
|
+
if (msg.byteStream === true) streams.push(spec.id);
|
|
243
|
+
if (msg.discovery === true) discoverable.push(spec.id);
|
|
244
|
+
ready = true;
|
|
245
|
+
// the same progress, and the same start time for the receipt (host-cli.ts), as a shared twin's start
|
|
246
|
+
onEvent({ twin: spec.id, kind: 'started', detail: `port ${msg.port} at ${Math.round(performance.now())} ms` });
|
|
247
|
+
resolveReady();
|
|
179
248
|
}
|
|
180
249
|
});
|
|
181
250
|
worker.on('error', (err) => {
|
|
@@ -205,6 +274,19 @@ export async function startColocatedHost(
|
|
|
205
274
|
return {
|
|
206
275
|
isolation,
|
|
207
276
|
ports,
|
|
277
|
+
streams,
|
|
278
|
+
discoverable,
|
|
279
|
+
// a worker twin is warmed in its own thread, where its server's handler is: asked, and answered once done
|
|
280
|
+
warm: () => warmEach(discoverable, (id) => new Promise<void>((resolve) => {
|
|
281
|
+
const worker = workers.get(id);
|
|
282
|
+
if (!worker) { resolve(); return; }
|
|
283
|
+
const done = (): void => { clearTimeout(timer); worker.off('message', warmed); worker.off('exit', done); resolve(); };
|
|
284
|
+
const warmed = (msg: { type?: string }): void => { if (msg?.type === 'warmed') done(); };
|
|
285
|
+
const timer = setTimeout(done, WARM_TIMEOUT_MS);
|
|
286
|
+
worker.on('message', warmed);
|
|
287
|
+
worker.once('exit', done);
|
|
288
|
+
try { worker.postMessage({ type: 'warm' }); } catch { done(); }
|
|
289
|
+
})),
|
|
208
290
|
stop: async () => {
|
|
209
291
|
stopping.value = true;
|
|
210
292
|
await Promise.all([...workers.values()].map((w) => w.terminate()));
|