cabloy 5.1.229 → 5.1.231
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/playwright-e2e.yml +4 -2
- package/.github/workflows/vona-test-sqlite3.yml +2 -1
- package/CHANGELOG.md +23 -0
- package/package.json +1 -1
- package/repo-agent-governance/managed-assets.json +9 -9
- package/repo-agent-governance/skills/cabloy-frontend-scaffold/SKILL.md +2 -0
- package/repo-agent-governance/skills/cabloy-spec-execution/references/execution-protocol.md +4 -1
- package/repo-agent-governance/skills/cabloy-spec-execution/references/status-and-evidence.md +10 -0
- package/repo-docs/ai/verification.md +22 -0
- package/repo-docs/backend/model-guide.md +1 -0
- package/repo-docs/backend/websocket-usage-guide.md +3 -0
- package/repo-docs/frontend/controller-boundary-guide.md +1 -1
- package/repo-docs/frontend/ioc-and-beans.md +2 -1
- package/repo-docs/frontend/modules-and-suites.md +1 -0
- package/repo-docs/reference/repo-scripts.md +2 -0
- package/vona/env/.env +1 -0
- package/vona/packages-cli/cli/package.json +1 -1
- package/vona/packages-cli/cli-set-api/package.json +1 -1
- package/vona/packages-utils/zod-query/package.json +1 -1
- package/vona/packages-utils/zod-query/src/lib/query.ts +4 -1
- 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/bean/type.ts +3 -3
- package/vona/packages-vona/vona-core/src/lib/core/testClaim.test.ts +294 -1
- package/vona/packages-vona/vona-core/src/lib/core/testClaim.ts +47 -9
- package/vona/packages-vona/vona-core/src/lib/core/testClaimFilesystem.test.ts +163 -39
- package/vona/packages-vona/vona-core/src/lib/core/testClaimFilesystemWindows.cs +16 -6
- package/vona/packages-vona/vona-core/src/lib/core/testClaimFilesystemWindows.ts +9 -4
- package/vona/packages-vona/vona-core/src/lib/utils/index.ts +1 -0
- package/vona/packages-vona/vona-core/src/lib/utils/zod-enhance.ts +8 -1
- package/vona/packages-vona/vona-mock/package.json +1 -1
- package/vona/src/suite/a-commerce/modules/commerce-trade/src/dto/cartItem.tsx +1 -1
- package/vona/src/suite/a-commerce/modules/commerce-trade/test/cartOwnership.test.ts +144 -18
- package/vona/src/suite-vendor/a-auth/modules/auth-oauth/package.json +1 -1
- package/vona/src/suite-vendor/a-auth/modules/auth-oauth/src/bean/authProvider.oauth.ts +1 -0
- package/vona/src/suite-vendor/a-auth/modules/auth-oauth/src/types/index.ts +1 -0
- package/vona/src/suite-vendor/a-auth/package.json +2 -2
- 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-openapi/package.json +1 -1
- 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/types/relationsColumns.ts +7 -5
- package/vona/src/suite-vendor/a-vona/modules/a-permission/package.json +1 -1
- package/vona/src/suite-vendor/a-vona/package.json +1 -1
- package/zova/packages-utils/zova-jsx/package.json +2 -2
- package/zova/packages-zova/zova/package.json +3 -3
- package/zova/packages-zova/zova-core/package.json +2 -2
- package/zova/packages-zova/zova-core/src/bean/resource/component/type.ts +3 -3
- package/zova/packages-zova/zova-core/src/bean/type.ts +6 -6
- package/zova/src/suite/cabloy-basic/modules/basic-button/src/bean/behavior.perform.tsx +5 -5
- package/zova/src/suite/cabloy-basic/modules/basic-date/src/.metadata/component/date.ts +3 -1
- package/zova/src/suite/cabloy-basic/modules/basic-date/src/.metadata/component/dateRange.ts +12 -4
- package/zova/src/suite/cabloy-basic/modules/basic-pageentry/src/component/blockPageEntry/controller.tsx +8 -2
- package/zova/src/suite/cabloy-basic/modules/basic-select/src/.metadata/component/select.ts +9 -3
- package/zova/src/suite-vendor/a-zova/modules/a-behavior/package.json +1 -1
- package/zova/src/suite-vendor/a-zova/modules/a-behavior/src/types/behavior.ts +3 -3
- package/zova/src/suite-vendor/a-zova/modules/a-openapi/package.json +1 -1
- package/zova/src/suite-vendor/a-zova/modules/a-openapi/src/types/detail/detailsActionBulk.ts +5 -3
- package/zova/src/suite-vendor/a-zova/modules/a-openapi/src/types/detail/detailsActionRow.ts +5 -3
- package/zova/src/suite-vendor/a-zova/modules/a-openapi/src/types/resource/formActionRow.ts +5 -3
- package/zova/src/suite-vendor/a-zova/modules/a-openapi/src/types/resource/tableActionBulk.ts +5 -3
- package/zova/src/suite-vendor/a-zova/modules/a-openapi/src/types/resource/tableActionRow.ts +5 -3
- package/zova/src/suite-vendor/a-zova/modules/a-zod/package.json +2 -2
- package/zova/src/suite-vendor/a-zova/modules/a-zova/package.json +2 -2
- package/zova/src/suite-vendor/a-zova/package.json +5 -5
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import '@cabloy/json5';
|
|
2
|
+
import type { Socket } from 'node:net';
|
|
3
|
+
|
|
2
4
|
import assert from 'node:assert/strict';
|
|
3
|
-
import { execFileSync } from 'node:child_process';
|
|
5
|
+
import { execFileSync, spawn } from 'node:child_process';
|
|
4
6
|
import {
|
|
5
7
|
chmodSync,
|
|
6
8
|
lstatSync,
|
|
@@ -11,6 +13,7 @@ import {
|
|
|
11
13
|
rmSync,
|
|
12
14
|
symlinkSync,
|
|
13
15
|
} from 'node:fs';
|
|
16
|
+
import { createServer } from 'node:http';
|
|
14
17
|
import { hostname, tmpdir } from 'node:os';
|
|
15
18
|
import path from 'node:path';
|
|
16
19
|
import { test } from 'node:test';
|
|
@@ -363,6 +366,296 @@ test('broker serializes simultaneous updates and acknowledges durable intent bef
|
|
|
363
366
|
}
|
|
364
367
|
});
|
|
365
368
|
|
|
369
|
+
test('broker reports safe failure phases without acknowledging invalid or unreadable claims', async () => {
|
|
370
|
+
const f = fixture();
|
|
371
|
+
let broker: Awaited<ReturnType<typeof startTestClaimBroker>> | undefined;
|
|
372
|
+
try {
|
|
373
|
+
const { claimPath } = await createTestClaim(f.projectPath, runId, 'unit', owner);
|
|
374
|
+
broker = await startTestClaimBroker(claimPath);
|
|
375
|
+
const env = transportEnv(broker.env);
|
|
376
|
+
const before = await readTestClaimFile(claimPath);
|
|
377
|
+
await assert.rejects(
|
|
378
|
+
sendTestClaimEvent(env, { type: 'status', status: 'SECRET_INVALID_STATUS' as any }),
|
|
379
|
+
{ message: 'Managed test allocation registration failed (HTTP 409, validate)' },
|
|
380
|
+
);
|
|
381
|
+
assert.equal(await readTestClaimFile(claimPath), before);
|
|
382
|
+
await assert.rejects(
|
|
383
|
+
sendTestClaimEvent(
|
|
384
|
+
{ ...env, VONA_TEST_CLAIM_TOKEN: '0'.repeat(64) },
|
|
385
|
+
{ type: 'status', status: 'running' },
|
|
386
|
+
),
|
|
387
|
+
{ message: 'Managed test allocation registration failed (HTTP 403)' },
|
|
388
|
+
);
|
|
389
|
+
rmSync(claimPath);
|
|
390
|
+
await assert.rejects(sendTestClaimEvent(env, { type: 'status', status: 'running' }), {
|
|
391
|
+
message:
|
|
392
|
+
/^Managed test allocation registration failed \(HTTP 409, read, ENOENT(?:, native [23])?\)$/,
|
|
393
|
+
});
|
|
394
|
+
} finally {
|
|
395
|
+
await broker?.close();
|
|
396
|
+
f.dispose();
|
|
397
|
+
}
|
|
398
|
+
});
|
|
399
|
+
|
|
400
|
+
test(
|
|
401
|
+
'registration uses fresh connections after a blocked startup without replaying events',
|
|
402
|
+
{ timeout: 45000 },
|
|
403
|
+
async () => {
|
|
404
|
+
const requests: {
|
|
405
|
+
connection: string | undefined;
|
|
406
|
+
authorization: string | undefined;
|
|
407
|
+
event: unknown;
|
|
408
|
+
socket: Socket;
|
|
409
|
+
}[] = [];
|
|
410
|
+
const server = createServer(async (req, res) => {
|
|
411
|
+
let body = '';
|
|
412
|
+
for await (const chunk of req) body += chunk.toString();
|
|
413
|
+
requests.push({
|
|
414
|
+
connection: req.headers.connection,
|
|
415
|
+
authorization: req.headers.authorization,
|
|
416
|
+
event: JSON.parse(body),
|
|
417
|
+
socket: req.socket,
|
|
418
|
+
});
|
|
419
|
+
res.writeHead(204).end();
|
|
420
|
+
});
|
|
421
|
+
await new Promise<void>(resolve => server.listen(0, '127.0.0.1', resolve));
|
|
422
|
+
let child: ReturnType<typeof spawn> | undefined;
|
|
423
|
+
let exited: Promise<number | null> | undefined;
|
|
424
|
+
let spawnError: Error | undefined;
|
|
425
|
+
try {
|
|
426
|
+
const address = server.address();
|
|
427
|
+
assert.ok(address && typeof address !== 'string');
|
|
428
|
+
const env = transportEnv({
|
|
429
|
+
VONA_TEST_CLAIM_URL: `http://127.0.0.1:${address.port}/event`,
|
|
430
|
+
VONA_TEST_CLAIM_TOKEN: 'a'.repeat(64),
|
|
431
|
+
});
|
|
432
|
+
const events = [
|
|
433
|
+
{ type: 'status', status: 'running' },
|
|
434
|
+
{ type: 'status', status: 'failed' },
|
|
435
|
+
];
|
|
436
|
+
const script = `
|
|
437
|
+
import { setImmediate } from 'node:timers/promises';
|
|
438
|
+
import { sendTestClaimEvent } from ${JSON.stringify(new URL('./testClaim.ts', import.meta.url).href)};
|
|
439
|
+
const [env, events] = JSON.parse(process.argv[1]);
|
|
440
|
+
await sendTestClaimEvent(env, events[0]);
|
|
441
|
+
await setImmediate();
|
|
442
|
+
// Let the parent broker expire an idle connection while startup blocks this event loop.
|
|
443
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 6500);
|
|
444
|
+
await sendTestClaimEvent(env, events[1]);
|
|
445
|
+
`;
|
|
446
|
+
child = spawn(
|
|
447
|
+
process.execPath,
|
|
448
|
+
['--input-type=module', '-e', script, JSON.stringify([env, events])],
|
|
449
|
+
{
|
|
450
|
+
stdio: 'ignore',
|
|
451
|
+
windowsHide: true,
|
|
452
|
+
timeout: 30000,
|
|
453
|
+
killSignal: 'SIGKILL',
|
|
454
|
+
},
|
|
455
|
+
);
|
|
456
|
+
child.once('error', error => {
|
|
457
|
+
spawnError = error;
|
|
458
|
+
});
|
|
459
|
+
exited = new Promise(resolve => child!.once('close', code => resolve(code)));
|
|
460
|
+
assert.equal(await exited, 0, 'claim client child failed');
|
|
461
|
+
assert.equal(spawnError, undefined);
|
|
462
|
+
assert.deepEqual(
|
|
463
|
+
requests.map(request => request.event),
|
|
464
|
+
events,
|
|
465
|
+
);
|
|
466
|
+
assert.deepEqual(
|
|
467
|
+
requests.map(request => request.connection),
|
|
468
|
+
['close', 'close'],
|
|
469
|
+
);
|
|
470
|
+
assert.ok(
|
|
471
|
+
requests.every(request => request.authorization === `Bearer ${env.VONA_TEST_CLAIM_TOKEN}`),
|
|
472
|
+
);
|
|
473
|
+
assert.notEqual(requests[0].socket, requests[1].socket);
|
|
474
|
+
} finally {
|
|
475
|
+
if (child && child.exitCode === null && child.signalCode === null) child.kill('SIGKILL');
|
|
476
|
+
await exited;
|
|
477
|
+
server.closeAllConnections();
|
|
478
|
+
await new Promise<void>((resolve, reject) =>
|
|
479
|
+
server.close(error => (error ? reject(error) : resolve())),
|
|
480
|
+
);
|
|
481
|
+
}
|
|
482
|
+
},
|
|
483
|
+
);
|
|
484
|
+
|
|
485
|
+
test('containment requests do not reuse broker connections', async () => {
|
|
486
|
+
const requests: { connection: string | undefined; url: string | undefined; socket: Socket }[] =
|
|
487
|
+
[];
|
|
488
|
+
const server = createServer((req, res) => {
|
|
489
|
+
req.resume();
|
|
490
|
+
req.on('end', () => {
|
|
491
|
+
requests.push({ connection: req.headers.connection, url: req.url, socket: req.socket });
|
|
492
|
+
res.writeHead(204).end();
|
|
493
|
+
});
|
|
494
|
+
});
|
|
495
|
+
await new Promise<void>(resolve => server.listen(0, '127.0.0.1', resolve));
|
|
496
|
+
try {
|
|
497
|
+
const address = server.address();
|
|
498
|
+
assert.ok(address && typeof address !== 'string');
|
|
499
|
+
const env = {
|
|
500
|
+
...transportEnv({
|
|
501
|
+
VONA_TEST_CLAIM_URL: `http://127.0.0.1:${address.port}/event`,
|
|
502
|
+
VONA_TEST_CLAIM_TOKEN: 'a'.repeat(64),
|
|
503
|
+
}),
|
|
504
|
+
[TEST_CLAIM_WINDOWS_GUARDIAN_ENV]: 'windows-e2e-guardian',
|
|
505
|
+
};
|
|
506
|
+
const identity = { pid: 123, startTime: '133000000000000001', host: hostname() };
|
|
507
|
+
await assertTestClaimWindowsJob(env, identity);
|
|
508
|
+
await new Promise(resolve => setImmediate(resolve));
|
|
509
|
+
await assertTestClaimWindowsJob(env, identity);
|
|
510
|
+
assert.deepEqual(
|
|
511
|
+
requests.map(request => request.url),
|
|
512
|
+
['/containment', '/containment'],
|
|
513
|
+
);
|
|
514
|
+
assert.deepEqual(
|
|
515
|
+
requests.map(request => request.connection),
|
|
516
|
+
['close', 'close'],
|
|
517
|
+
);
|
|
518
|
+
assert.notEqual(requests[0].socket, requests[1].socket);
|
|
519
|
+
} finally {
|
|
520
|
+
server.closeAllConnections();
|
|
521
|
+
await new Promise<void>((resolve, reject) =>
|
|
522
|
+
server.close(error => (error ? reject(error) : resolve())),
|
|
523
|
+
);
|
|
524
|
+
}
|
|
525
|
+
});
|
|
526
|
+
|
|
527
|
+
test('registration waits beyond ten seconds for acknowledgement without replaying the event', async () => {
|
|
528
|
+
const received = Promise.withResolvers<() => void>();
|
|
529
|
+
let requests = 0;
|
|
530
|
+
const server = createServer((req, res) => {
|
|
531
|
+
requests++;
|
|
532
|
+
req.resume();
|
|
533
|
+
req.on('end', () => received.resolve(() => res.writeHead(204).end()));
|
|
534
|
+
});
|
|
535
|
+
let registration: Promise<void> | undefined;
|
|
536
|
+
await new Promise<void>(resolve => server.listen(0, '127.0.0.1', resolve));
|
|
537
|
+
try {
|
|
538
|
+
const address = server.address();
|
|
539
|
+
assert.ok(address && typeof address !== 'string');
|
|
540
|
+
const env = transportEnv({
|
|
541
|
+
VONA_TEST_CLAIM_URL: `http://127.0.0.1:${address.port}/event`,
|
|
542
|
+
VONA_TEST_CLAIM_TOKEN: 'a'.repeat(64),
|
|
543
|
+
});
|
|
544
|
+
let settled = false;
|
|
545
|
+
registration = sendTestClaimEvent(env, { type: 'status', status: 'running' });
|
|
546
|
+
void registration.then(
|
|
547
|
+
() => {
|
|
548
|
+
settled = true;
|
|
549
|
+
},
|
|
550
|
+
() => {
|
|
551
|
+
settled = true;
|
|
552
|
+
},
|
|
553
|
+
);
|
|
554
|
+
const acknowledge = await received.promise;
|
|
555
|
+
await new Promise(resolve => setTimeout(resolve, 11000));
|
|
556
|
+
assert.equal(settled, false);
|
|
557
|
+
assert.equal(requests, 1);
|
|
558
|
+
acknowledge();
|
|
559
|
+
await registration;
|
|
560
|
+
assert.equal(requests, 1);
|
|
561
|
+
} finally {
|
|
562
|
+
server.closeAllConnections();
|
|
563
|
+
await registration?.catch(() => {});
|
|
564
|
+
await new Promise<void>((resolve, reject) =>
|
|
565
|
+
server.close(error => (error ? reject(error) : resolve())),
|
|
566
|
+
);
|
|
567
|
+
}
|
|
568
|
+
});
|
|
569
|
+
|
|
570
|
+
test(
|
|
571
|
+
'registration has a finite acknowledgement deadline and fails closed without replay',
|
|
572
|
+
{ concurrency: false },
|
|
573
|
+
async t => {
|
|
574
|
+
const controller = new AbortController();
|
|
575
|
+
const timeout = AbortSignal.timeout.bind(AbortSignal);
|
|
576
|
+
let requests = 0;
|
|
577
|
+
const server = createServer(req => {
|
|
578
|
+
requests++;
|
|
579
|
+
req.resume();
|
|
580
|
+
req.on('end', () => {
|
|
581
|
+
timeout(20).addEventListener('abort', () => controller.abort(), { once: true });
|
|
582
|
+
});
|
|
583
|
+
});
|
|
584
|
+
const deadlines: number[] = [];
|
|
585
|
+
let registration: Promise<void> | undefined;
|
|
586
|
+
await new Promise<void>(resolve => server.listen(0, '127.0.0.1', resolve));
|
|
587
|
+
const mock = t.mock.method(AbortSignal, 'timeout', (milliseconds: number) => {
|
|
588
|
+
deadlines.push(milliseconds);
|
|
589
|
+
return controller.signal;
|
|
590
|
+
});
|
|
591
|
+
try {
|
|
592
|
+
const address = server.address();
|
|
593
|
+
assert.ok(address && typeof address !== 'string');
|
|
594
|
+
const env = transportEnv({
|
|
595
|
+
VONA_TEST_CLAIM_URL: `http://127.0.0.1:${address.port}/event`,
|
|
596
|
+
VONA_TEST_CLAIM_TOKEN: 'a'.repeat(64),
|
|
597
|
+
});
|
|
598
|
+
registration = sendTestClaimEvent(env, { type: 'status', status: 'running' });
|
|
599
|
+
await assert.rejects(registration, {
|
|
600
|
+
message: 'Managed test allocation registration failed (transport)',
|
|
601
|
+
});
|
|
602
|
+
assert.deepEqual(deadlines, [120000]);
|
|
603
|
+
assert.equal(requests, 1);
|
|
604
|
+
} finally {
|
|
605
|
+
mock.mock.restore();
|
|
606
|
+
server.closeAllConnections();
|
|
607
|
+
await registration?.catch(() => {});
|
|
608
|
+
await new Promise<void>((resolve, reject) =>
|
|
609
|
+
server.close(error => (error ? reject(error) : resolve())),
|
|
610
|
+
);
|
|
611
|
+
}
|
|
612
|
+
},
|
|
613
|
+
);
|
|
614
|
+
|
|
615
|
+
test('registration diagnostics accept only categorical headers and never response bodies', async () => {
|
|
616
|
+
let headers: Record<string, string> = {
|
|
617
|
+
'x-vona-claim-phase': 'write',
|
|
618
|
+
'x-vona-claim-code': 'EBUSY',
|
|
619
|
+
'x-vona-claim-native-code': '32',
|
|
620
|
+
};
|
|
621
|
+
const server = createServer((req, res) => {
|
|
622
|
+
req.resume();
|
|
623
|
+
res.writeHead(409, headers).end('SECRET_RESPONSE_BODY');
|
|
624
|
+
});
|
|
625
|
+
await new Promise<void>(resolve => server.listen(0, '127.0.0.1', resolve));
|
|
626
|
+
try {
|
|
627
|
+
const address = server.address();
|
|
628
|
+
assert.ok(address && typeof address !== 'string');
|
|
629
|
+
const env = transportEnv({
|
|
630
|
+
VONA_TEST_CLAIM_URL: `http://127.0.0.1:${address.port}/event`,
|
|
631
|
+
VONA_TEST_CLAIM_TOKEN: 'a'.repeat(64),
|
|
632
|
+
});
|
|
633
|
+
await assert.rejects(sendTestClaimEvent(env, { type: 'status', status: 'running' }), {
|
|
634
|
+
message: 'Managed test allocation registration failed (HTTP 409, write, EBUSY, native 32)',
|
|
635
|
+
});
|
|
636
|
+
headers = {
|
|
637
|
+
'x-vona-claim-phase': 'SECRET_PATH',
|
|
638
|
+
'x-vona-claim-code': 'SECRET_TOKEN',
|
|
639
|
+
'x-vona-claim-native-code': '999999',
|
|
640
|
+
};
|
|
641
|
+
await assert.rejects(sendTestClaimEvent(env, { type: 'status', status: 'running' }), {
|
|
642
|
+
message: 'Managed test allocation registration failed (HTTP 409)',
|
|
643
|
+
});
|
|
644
|
+
await new Promise<void>((resolve, reject) =>
|
|
645
|
+
server.close(error => (error ? reject(error) : resolve())),
|
|
646
|
+
);
|
|
647
|
+
await assert.rejects(sendTestClaimEvent(env, { type: 'status', status: 'running' }), {
|
|
648
|
+
message: 'Managed test allocation registration failed (transport)',
|
|
649
|
+
});
|
|
650
|
+
} finally {
|
|
651
|
+
if (server.listening) {
|
|
652
|
+
await new Promise<void>((resolve, reject) =>
|
|
653
|
+
server.close(error => (error ? reject(error) : resolve())),
|
|
654
|
+
);
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
});
|
|
658
|
+
|
|
366
659
|
test('database confirmations and process registration cannot precede allocation intent', async () => {
|
|
367
660
|
const f = fixture();
|
|
368
661
|
let broker: Awaited<ReturnType<typeof startTestClaimBroker>> | undefined;
|
|
@@ -662,9 +662,17 @@ export async function startTestClaimBroker(claimPath: string) {
|
|
|
662
662
|
const update = (event: TestClaimEvent) => {
|
|
663
663
|
if (closing) return Promise.reject(new Error('Test claim broker is closed'));
|
|
664
664
|
const result = queue.then(async () => {
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
665
|
+
let phase = 'read';
|
|
666
|
+
try {
|
|
667
|
+
const manifest = await loadTestClaim(claimPath);
|
|
668
|
+
phase = 'validate';
|
|
669
|
+
applyEvent(manifest, event);
|
|
670
|
+
phase = 'write';
|
|
671
|
+
await writeTestClaim(claimPath, manifest);
|
|
672
|
+
} catch (error) {
|
|
673
|
+
if (error instanceof Error) Object.assign(error, { testClaimPhase: phase });
|
|
674
|
+
throw error;
|
|
675
|
+
}
|
|
668
676
|
});
|
|
669
677
|
queue = result.catch(() => {});
|
|
670
678
|
return result;
|
|
@@ -692,8 +700,21 @@ export async function startTestClaimBroker(claimPath: string) {
|
|
|
692
700
|
if (req.url === '/containment') await containment(parseTestClaimJson(body));
|
|
693
701
|
else await update(parseTestClaimJson(body));
|
|
694
702
|
res.writeHead(204).end();
|
|
695
|
-
} catch {
|
|
696
|
-
|
|
703
|
+
} catch (error) {
|
|
704
|
+
const failure = error as { testClaimPhase?: string; code?: string; nativeCode?: number };
|
|
705
|
+
const headers: Record<string, string> = {};
|
|
706
|
+
if (['read', 'validate', 'write'].includes(failure?.testClaimPhase!))
|
|
707
|
+
headers['x-vona-claim-phase'] = failure.testClaimPhase!;
|
|
708
|
+
if (['EEXIST', 'ENOENT', 'EBUSY', 'EACCES', 'EUNSAFE'].includes(failure?.code!))
|
|
709
|
+
headers['x-vona-claim-code'] = failure.code!;
|
|
710
|
+
if (
|
|
711
|
+
Number.isInteger(failure?.nativeCode) &&
|
|
712
|
+
failure.nativeCode! >= 0 &&
|
|
713
|
+
failure.nativeCode! <= 65535
|
|
714
|
+
) {
|
|
715
|
+
headers['x-vona-claim-native-code'] = String(failure.nativeCode);
|
|
716
|
+
}
|
|
717
|
+
res.writeHead(409, headers).end('Test claim registration rejected');
|
|
697
718
|
}
|
|
698
719
|
});
|
|
699
720
|
server.requestTimeout = 10000;
|
|
@@ -753,20 +774,35 @@ export function hasTestClaimTransport(env: TestClaimEnv = process.env) {
|
|
|
753
774
|
}
|
|
754
775
|
export async function sendTestClaimEvent(env: TestClaimEnv, event: TestClaimEvent) {
|
|
755
776
|
if (!hasTestClaimTransport(env)) return;
|
|
777
|
+
let response: Response;
|
|
756
778
|
try {
|
|
757
|
-
|
|
779
|
+
response = await fetch(env[TEST_CLAIM_URL_ENV]!, {
|
|
758
780
|
method: 'POST',
|
|
759
781
|
headers: {
|
|
760
782
|
'authorization': `Bearer ${env[TEST_CLAIM_TOKEN_ENV]}`,
|
|
783
|
+
// Startup can block past the broker's idle timeout; never reuse a stale socket.
|
|
784
|
+
'connection': 'close',
|
|
761
785
|
'content-type': 'application/json',
|
|
762
786
|
},
|
|
763
787
|
body: JSON.stringify(event),
|
|
764
|
-
|
|
788
|
+
// Includes the serialized queue and durable Windows filesystem work (30s per operation).
|
|
789
|
+
signal: AbortSignal.timeout(120000),
|
|
765
790
|
redirect: 'error',
|
|
766
791
|
});
|
|
767
|
-
if (response.status !== 204) throw new Error('Registration rejected');
|
|
768
792
|
} catch {
|
|
769
|
-
throw new Error('Managed test allocation registration failed');
|
|
793
|
+
throw new Error('Managed test allocation registration failed (transport)');
|
|
794
|
+
}
|
|
795
|
+
if (response.status !== 204) {
|
|
796
|
+
const details = [`HTTP ${response.status}`];
|
|
797
|
+
const phase = response.headers.get('x-vona-claim-phase');
|
|
798
|
+
const code = response.headers.get('x-vona-claim-code');
|
|
799
|
+
const nativeCode = response.headers.get('x-vona-claim-native-code');
|
|
800
|
+
if (['read', 'validate', 'write'].includes(phase!)) details.push(phase!);
|
|
801
|
+
if (['EEXIST', 'ENOENT', 'EBUSY', 'EACCES', 'EUNSAFE'].includes(code!)) details.push(code!);
|
|
802
|
+
if (nativeCode && /^\d{1,5}$/.test(nativeCode) && Number(nativeCode) <= 65535)
|
|
803
|
+
details.push(`native ${nativeCode}`);
|
|
804
|
+
await response.body?.cancel().catch(() => {});
|
|
805
|
+
throw new Error(`Managed test allocation registration failed (${details.join(', ')})`);
|
|
770
806
|
}
|
|
771
807
|
}
|
|
772
808
|
|
|
@@ -784,6 +820,8 @@ export async function assertTestClaimWindowsJob(env: TestClaimEnv, identity: Tes
|
|
|
784
820
|
method: 'POST',
|
|
785
821
|
headers: {
|
|
786
822
|
'authorization': `Bearer ${env[TEST_CLAIM_TOKEN_ENV]}`,
|
|
823
|
+
// Startup can block past the broker's idle timeout; never reuse a stale socket.
|
|
824
|
+
'connection': 'close',
|
|
787
825
|
'content-type': 'application/json',
|
|
788
826
|
},
|
|
789
827
|
body: JSON.stringify(query),
|
|
@@ -164,61 +164,185 @@ test(
|
|
|
164
164
|
},
|
|
165
165
|
);
|
|
166
166
|
|
|
167
|
+
async function holdWindowsClaimFile(file: string) {
|
|
168
|
+
// A native handle denying FILE_SHARE_DELETE prevents replacement. Node opens
|
|
169
|
+
// with delete sharing, so hold this deliberately through a separate PS7 peer.
|
|
170
|
+
const { spawn } = await import('node:child_process');
|
|
171
|
+
const script =
|
|
172
|
+
'$h=[System.IO.File]::Open($env:VONA_CLAIM_TEST_FILE,[System.IO.FileMode]::Open,[System.IO.FileAccess]::Read,[System.IO.FileShare]::Read);[Console]::Out.WriteLine("ready");[Console]::Out.Flush();[Console]::In.ReadLine()|Out-Null;$h.Dispose()';
|
|
173
|
+
const child = spawn('pwsh', ['-NoLogo', '-NoProfile', '-NonInteractive', '-Command', script], {
|
|
174
|
+
windowsHide: true,
|
|
175
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
176
|
+
env: { ...process.env, VONA_CLAIM_TEST_FILE: file },
|
|
177
|
+
});
|
|
178
|
+
child.stderr.resume();
|
|
179
|
+
child.stdin.on('error', () => {});
|
|
180
|
+
const exited = new Promise<void>(resolve => child.once('close', () => resolve()));
|
|
181
|
+
const release = async () => {
|
|
182
|
+
child.stdin.end('\n');
|
|
183
|
+
const timer = setTimeout(() => child.kill(), 5000);
|
|
184
|
+
try {
|
|
185
|
+
await exited;
|
|
186
|
+
} finally {
|
|
187
|
+
clearTimeout(timer);
|
|
188
|
+
}
|
|
189
|
+
};
|
|
190
|
+
try {
|
|
191
|
+
await new Promise<void>((resolve, reject) => {
|
|
192
|
+
const timer = setTimeout(() => reject(new Error('fixture handle readiness timeout')), 15000);
|
|
193
|
+
child.once('error', error => {
|
|
194
|
+
clearTimeout(timer);
|
|
195
|
+
reject(error);
|
|
196
|
+
});
|
|
197
|
+
child.once('exit', () => {
|
|
198
|
+
clearTimeout(timer);
|
|
199
|
+
reject(new Error('fixture handle exited'));
|
|
200
|
+
});
|
|
201
|
+
let output = '';
|
|
202
|
+
child.stdout.on('data', chunk => {
|
|
203
|
+
output += String(chunk);
|
|
204
|
+
if (output.includes('ready')) {
|
|
205
|
+
clearTimeout(timer);
|
|
206
|
+
resolve();
|
|
207
|
+
}
|
|
208
|
+
});
|
|
209
|
+
});
|
|
210
|
+
return release;
|
|
211
|
+
} catch (error) {
|
|
212
|
+
await release();
|
|
213
|
+
throw error;
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
167
217
|
test(
|
|
168
|
-
'Windows replacement
|
|
218
|
+
'Windows replacement retries a transient sharing conflict within the same call',
|
|
169
219
|
{ skip: process.platform !== 'win32' },
|
|
170
220
|
async () => {
|
|
171
221
|
const f = await fixture();
|
|
222
|
+
let release: (() => Promise<void>) | undefined;
|
|
223
|
+
let replacement: Promise<unknown> | undefined;
|
|
172
224
|
try {
|
|
173
225
|
await createTestClaimFile(f.file, 'previous committed inventory');
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
226
|
+
release = await holdWindowsClaimFile(f.file);
|
|
227
|
+
assert.equal(await readTestClaimFile(f.file), 'previous committed inventory');
|
|
228
|
+
let settled = false;
|
|
229
|
+
// Attach a rejection handler before polling, including the failure path.
|
|
230
|
+
replacement = replaceTestClaimFile(f.file, 'new committed inventory').then(
|
|
231
|
+
() => {
|
|
232
|
+
settled = true;
|
|
233
|
+
},
|
|
234
|
+
error => {
|
|
235
|
+
settled = true;
|
|
236
|
+
return error;
|
|
237
|
+
},
|
|
238
|
+
);
|
|
239
|
+
const deadline = Date.now() + 10000;
|
|
240
|
+
while (!(await readdir(f.directory)).some(name => name.endsWith('.tmp'))) {
|
|
241
|
+
if (settled) {
|
|
242
|
+
assert.equal(await replacement, undefined);
|
|
243
|
+
assert.fail('replacement completed before the held handle was released');
|
|
244
|
+
}
|
|
245
|
+
assert.ok(Date.now() < deadline, 'replacement temporary file was not observed');
|
|
246
|
+
await new Promise(resolve => setTimeout(resolve, 10));
|
|
247
|
+
}
|
|
248
|
+
await release();
|
|
249
|
+
release = undefined;
|
|
250
|
+
assert.equal(await replacement, undefined);
|
|
251
|
+
assert.equal(await readTestClaimFile(f.file), 'new committed inventory');
|
|
252
|
+
assert.deepEqual(await readdir(f.directory), ['claim.json']);
|
|
253
|
+
} finally {
|
|
254
|
+
await release?.();
|
|
255
|
+
await replacement;
|
|
256
|
+
await f.close();
|
|
257
|
+
}
|
|
258
|
+
},
|
|
259
|
+
);
|
|
260
|
+
|
|
261
|
+
test(
|
|
262
|
+
'Windows persistent sharing conflict fails boundedly and retains the old manifest',
|
|
263
|
+
{ skip: process.platform !== 'win32' },
|
|
264
|
+
async () => {
|
|
265
|
+
const f = await fixture();
|
|
266
|
+
let release: (() => Promise<void>) | undefined;
|
|
267
|
+
try {
|
|
268
|
+
await createTestClaimFile(f.file, 'previous committed inventory');
|
|
269
|
+
release = await holdWindowsClaimFile(f.file);
|
|
270
|
+
assert.equal(await readTestClaimFile(f.file), 'previous committed inventory');
|
|
271
|
+
const started = performance.now();
|
|
272
|
+
await assert.rejects(replaceTestClaimFile(f.file, 'uncommitted'), {
|
|
273
|
+
code: 'EBUSY',
|
|
274
|
+
nativeCode: 32,
|
|
275
|
+
message: 'Unsafe Windows test claim filesystem operation (EBUSY, nativeCode=32)',
|
|
276
|
+
});
|
|
277
|
+
const elapsed = performance.now() - started;
|
|
278
|
+
assert.ok(elapsed >= 900 && elapsed < 10000, `bounded retry took ${elapsed}ms`);
|
|
279
|
+
assert.equal(await readFile(f.file, 'utf8'), 'previous committed inventory');
|
|
280
|
+
assert.deepEqual(await readdir(f.directory), ['claim.json']);
|
|
281
|
+
await release();
|
|
282
|
+
release = undefined;
|
|
283
|
+
await replaceTestClaimFile(f.file, 'new committed inventory');
|
|
284
|
+
assert.equal(await readTestClaimFile(f.file), 'new committed inventory');
|
|
285
|
+
} finally {
|
|
286
|
+
await release?.();
|
|
287
|
+
await f.close();
|
|
288
|
+
}
|
|
289
|
+
},
|
|
290
|
+
);
|
|
291
|
+
|
|
292
|
+
test(
|
|
293
|
+
'Windows replacement rejects a read-only target without changing its contents or attributes',
|
|
294
|
+
{ skip: process.platform !== 'win32' },
|
|
295
|
+
async () => {
|
|
296
|
+
const f = await fixture();
|
|
297
|
+
const attributes = (script: string) =>
|
|
298
|
+
execFileSync(
|
|
180
299
|
'pwsh',
|
|
181
|
-
[
|
|
300
|
+
[
|
|
301
|
+
'-NoLogo',
|
|
302
|
+
'-NoProfile',
|
|
303
|
+
'-NonInteractive',
|
|
304
|
+
'-Command',
|
|
305
|
+
`$ErrorActionPreference='Stop';${script}`,
|
|
306
|
+
],
|
|
182
307
|
{
|
|
183
308
|
windowsHide: true,
|
|
184
|
-
|
|
309
|
+
encoding: 'utf8',
|
|
185
310
|
env: { ...process.env, VONA_CLAIM_TEST_FILE: f.file },
|
|
186
311
|
},
|
|
312
|
+
).trim();
|
|
313
|
+
let original: number | undefined;
|
|
314
|
+
try {
|
|
315
|
+
await createTestClaimFile(f.file, 'previous committed inventory');
|
|
316
|
+
original = Number(
|
|
317
|
+
attributes('[int][System.IO.File]::GetAttributes($env:VONA_CLAIM_TEST_FILE)'),
|
|
318
|
+
);
|
|
319
|
+
assert.ok(Number.isInteger(original));
|
|
320
|
+
const readOnly = (original & ~128) | 1;
|
|
321
|
+
attributes(
|
|
322
|
+
`[System.IO.File]::SetAttributes($env:VONA_CLAIM_TEST_FILE,[System.IO.FileAttributes]${readOnly})`,
|
|
323
|
+
);
|
|
324
|
+
assert.equal(await readTestClaimFile(f.file), 'previous committed inventory');
|
|
325
|
+
await assert.rejects(replaceTestClaimFile(f.file, 'uncommitted'), {
|
|
326
|
+
code: 'EACCES',
|
|
327
|
+
nativeCode: 5,
|
|
328
|
+
message: 'Unsafe Windows test claim filesystem operation (EACCES, nativeCode=5)',
|
|
329
|
+
});
|
|
330
|
+
assert.equal(await readTestClaimFile(f.file), 'previous committed inventory');
|
|
331
|
+
assert.equal(
|
|
332
|
+
Number(attributes('[int][System.IO.File]::GetAttributes($env:VONA_CLAIM_TEST_FILE)')),
|
|
333
|
+
readOnly,
|
|
187
334
|
);
|
|
335
|
+
assert.deepEqual(await readdir(f.directory), ['claim.json']);
|
|
336
|
+
} finally {
|
|
188
337
|
try {
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
()
|
|
192
|
-
15000,
|
|
338
|
+
if (original !== undefined) {
|
|
339
|
+
attributes(
|
|
340
|
+
`[System.IO.File]::SetAttributes($env:VONA_CLAIM_TEST_FILE,[System.IO.FileAttributes]${original})`,
|
|
193
341
|
);
|
|
194
|
-
|
|
195
|
-
clearTimeout(timer);
|
|
196
|
-
reject(error);
|
|
197
|
-
});
|
|
198
|
-
child.once('exit', () => {
|
|
199
|
-
clearTimeout(timer);
|
|
200
|
-
reject(new Error('fixture handle exited'));
|
|
201
|
-
});
|
|
202
|
-
child.stdout.once('data', chunk => {
|
|
203
|
-
clearTimeout(timer);
|
|
204
|
-
assert.match(String(chunk), /ready/);
|
|
205
|
-
resolve();
|
|
206
|
-
});
|
|
207
|
-
});
|
|
208
|
-
await assert.rejects(replaceTestClaimFile(f.file, 'uncommitted'));
|
|
209
|
-
assert.equal(await readFile(f.file, 'utf8'), 'previous committed inventory');
|
|
210
|
-
assert.deepEqual(await readdir(f.directory), ['claim.json']);
|
|
342
|
+
}
|
|
211
343
|
} finally {
|
|
212
|
-
|
|
213
|
-
await new Promise<void>(resolve => {
|
|
214
|
-
if (child.exitCode !== null) resolve();
|
|
215
|
-
else child.once('exit', () => resolve());
|
|
216
|
-
});
|
|
344
|
+
await f.close();
|
|
217
345
|
}
|
|
218
|
-
await replaceTestClaimFile(f.file, 'new committed inventory');
|
|
219
|
-
assert.equal(await readTestClaimFile(f.file), 'new committed inventory');
|
|
220
|
-
} finally {
|
|
221
|
-
await f.close();
|
|
222
346
|
}
|
|
223
347
|
},
|
|
224
348
|
);
|
|
@@ -209,10 +209,20 @@ namespace Cabloy {
|
|
|
209
209
|
bool created = false;
|
|
210
210
|
try {
|
|
211
211
|
Write(temp, bytes); created = true;
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
212
|
+
for (int attempt = 0; ; attempt++) {
|
|
213
|
+
try {
|
|
214
|
+
// Surface denied delete sharing as a sharing violation, not a rename access denial.
|
|
215
|
+
// Close our own non-delete-sharing handle before attempting the rename.
|
|
216
|
+
using (SafeFileHandle h = Open(file, false, GenericRead | DeleteAccess)) Private(h);
|
|
217
|
+
// No COPY_ALLOWED: same-directory local-NTFS replacement of the flushed temporary file.
|
|
218
|
+
// Failure is never acknowledged.
|
|
219
|
+
Check(MoveFileExW(temp, file, 1 | 8));
|
|
220
|
+
created = false; break;
|
|
221
|
+
} catch (Win32Exception e) {
|
|
222
|
+
if ((e.NativeErrorCode != 32 && e.NativeErrorCode != 33) || attempt >= 10) throw;
|
|
223
|
+
}
|
|
224
|
+
System.Threading.Thread.Sleep(100);
|
|
225
|
+
}
|
|
216
226
|
using (SafeFileHandle h = Open(file, false, GenericRead)) { if (Read(h) != content) throw Unsafe(); }
|
|
217
227
|
} finally {
|
|
218
228
|
if (created) {
|
|
@@ -222,8 +232,8 @@ namespace Cabloy {
|
|
|
222
232
|
return result;
|
|
223
233
|
}
|
|
224
234
|
} catch (Win32Exception e) {
|
|
225
|
-
return new Dictionary<string, object> { { "ok", false }, { "code", e.NativeErrorCode == 80 || e.NativeErrorCode == 183 ? "EEXIST" : e.NativeErrorCode == 2 || e.NativeErrorCode == 3 ? "ENOENT" : "EUNSAFE" } };
|
|
226
|
-
} catch { return new Dictionary<string, object> { { "ok", false }, { "code", "EUNSAFE" } }; }
|
|
235
|
+
return new Dictionary<string, object> { { "ok", false }, { "code", e.NativeErrorCode == 80 || e.NativeErrorCode == 183 ? "EEXIST" : e.NativeErrorCode == 2 || e.NativeErrorCode == 3 ? "ENOENT" : e.NativeErrorCode == 32 || e.NativeErrorCode == 33 ? "EBUSY" : e.NativeErrorCode == 5 ? "EACCES" : "EUNSAFE" }, { "nativeCode", e.NativeErrorCode } };
|
|
236
|
+
} catch { return new Dictionary<string, object> { { "ok", false }, { "code", "EUNSAFE" }, { "nativeCode", 0 } }; }
|
|
227
237
|
}
|
|
228
238
|
}
|
|
229
239
|
}
|