cabloy 5.1.216 → 5.1.218
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/.cabloy-version +1 -1
- package/.github/workflows/create-cabloy.yml +137 -0
- package/.github/workflows/playwright-e2e.yml +163 -13
- package/.github/workflows/vona-test-sqlite3.yml +77 -7
- package/CHANGELOG.md +22 -0
- package/CONTRIBUTING.md +4 -2
- package/package.json +1 -1
- package/repo-docs/frontend/scripts.md +2 -0
- package/repo-docs/reference/repo-scripts.md +27 -1
- package/repo-e2e/scripts/e2e.test.ts +1 -1
- package/repo-e2e/scripts/managedTest.test.ts +115 -3
- package/repo-e2e/scripts/managedTest.ts +69 -20
- package/repo-e2e/scripts/managedTestImports.test.ts +55 -5
- package/repo-e2e/scripts/runE2e.test.ts +109 -0
- package/repo-e2e/scripts/runE2e.ts +58 -39
- package/repo-e2e/scripts/runE2eArgs.test.ts +16 -1
- package/repo-e2e/scripts/runE2eArgs.ts +5 -0
- package/repo-e2e/specs/a-commerce.spec.ts +10 -2
- package/repo-e2e/specs/home-user-account.spec.ts +4 -0
- package/repo-e2e/specs/markdown.spec.ts +14 -3
- package/scripts/generateOpenapiIsolated.test.ts +5 -1
- package/scripts/runIsolatedTest.test.ts +403 -36
- package/scripts/runIsolatedTest.ts +139 -12
- package/scripts/testBrowserGuardianWindows.test.ts +327 -0
- package/scripts/testClaimCleanup.test.ts +25 -9
- package/scripts/testClaimCleanup.ts +14 -45
- package/scripts/testClaimProcesses.test.ts +81 -2
- package/scripts/testClaimProcesses.ts +46 -10
- package/scripts/testProcessGuardianWindows.test.ts +602 -0
- package/scripts/testProcessGuardianWindows.ts +155 -0
- package/vona/packages-cli/cli/package.json +1 -1
- package/vona/packages-cli/cli-set-api/package.json +1 -1
- package/vona/packages-cli/cli-set-api/src/lib/bean/cli.bin.build.ts +4 -1
- package/vona/packages-cli/cli-set-api/test/binBuildEntries.test.ts +159 -0
- package/vona/packages-vona/vona/package.json +1 -1
- package/vona/packages-vona/vona-core/package.json +1 -1
- package/vona/packages-vona/vona-core/src/lib/core/testClaim.test.ts +298 -17
- package/vona/packages-vona/vona-core/src/lib/core/testClaim.ts +205 -97
- package/vona/packages-vona/vona-core/src/lib/core/testClaimFilesystem.test.ts +247 -0
- package/vona/packages-vona/vona-core/src/lib/core/testClaimFilesystem.ts +224 -0
- package/vona/packages-vona/vona-core/src/lib/core/testClaimFilesystemWindows.cs +229 -0
- package/vona/packages-vona/vona-core/src/lib/core/testClaimFilesystemWindows.ps1 +20 -0
- package/vona/packages-vona/vona-core/src/lib/core/testClaimFilesystemWindows.ts +200 -0
- package/vona/packages-vona/vona-core/src/lib/core/testProcessWindowsNative.cs +336 -0
- package/vona/packages-vona/vona-core/src/lib/core/testProcessWindowsNative.ps1 +20 -0
- package/vona/packages-vona/vona-core/src/lib/core/testProcessWindowsNative.ts +531 -0
- package/vona/packages-vona/vona-core/src/lib/framework/bootstrap.test.ts +61 -0
- package/vona/packages-vona/vona-core/src/lib/framework/index.ts +1 -0
- package/vona/packages-vona/vona-core/src/lib/framework/processWorker.test.ts +59 -0
- package/vona/packages-vona/vona-core/src/lib/framework/processWorker.ts +8 -5
- package/vona/packages-vona/vona-core/src/lib/framework/useApp.test.ts +84 -0
- package/vona/packages-vona/vona-core/src/lib/framework/useApp.ts +2 -2
- package/vona/packages-vona/vona-core/src/lib/utils/util.ts +1 -1
- package/vona/packages-vona/vona-mock/package.json +1 -1
- package/vona/pnpm-lock.yaml +42 -181
- package/vona/src/suite-vendor/a-cabloy/modules/a-rbac/package.json +1 -1
- package/vona/src/suite-vendor/a-cabloy/package.json +2 -2
- package/vona/src/suite-vendor/a-vona/modules/a-core/package.json +1 -1
- package/vona/src/suite-vendor/a-vona/modules/a-executor/package.json +1 -1
- package/vona/src/suite-vendor/a-vona/modules/a-executor/src/bean/bean.executor.ts +2 -1
- package/vona/src/suite-vendor/a-vona/modules/a-logger/package.json +1 -1
- package/vona/src/suite-vendor/a-vona/modules/a-logger/src/bean/middlewareSystem.httpLog.ts +2 -2
- package/vona/src/suite-vendor/a-vona/modules/a-logger/src/lib/redactIsolatedReadyHeader.test.ts +16 -0
- package/vona/src/suite-vendor/a-vona/modules/a-logger/src/lib/redactIsolatedReadyHeader.ts +6 -3
- package/vona/src/suite-vendor/a-vona/modules/a-logger/test/middlewareSystem.httpLog.test.ts +80 -0
- package/vona/src/suite-vendor/a-vona/modules/a-metrics/package.json +1 -1
- package/vona/src/suite-vendor/a-vona/modules/a-metrics/src/bean/middlewareSystem.health.ts +73 -2
- package/vona/src/suite-vendor/a-vona/modules/a-metrics/test/middlewareSystem.health.test.ts +309 -0
- package/vona/src/suite-vendor/a-vona/modules/a-orm/package.json +1 -1
- package/vona/src/suite-vendor/a-vona/modules/a-orm/src/service/transactionAsyncLocalStorage_.ts +3 -3
- package/vona/src/suite-vendor/a-vona/modules/a-permission/package.json +1 -1
- package/vona/src/suite-vendor/a-vona/modules/a-static/package.json +1 -1
- package/vona/src/suite-vendor/a-vona/modules/a-static/src/bean/middlewareSystem.static.ts +2 -2
- package/vona/src/suite-vendor/a-vona/modules/a-static/test/middlewareSystem.static.test.ts +101 -0
- package/vona/src/suite-vendor/a-vona/package.json +1 -1
- package/zova/package.original.json +1 -1
- package/zova/packages-cli/cli/package.json +2 -2
- package/zova/packages-cli/cli-set-front/package.json +4 -4
- package/zova/packages-utils/zova-jsx/package.json +2 -2
- package/zova/packages-utils/zova-vite/package.json +2 -2
- package/zova/packages-utils/zova-vite/src/configUtils.ts +1 -1
- package/zova/packages-utils/zova-vite/test/configUtils.test.ts +55 -0
- package/zova/packages-zova/zova/package.json +3 -3
- package/zova/packages-zova/zova-core/package.json +2 -2
- package/zova/pnpm-lock.yaml +324 -63
- package/zova/src/suite/a-commerce/modules/commerce-trade/src/model/cart.ts +8 -4
- package/zova/src/suite/a-commerce/modules/commerce-trade/test/lib/cartModel.test.ts +71 -0
- package/zova/src/suite-vendor/a-zova/modules/a-zova/package.json +2 -2
- package/zova/src/suite-vendor/a-zova/package.json +2 -2
|
@@ -0,0 +1,531 @@
|
|
|
1
|
+
import type { ChildProcessWithoutNullStreams } from 'node:child_process';
|
|
2
|
+
import type { Server, Socket } from 'node:net';
|
|
3
|
+
import type { Writable } from 'node:stream';
|
|
4
|
+
|
|
5
|
+
import { spawn } from 'node:child_process';
|
|
6
|
+
import { randomBytes } from 'node:crypto';
|
|
7
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
8
|
+
import { createServer } from 'node:net';
|
|
9
|
+
import { hostname } from 'node:os';
|
|
10
|
+
import { dirname, join } from 'node:path';
|
|
11
|
+
import { fileURLToPath } from 'node:url';
|
|
12
|
+
|
|
13
|
+
const MAX_FRAME = 1024 * 1024;
|
|
14
|
+
const START_TIMEOUT = 30_000;
|
|
15
|
+
|
|
16
|
+
export interface WindowsProcessSnapshot {
|
|
17
|
+
pid: number;
|
|
18
|
+
startTime: string;
|
|
19
|
+
host: string;
|
|
20
|
+
state: 'running';
|
|
21
|
+
args: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface WindowsNativeResult {
|
|
25
|
+
type: 'drained';
|
|
26
|
+
result: { code: number; signal: null };
|
|
27
|
+
failed: boolean;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface WindowsNativeJobConfig {
|
|
31
|
+
jobId: string;
|
|
32
|
+
command: string;
|
|
33
|
+
args: string[];
|
|
34
|
+
cwd: string;
|
|
35
|
+
env: NodeJS.ProcessEnv;
|
|
36
|
+
/** Negative capability probe for native tests: invalid Job assignment must prevent user code. */
|
|
37
|
+
rejectJobAssignment?: boolean;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface WindowsNativeJob {
|
|
41
|
+
ready: Promise<WindowsProcessSnapshot>;
|
|
42
|
+
started: Promise<void>;
|
|
43
|
+
wait: () => Promise<WindowsNativeResult>;
|
|
44
|
+
authorize: () => void;
|
|
45
|
+
/** Exact live PID/FILETIME membership in this helper's private Job; never arbitrary Job membership. */
|
|
46
|
+
contains: (identity: { pid: number; startTime: string }) => Promise<boolean>;
|
|
47
|
+
stop: () => void;
|
|
48
|
+
/** Closing this pipe is the parent-death protocol; it never signals a historical PID. */
|
|
49
|
+
disconnect: () => void;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** src is included in the published package; build tools need not copy the native assets. */
|
|
53
|
+
export function resolveWindowsProcessAssets(moduleUrl = import.meta.url) {
|
|
54
|
+
let directory = dirname(fileURLToPath(moduleUrl));
|
|
55
|
+
for (;;) {
|
|
56
|
+
const manifest = join(directory, 'package.json');
|
|
57
|
+
if (existsSync(manifest)) {
|
|
58
|
+
const info = JSON.parse(readFileSync(manifest, 'utf8')) as { name?: string };
|
|
59
|
+
if (info.name === 'vona-core') {
|
|
60
|
+
const script = join(directory, 'src/lib/core/testProcessWindowsNative.ps1');
|
|
61
|
+
const source = join(directory, 'src/lib/core/testProcessWindowsNative.cs');
|
|
62
|
+
if (!existsSync(script) || !existsSync(source)) {
|
|
63
|
+
throw new Error('Installed vona-core is missing the Windows native process assets');
|
|
64
|
+
}
|
|
65
|
+
return { script, source };
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
const parent = dirname(directory);
|
|
69
|
+
if (parent === directory) throw new Error('Cannot resolve packaged Windows process assets');
|
|
70
|
+
directory = parent;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function requireWindows() {
|
|
75
|
+
if (process.platform !== 'win32')
|
|
76
|
+
throw new Error('The Windows process backend requires native Windows and PowerShell 7 (pwsh)');
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function spawnHelper(mode: 'job' | 'inspect'): ChildProcessWithoutNullStreams {
|
|
80
|
+
requireWindows();
|
|
81
|
+
const { script } = resolveWindowsProcessAssets();
|
|
82
|
+
return spawn(
|
|
83
|
+
'pwsh.exe',
|
|
84
|
+
['-NoLogo', '-NoProfile', '-NonInteractive', '-File', script, '-Mode', mode],
|
|
85
|
+
{
|
|
86
|
+
shell: false,
|
|
87
|
+
windowsHide: true,
|
|
88
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
89
|
+
},
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function frame(value: unknown) {
|
|
94
|
+
const result = `${JSON.stringify(value)}\n`;
|
|
95
|
+
if (Buffer.byteLength(result, 'utf8') > MAX_FRAME)
|
|
96
|
+
throw new Error('Windows native control frame exceeds limit');
|
|
97
|
+
return result;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function deferred<T>() {
|
|
101
|
+
let resolve!: (value: T) => void;
|
|
102
|
+
let reject!: (reason: Error) => void;
|
|
103
|
+
const promise = new Promise<T>((yes, no) => {
|
|
104
|
+
resolve = yes;
|
|
105
|
+
reject = no;
|
|
106
|
+
});
|
|
107
|
+
// Startup can fail before callers begin awaiting the subsequent stage.
|
|
108
|
+
void promise.catch(() => {});
|
|
109
|
+
return { promise, resolve, reject };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function errorOf(value: unknown) {
|
|
113
|
+
return value instanceof Error ? value : new Error(String(value));
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Strict, bounded line protocol. Target stdout/stderr never enter this parser. */
|
|
117
|
+
function consumeFrames(
|
|
118
|
+
child: ChildProcessWithoutNullStreams,
|
|
119
|
+
receive: (value: Record<string, unknown>) => void,
|
|
120
|
+
fail: (error: Error) => void,
|
|
121
|
+
) {
|
|
122
|
+
let buffer = Buffer.alloc(0);
|
|
123
|
+
child.stdout.on('data', (chunk: Buffer) => {
|
|
124
|
+
try {
|
|
125
|
+
buffer = Buffer.concat([buffer, chunk]);
|
|
126
|
+
for (;;) {
|
|
127
|
+
const newline = buffer.indexOf(10);
|
|
128
|
+
if (newline < 0) break;
|
|
129
|
+
if (newline > MAX_FRAME) throw new Error('Windows native control frame exceeds limit');
|
|
130
|
+
const text = new TextDecoder('utf-8', { fatal: true }).decode(buffer.subarray(0, newline));
|
|
131
|
+
buffer = buffer.subarray(newline + 1);
|
|
132
|
+
const value: unknown = JSON.parse(text);
|
|
133
|
+
if (!value || typeof value !== 'object' || Array.isArray(value))
|
|
134
|
+
throw new Error('Invalid Windows native control frame');
|
|
135
|
+
receive(value as Record<string, unknown>);
|
|
136
|
+
}
|
|
137
|
+
if (buffer.length > MAX_FRAME) throw new Error('Windows native control frame exceeds limit');
|
|
138
|
+
} catch (error) {
|
|
139
|
+
fail(errorOf(error));
|
|
140
|
+
}
|
|
141
|
+
});
|
|
142
|
+
child.stdout.on('end', () => {
|
|
143
|
+
if (buffer.length) fail(new Error('Truncated Windows native control frame'));
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
interface OutputBridge {
|
|
148
|
+
name: string;
|
|
149
|
+
close: () => void;
|
|
150
|
+
ended: Promise<void>;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
async function outputBridge(name: string, destination: Writable): Promise<OutputBridge> {
|
|
154
|
+
const ended = deferred<void>();
|
|
155
|
+
let socket: Socket | undefined;
|
|
156
|
+
const server: Server = createServer(connection => {
|
|
157
|
+
if (socket) {
|
|
158
|
+
connection.destroy();
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
socket = connection;
|
|
162
|
+
server.close();
|
|
163
|
+
connection.on('error', error => ended.reject(error));
|
|
164
|
+
connection.on('end', () => ended.resolve());
|
|
165
|
+
connection.on('close', () => {
|
|
166
|
+
if (!connection.readableEnded)
|
|
167
|
+
ended.reject(new Error('Windows target output pipe closed prematurely'));
|
|
168
|
+
});
|
|
169
|
+
connection.pipe(destination, { end: false });
|
|
170
|
+
});
|
|
171
|
+
server.on('error', error => ended.reject(error));
|
|
172
|
+
await new Promise<void>((resolve, reject) => {
|
|
173
|
+
server.once('error', reject);
|
|
174
|
+
server.listen(`\\\\.\\pipe\\${name}`, () => {
|
|
175
|
+
server.off('error', reject);
|
|
176
|
+
resolve();
|
|
177
|
+
});
|
|
178
|
+
});
|
|
179
|
+
return {
|
|
180
|
+
name,
|
|
181
|
+
ended: ended.promise,
|
|
182
|
+
close: () => {
|
|
183
|
+
socket?.destroy();
|
|
184
|
+
server.close();
|
|
185
|
+
},
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Creates the external native helper, but does not authorize application launch.
|
|
191
|
+
* The caller MUST persist ready's helper identity before authorize().
|
|
192
|
+
*/
|
|
193
|
+
export async function createWindowsNativeJob(
|
|
194
|
+
config: WindowsNativeJobConfig,
|
|
195
|
+
output: {
|
|
196
|
+
stdout: Writable;
|
|
197
|
+
stderr: Writable;
|
|
198
|
+
/** Native crash tests use the live ChildProcess handle, never a historical PID. */
|
|
199
|
+
onHelperSpawn?: (helper: ChildProcessWithoutNullStreams) => void;
|
|
200
|
+
} = { stdout: process.stdout, stderr: process.stderr },
|
|
201
|
+
): Promise<WindowsNativeJob> {
|
|
202
|
+
requireWindows();
|
|
203
|
+
const suffix = randomBytes(32).toString('hex');
|
|
204
|
+
const stdout = await outputBridge(`cabloy-test-${suffix}-out`, output.stdout);
|
|
205
|
+
let stderr: OutputBridge;
|
|
206
|
+
try {
|
|
207
|
+
stderr = await outputBridge(`cabloy-test-${suffix}-err`, output.stderr);
|
|
208
|
+
} catch (error) {
|
|
209
|
+
stdout.close();
|
|
210
|
+
throw error;
|
|
211
|
+
}
|
|
212
|
+
let child: ChildProcessWithoutNullStreams;
|
|
213
|
+
try {
|
|
214
|
+
child = spawnHelper('job');
|
|
215
|
+
} catch (error) {
|
|
216
|
+
stdout.close();
|
|
217
|
+
stderr.close();
|
|
218
|
+
throw error;
|
|
219
|
+
}
|
|
220
|
+
const ready = deferred<WindowsProcessSnapshot>();
|
|
221
|
+
const started = deferred<void>();
|
|
222
|
+
const closed = deferred<WindowsNativeResult>();
|
|
223
|
+
let phase: 'initial' | 'ready' | 'authorized' | 'started' | 'drained' = 'initial';
|
|
224
|
+
let proof: WindowsNativeResult | undefined;
|
|
225
|
+
let failure: Error | undefined;
|
|
226
|
+
let diagnostics = '';
|
|
227
|
+
let stopping = false;
|
|
228
|
+
let didClose = false;
|
|
229
|
+
let timeout: NodeJS.Timeout | undefined;
|
|
230
|
+
const queries = new Map<
|
|
231
|
+
string,
|
|
232
|
+
{ result: ReturnType<typeof deferred<boolean>>; timer: NodeJS.Timeout }
|
|
233
|
+
>();
|
|
234
|
+
const rejectQueries = (error: Error, preserveResponses = false) => {
|
|
235
|
+
for (const { result, timer } of queries.values()) {
|
|
236
|
+
clearTimeout(timer);
|
|
237
|
+
result.reject(error);
|
|
238
|
+
}
|
|
239
|
+
// Stop can race with valid replies already in the pipe. Keep only their bounded
|
|
240
|
+
// correlation records until the helper replies or closes, never grant success.
|
|
241
|
+
if (!preserveResponses) queries.clear();
|
|
242
|
+
};
|
|
243
|
+
const disconnect = () => {
|
|
244
|
+
if (!child.stdin.destroyed) child.stdin.end();
|
|
245
|
+
};
|
|
246
|
+
const fail = (error: Error) => {
|
|
247
|
+
failure ??= error;
|
|
248
|
+
rejectQueries(error);
|
|
249
|
+
ready.reject(error);
|
|
250
|
+
started.reject(error);
|
|
251
|
+
closed.reject(error);
|
|
252
|
+
clearTimeout(timeout);
|
|
253
|
+
disconnect();
|
|
254
|
+
// Do not wait indefinitely for a stuck compiler/native inspector. Closing the
|
|
255
|
+
// protocol/output handles makes its next operation fail closed; no PID signal.
|
|
256
|
+
stdout.close();
|
|
257
|
+
stderr.close();
|
|
258
|
+
child.stdout.destroy();
|
|
259
|
+
child.stderr.destroy();
|
|
260
|
+
child.unref();
|
|
261
|
+
};
|
|
262
|
+
timeout = setTimeout(
|
|
263
|
+
() => fail(new Error('Windows native helper startup timed out')),
|
|
264
|
+
START_TIMEOUT,
|
|
265
|
+
);
|
|
266
|
+
child.stdin.on('error', error => fail(error));
|
|
267
|
+
child.on('error', error => fail(error));
|
|
268
|
+
child.stderr.on('data', (chunk: Buffer) => {
|
|
269
|
+
diagnostics = (diagnostics + chunk.toString('utf8')).slice(-16_384);
|
|
270
|
+
});
|
|
271
|
+
consumeFrames(
|
|
272
|
+
child,
|
|
273
|
+
value => {
|
|
274
|
+
if (value.type === 'ready' && phase === 'initial') {
|
|
275
|
+
if (
|
|
276
|
+
value.jobId !== config.jobId ||
|
|
277
|
+
value.pid !== child.pid ||
|
|
278
|
+
typeof value.startTime !== 'string' ||
|
|
279
|
+
!/^\d{1,20}$/.test(value.startTime)
|
|
280
|
+
) {
|
|
281
|
+
throw new Error('Invalid Windows native helper identity');
|
|
282
|
+
}
|
|
283
|
+
phase = 'ready';
|
|
284
|
+
ready.resolve({
|
|
285
|
+
pid: value.pid as number,
|
|
286
|
+
startTime: value.startTime,
|
|
287
|
+
host: hostname(),
|
|
288
|
+
state: 'running',
|
|
289
|
+
args: '',
|
|
290
|
+
});
|
|
291
|
+
} else if (value.type === 'started' && phase === 'authorized') {
|
|
292
|
+
phase = 'started';
|
|
293
|
+
clearTimeout(timeout);
|
|
294
|
+
started.resolve();
|
|
295
|
+
} else if (value.type === 'containment' && phase === 'started') {
|
|
296
|
+
if (
|
|
297
|
+
typeof value.requestId !== 'string' ||
|
|
298
|
+
value.jobId !== config.jobId ||
|
|
299
|
+
typeof value.contained !== 'boolean'
|
|
300
|
+
) {
|
|
301
|
+
throw new Error('Invalid Windows native containment response');
|
|
302
|
+
}
|
|
303
|
+
const query = queries.get(value.requestId);
|
|
304
|
+
if (!query) throw new Error('Unknown Windows native containment response');
|
|
305
|
+
queries.delete(value.requestId);
|
|
306
|
+
clearTimeout(query.timer);
|
|
307
|
+
query.result.resolve(value.contained && !stopping && !failure && !didClose);
|
|
308
|
+
} else if (value.type === 'drained' && phase === 'started') {
|
|
309
|
+
rejectQueries(new Error('Windows native Job has drained'));
|
|
310
|
+
if (
|
|
311
|
+
!Number.isSafeInteger(value.code) ||
|
|
312
|
+
(value.code as number) < 0 ||
|
|
313
|
+
(value.code as number) > 0xffffffff ||
|
|
314
|
+
typeof value.failed !== 'boolean'
|
|
315
|
+
) {
|
|
316
|
+
throw new Error('Invalid Windows native drainage proof');
|
|
317
|
+
}
|
|
318
|
+
phase = 'drained';
|
|
319
|
+
proof = {
|
|
320
|
+
type: 'drained',
|
|
321
|
+
result: { code: value.code as number, signal: null },
|
|
322
|
+
failed: value.failed || stopping,
|
|
323
|
+
};
|
|
324
|
+
} else {
|
|
325
|
+
throw new Error('Unexpected Windows native control message');
|
|
326
|
+
}
|
|
327
|
+
},
|
|
328
|
+
fail,
|
|
329
|
+
);
|
|
330
|
+
child.once('close', (code, signal) => {
|
|
331
|
+
didClose = true;
|
|
332
|
+
rejectQueries(new Error('Windows native Job helper is no longer live'));
|
|
333
|
+
clearTimeout(timeout);
|
|
334
|
+
void (async () => {
|
|
335
|
+
try {
|
|
336
|
+
if (failure) throw new Error('Windows native helper control failed', { cause: failure });
|
|
337
|
+
if (code !== 0 || signal || !proof) {
|
|
338
|
+
throw new Error(
|
|
339
|
+
`Windows native helper exited without proving Job shutdown${diagnostics ? `: ${diagnostics.trim()}` : ''}`,
|
|
340
|
+
);
|
|
341
|
+
}
|
|
342
|
+
timeout = setTimeout(
|
|
343
|
+
() => fail(new Error('Windows native output bridge did not close in time')),
|
|
344
|
+
10_000,
|
|
345
|
+
);
|
|
346
|
+
await Promise.all([stdout.ended, stderr.ended]);
|
|
347
|
+
clearTimeout(timeout);
|
|
348
|
+
closed.resolve(proof);
|
|
349
|
+
} catch (error) {
|
|
350
|
+
const cause = errorOf(error);
|
|
351
|
+
ready.reject(cause);
|
|
352
|
+
started.reject(cause);
|
|
353
|
+
closed.reject(cause);
|
|
354
|
+
} finally {
|
|
355
|
+
clearTimeout(timeout);
|
|
356
|
+
stdout.close();
|
|
357
|
+
stderr.close();
|
|
358
|
+
}
|
|
359
|
+
})();
|
|
360
|
+
});
|
|
361
|
+
try {
|
|
362
|
+
const env: Record<string, string> = {};
|
|
363
|
+
// Node's Windows spawn convention: case-insensitive keys, first lexical spelling wins.
|
|
364
|
+
const keys = new Set<string>();
|
|
365
|
+
for (const key of Object.keys(config.env).sort()) {
|
|
366
|
+
const value = config.env[key];
|
|
367
|
+
if (value !== undefined && !keys.has(key.toUpperCase())) {
|
|
368
|
+
keys.add(key.toUpperCase());
|
|
369
|
+
env[key] = value;
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
child.stdin.write(frame({ ...config, env, stdoutPipe: stdout.name, stderrPipe: stderr.name }));
|
|
373
|
+
output.onHelperSpawn?.(child);
|
|
374
|
+
} catch (error) {
|
|
375
|
+
fail(errorOf(error));
|
|
376
|
+
}
|
|
377
|
+
const stop = () => {
|
|
378
|
+
if (stopping) return;
|
|
379
|
+
stopping = true;
|
|
380
|
+
rejectQueries(new Error('Windows native Job is stopping'), true);
|
|
381
|
+
if (proof) proof.failed = true;
|
|
382
|
+
if (!didClose) {
|
|
383
|
+
clearTimeout(timeout);
|
|
384
|
+
timeout = setTimeout(
|
|
385
|
+
() => fail(new Error('Windows native helper did not prove shutdown after stop')),
|
|
386
|
+
25_000,
|
|
387
|
+
);
|
|
388
|
+
}
|
|
389
|
+
disconnect();
|
|
390
|
+
};
|
|
391
|
+
return {
|
|
392
|
+
ready: ready.promise,
|
|
393
|
+
started: started.promise,
|
|
394
|
+
wait: () => closed.promise,
|
|
395
|
+
authorize: () => {
|
|
396
|
+
if (phase !== 'ready' || stopping || didClose || failure)
|
|
397
|
+
throw new Error('Windows native launch authorization is no longer valid');
|
|
398
|
+
phase = 'authorized';
|
|
399
|
+
child.stdin.write(frame({ type: 'authorize', jobId: config.jobId }));
|
|
400
|
+
},
|
|
401
|
+
contains: async identity => {
|
|
402
|
+
if (
|
|
403
|
+
phase !== 'started' ||
|
|
404
|
+
stopping ||
|
|
405
|
+
didClose ||
|
|
406
|
+
failure ||
|
|
407
|
+
queries.size >= 32 ||
|
|
408
|
+
!Number.isSafeInteger(identity.pid) ||
|
|
409
|
+
identity.pid <= 0 ||
|
|
410
|
+
identity.pid > 0xffffffff ||
|
|
411
|
+
typeof identity.startTime !== 'string' ||
|
|
412
|
+
!/^[1-9]\d{0,19}$/.test(identity.startTime)
|
|
413
|
+
) {
|
|
414
|
+
throw new Error('Windows native containment query is unavailable or invalid');
|
|
415
|
+
}
|
|
416
|
+
const requestId = randomBytes(16).toString('hex');
|
|
417
|
+
const result = deferred<boolean>();
|
|
418
|
+
const timer = setTimeout(() => {
|
|
419
|
+
fail(new Error('Windows native containment query timed out'));
|
|
420
|
+
}, 4000);
|
|
421
|
+
queries.set(requestId, { result, timer });
|
|
422
|
+
try {
|
|
423
|
+
child.stdin.write(
|
|
424
|
+
frame({
|
|
425
|
+
type: 'containment',
|
|
426
|
+
requestId,
|
|
427
|
+
jobId: config.jobId,
|
|
428
|
+
pid: identity.pid,
|
|
429
|
+
startTime: identity.startTime,
|
|
430
|
+
}),
|
|
431
|
+
);
|
|
432
|
+
} catch (error) {
|
|
433
|
+
fail(errorOf(error));
|
|
434
|
+
}
|
|
435
|
+
return await result.promise;
|
|
436
|
+
},
|
|
437
|
+
stop,
|
|
438
|
+
disconnect: stop,
|
|
439
|
+
};
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
/** Query only the relevant owner/helper PIDs. Access/query errors are never absence. */
|
|
443
|
+
export async function inspectWindowsProcesses(
|
|
444
|
+
pids: readonly number[],
|
|
445
|
+
): Promise<WindowsProcessSnapshot[]> {
|
|
446
|
+
requireWindows();
|
|
447
|
+
if (pids.some(pid => !Number.isSafeInteger(pid) || pid <= 0 || pid > 0xffffffff))
|
|
448
|
+
throw new Error('Invalid Windows process PID');
|
|
449
|
+
const unique = [...new Set(pids)];
|
|
450
|
+
if (unique.length > 4096) throw new Error('Too many Windows process identities to inspect');
|
|
451
|
+
if (!unique.length) return [];
|
|
452
|
+
const child = spawnHelper('inspect');
|
|
453
|
+
let response: WindowsProcessSnapshot[] | undefined;
|
|
454
|
+
let failure: Error | undefined;
|
|
455
|
+
let diagnostics = '';
|
|
456
|
+
const inspection = deferred<WindowsProcessSnapshot[]>();
|
|
457
|
+
const fail = (error: Error) => {
|
|
458
|
+
failure ??= error;
|
|
459
|
+
inspection.reject(error);
|
|
460
|
+
child.stdin.destroy();
|
|
461
|
+
child.stdout.destroy();
|
|
462
|
+
child.stderr.destroy();
|
|
463
|
+
child.unref();
|
|
464
|
+
};
|
|
465
|
+
const timeout = setTimeout(
|
|
466
|
+
() => fail(new Error('Windows native process inspection timed out')),
|
|
467
|
+
START_TIMEOUT,
|
|
468
|
+
);
|
|
469
|
+
child.stdin.on('error', fail);
|
|
470
|
+
child.stderr.on('data', (chunk: Buffer) => {
|
|
471
|
+
diagnostics = (diagnostics + chunk.toString('utf8')).slice(-16_384);
|
|
472
|
+
});
|
|
473
|
+
consumeFrames(
|
|
474
|
+
child,
|
|
475
|
+
value => {
|
|
476
|
+
if (response || value.type !== 'inspection' || !Array.isArray(value.processes))
|
|
477
|
+
throw new Error('Invalid Windows native process inspection');
|
|
478
|
+
const seen = new Set<number>();
|
|
479
|
+
response = value.processes.map((row: unknown) => {
|
|
480
|
+
if (
|
|
481
|
+
!row ||
|
|
482
|
+
typeof row !== 'object' ||
|
|
483
|
+
!('pid' in row) ||
|
|
484
|
+
!('startTime' in row) ||
|
|
485
|
+
typeof row.pid !== 'number' ||
|
|
486
|
+
!unique.includes(row.pid) ||
|
|
487
|
+
seen.has(row.pid) ||
|
|
488
|
+
typeof row.startTime !== 'string' ||
|
|
489
|
+
!/^\d{1,20}$/.test(row.startTime)
|
|
490
|
+
) {
|
|
491
|
+
throw new Error('Invalid Windows native process identity');
|
|
492
|
+
}
|
|
493
|
+
seen.add(row.pid);
|
|
494
|
+
return {
|
|
495
|
+
pid: row.pid,
|
|
496
|
+
startTime: row.startTime,
|
|
497
|
+
host: hostname(),
|
|
498
|
+
state: 'running',
|
|
499
|
+
args: '',
|
|
500
|
+
};
|
|
501
|
+
});
|
|
502
|
+
},
|
|
503
|
+
fail,
|
|
504
|
+
);
|
|
505
|
+
try {
|
|
506
|
+
child.once('error', fail);
|
|
507
|
+
child.once('close', (code, signal) => {
|
|
508
|
+
if (failure) {
|
|
509
|
+
inspection.reject(failure);
|
|
510
|
+
} else if (code !== 0 || signal || !response) {
|
|
511
|
+
inspection.reject(
|
|
512
|
+
new Error(
|
|
513
|
+
`Cannot inspect Windows process identities${diagnostics ? `: ${diagnostics.trim()}` : ''}`,
|
|
514
|
+
),
|
|
515
|
+
);
|
|
516
|
+
} else {
|
|
517
|
+
inspection.resolve(response);
|
|
518
|
+
}
|
|
519
|
+
});
|
|
520
|
+
child.stdin.end(frame({ pids: unique }));
|
|
521
|
+
return await inspection.promise;
|
|
522
|
+
} finally {
|
|
523
|
+
clearTimeout(timeout);
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
export async function inspectWindowsProcess(
|
|
528
|
+
pid: number,
|
|
529
|
+
): Promise<WindowsProcessSnapshot | undefined> {
|
|
530
|
+
return (await inspectWindowsProcesses([pid]))[0];
|
|
531
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import { spawnSync } from 'node:child_process';
|
|
3
|
+
import { test } from 'node:test';
|
|
4
|
+
|
|
5
|
+
const bootstrapUrl = new URL('./bootstrap.ts', import.meta.url).href;
|
|
6
|
+
|
|
7
|
+
for (const platform of ['win32', 'linux', 'darwin']) {
|
|
8
|
+
test(`bootstrap preserves the server cluster policy on ${platform}`, () => {
|
|
9
|
+
const source = `
|
|
10
|
+
import assert from 'node:assert/strict';
|
|
11
|
+
import { registerHooks } from 'node:module';
|
|
12
|
+
const bootstrapUrl = ${JSON.stringify(bootstrapUrl)};
|
|
13
|
+
Object.defineProperty(process, 'platform', { value: ${JSON.stringify(platform)} });
|
|
14
|
+
const calls = globalThis.calls = [];
|
|
15
|
+
const app = globalThis.app = {};
|
|
16
|
+
const mocks = {
|
|
17
|
+
'../utils/util.ts': 'export function prepareEnv(env) { return env; }',
|
|
18
|
+
'./cluster.ts':
|
|
19
|
+
'export async function startCluster(workers, options) { globalThis.calls.push({ kind: "cluster", workers, options }); if (globalThis.failure) throw globalThis.failure; }' +
|
|
20
|
+
'export async function startWorker(options) { globalThis.calls.push({ kind: "worker", options }); if (globalThis.failure) throw globalThis.failure; return globalThis.app; }',
|
|
21
|
+
};
|
|
22
|
+
registerHooks({
|
|
23
|
+
resolve(specifier, context, nextResolve) {
|
|
24
|
+
if (context.parentURL === bootstrapUrl && mocks[specifier]) {
|
|
25
|
+
return { url: 'data:text/javascript,' + encodeURIComponent(mocks[specifier]), shortCircuit: true };
|
|
26
|
+
}
|
|
27
|
+
return nextResolve(specifier, context);
|
|
28
|
+
},
|
|
29
|
+
});
|
|
30
|
+
const { bootstrap } = await import(bootstrapUrl);
|
|
31
|
+
for (const mode of ['prod', 'test', 'dev']) {
|
|
32
|
+
for (const workers of [1, 2]) {
|
|
33
|
+
process.env.META_MODE = mode;
|
|
34
|
+
const options = { env: { SERVER_WORKERS: String(workers) } };
|
|
35
|
+
const result = await bootstrap(options);
|
|
36
|
+
const clustered = process.platform === 'win32' || mode === 'dev' || workers > 1;
|
|
37
|
+
assert.equal(calls.length, 1);
|
|
38
|
+
const call = calls.pop();
|
|
39
|
+
assert.equal(call.kind, clustered ? 'cluster' : 'worker', mode + ':' + workers);
|
|
40
|
+
assert.equal(call.options, options);
|
|
41
|
+
if (clustered) {
|
|
42
|
+
assert.equal(call.workers, workers);
|
|
43
|
+
assert.equal(result, undefined);
|
|
44
|
+
} else {
|
|
45
|
+
assert.equal(result, app);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
process.env.META_MODE = 'prod';
|
|
50
|
+
const failure = globalThis.failure = new Error('startup failed');
|
|
51
|
+
await assert.rejects(bootstrap({ env: { SERVER_WORKERS: '1' } }), error => error === failure);
|
|
52
|
+
`;
|
|
53
|
+
const result = spawnSync(process.execPath, ['--input-type=module', '--eval', source], {
|
|
54
|
+
encoding: 'utf8',
|
|
55
|
+
timeout: 5_000,
|
|
56
|
+
});
|
|
57
|
+
assert.ifError(result.error);
|
|
58
|
+
assert.equal(result.signal, null, result.stderr);
|
|
59
|
+
assert.equal(result.status, 0, result.stderr);
|
|
60
|
+
});
|
|
61
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import { spawnSync } from 'node:child_process';
|
|
3
|
+
import { test } from 'node:test';
|
|
4
|
+
|
|
5
|
+
const workerUrl = new URL('./processWorker.ts', import.meta.url).href;
|
|
6
|
+
|
|
7
|
+
for (const scenario of ['clean', 'reject', 'timeout', 'signal'] as const) {
|
|
8
|
+
test(`worker cooperative close reuses guarded cleanup: ${scenario}`, () => {
|
|
9
|
+
const source = `
|
|
10
|
+
import assert from 'node:assert/strict';
|
|
11
|
+
import { registerHooks } from 'node:module';
|
|
12
|
+
const scenario = process.argv[1];
|
|
13
|
+
let closes = 0;
|
|
14
|
+
let finished = false;
|
|
15
|
+
globalThis.closeMock = async () => {
|
|
16
|
+
closes++;
|
|
17
|
+
if (scenario === 'timeout') return new Promise(() => {});
|
|
18
|
+
await new Promise(resolve => setTimeout(resolve, 30));
|
|
19
|
+
if (scenario === 'reject') throw new Error('close failed');
|
|
20
|
+
finished = true;
|
|
21
|
+
};
|
|
22
|
+
registerHooks({ resolve(specifier, context, nextResolve) {
|
|
23
|
+
if (context.parentURL === ${JSON.stringify(workerUrl)}) {
|
|
24
|
+
const source = {
|
|
25
|
+
'@cabloy/utils': 'export async function catchError(fn) { try { return [await fn()]; } catch (error) { return [undefined, error]; } } export function catchErrorSync(fn) { try { return [fn()]; } catch (error) { return [undefined, error]; } }',
|
|
26
|
+
'./useApp.ts': 'export const closeApp = globalThis.closeMock; export function useApp() {}',
|
|
27
|
+
}[specifier];
|
|
28
|
+
if (source) return { url: 'data:text/javascript,' + encodeURIComponent(source), shortCircuit: true };
|
|
29
|
+
}
|
|
30
|
+
return nextResolve(specifier, context);
|
|
31
|
+
} });
|
|
32
|
+
process.on('exit', code => {
|
|
33
|
+
assert.equal(closes, 1);
|
|
34
|
+
assert.equal(finished, scenario === 'clean' || scenario === 'signal');
|
|
35
|
+
console.log(JSON.stringify({ code, closes, finished }));
|
|
36
|
+
});
|
|
37
|
+
const { closeProcessWorker, handleProcessWork } = await import(${JSON.stringify(workerUrl)});
|
|
38
|
+
handleProcessWork();
|
|
39
|
+
if (scenario === 'signal') process.emit('SIGTERM');
|
|
40
|
+
else void closeProcessWorker();
|
|
41
|
+
void closeProcessWorker();
|
|
42
|
+
process.emit('SIGINT');
|
|
43
|
+
`;
|
|
44
|
+
const result = spawnSync(process.execPath, ['--input-type=module', '-e', source, scenario], {
|
|
45
|
+
encoding: 'utf8',
|
|
46
|
+
timeout: 8000,
|
|
47
|
+
});
|
|
48
|
+
assert.ifError(result.error);
|
|
49
|
+
assert.equal(result.signal, null, result.stderr);
|
|
50
|
+
assert.equal(
|
|
51
|
+
result.status,
|
|
52
|
+
scenario === 'reject' || scenario === 'timeout' ? 1 : 0,
|
|
53
|
+
result.stderr,
|
|
54
|
+
);
|
|
55
|
+
assert.match(result.stdout, /"closes":1/);
|
|
56
|
+
if (scenario === 'reject') assert.match(result.stderr, /close failed/);
|
|
57
|
+
if (scenario === 'timeout') assert.match(result.stdout, /Cleanup timed out/);
|
|
58
|
+
});
|
|
59
|
+
}
|
|
@@ -20,13 +20,16 @@ async function _closeInner() {
|
|
|
20
20
|
process.exit(err ? 1 : 0);
|
|
21
21
|
}
|
|
22
22
|
|
|
23
|
+
// The authenticated test health endpoint uses the same guard and cleanup as signals.
|
|
24
|
+
export async function closeProcessWorker() {
|
|
25
|
+
if (__closing) return;
|
|
26
|
+
__closing = true;
|
|
27
|
+
await _closeInner();
|
|
28
|
+
}
|
|
29
|
+
|
|
23
30
|
export function handleProcessWork() {
|
|
24
31
|
['SIGINT', 'SIGTERM', 'SIGUSR2'].forEach(signal => {
|
|
25
|
-
process.on(signal,
|
|
26
|
-
if (__closing) return;
|
|
27
|
-
__closing = true;
|
|
28
|
-
await _closeInner();
|
|
29
|
-
});
|
|
32
|
+
process.on(signal, closeProcessWorker);
|
|
30
33
|
});
|
|
31
34
|
process.on('uncaughtException', async err => {
|
|
32
35
|
const app = useApp();
|