amalgm 0.1.152 → 0.1.153
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/lib/tunnel-events.js +268 -49
- package/package.json +1 -1
- package/runtime/scripts/amalgm-mcp/server/routes/state.js +4 -0
- package/runtime/scripts/amalgm-mcp/state/app-resources.js +2 -21
- package/runtime/scripts/amalgm-mcp/state/db.js +50 -0
- package/runtime/scripts/amalgm-mcp/state/docs.js +351 -31
- package/runtime/scripts/amalgm-mcp/state/merge3.js +197 -0
- package/runtime/scripts/amalgm-mcp/state/rest.js +79 -20
- package/runtime/scripts/amalgm-mcp/tests/browser-transport.test.js +11 -1
- package/runtime/scripts/amalgm-mcp/tests/state-docs-merge.test.js +251 -0
- package/runtime/scripts/amalgm-mcp/tests/state-docs-versioning.test.js +394 -0
- package/runtime/scripts/amalgm-mcp/tests/state-stream-bootstrap.test.js +223 -0
package/lib/tunnel-events.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
+
const crypto = require('crypto');
|
|
3
4
|
const fs = require('fs');
|
|
4
5
|
const http = require('http');
|
|
5
6
|
const os = require('os');
|
|
@@ -22,6 +23,31 @@ const DEFAULT_TARGET_PORT = DEFAULT_MCP_PORT;
|
|
|
22
23
|
const CONNECT_TIMEOUT_MS = 20_000;
|
|
23
24
|
const WATCHDOG_INTERVAL_MS = 15_000;
|
|
24
25
|
const HEARTBEAT_TIMEOUT_MS = 75_000;
|
|
26
|
+
// Per-process identity: a zombie runtime holding a stale gateway slot is
|
|
27
|
+
// indistinguishable from the live one without this. AMALGM_RUNTIME_INSTANCE_ID
|
|
28
|
+
// is directory-derived and inherited via env, so a zombie can share it —
|
|
29
|
+
// this id is fresh per process, always.
|
|
30
|
+
const INSTANCE_ID = crypto.randomBytes(8).toString('hex');
|
|
31
|
+
const BOOT_TS = Date.now();
|
|
32
|
+
const PROTOCOL_VERSION = 2;
|
|
33
|
+
// Gateway close code: this process lost slot arbitration to a newer runtime
|
|
34
|
+
// instance (same computer_id + runtime_label). Reconnecting fast would just
|
|
35
|
+
// steal the slot back and reset the winner's streams — the historical
|
|
36
|
+
// "stuck streams" ping-pong. Retry slowly instead of stopping: the newer
|
|
37
|
+
// instance may die, and regaining the slot then is correct.
|
|
38
|
+
const SUPERSEDED_CLOSE_CODE = 4009;
|
|
39
|
+
const DEFAULT_SUPERSEDED_RETRY_MS = 5 * 60 * 1000;
|
|
40
|
+
// Backoff only resets after a connection has proven stable for this long;
|
|
41
|
+
// resetting on open turns any slot fight into a 1s reconnect war.
|
|
42
|
+
const DEFAULT_STABLE_RESET_MS = 30_000;
|
|
43
|
+
// Send-path tuning: frames above BULK_FRAME_BYTES ride the bulk queue so a
|
|
44
|
+
// big file read cannot head-of-line-block keystroke-sized events; bodies
|
|
45
|
+
// above RES_CHUNK_BYTES additionally split into chunk frames on v2 peers.
|
|
46
|
+
const BULK_FRAME_BYTES = 32 * 1024;
|
|
47
|
+
const RES_CHUNK_BYTES = 256 * 1024;
|
|
48
|
+
const DEFAULT_SEND_HWM_BYTES = 1024 * 1024;
|
|
49
|
+
const DEFAULT_BULK_QUEUE_MAX_BYTES = 64 * 1024 * 1024;
|
|
50
|
+
const DRAIN_POLL_MS = 50;
|
|
25
51
|
const DEFAULT_RUNTIME_PORTS = new Map([
|
|
26
52
|
['gateway', DEFAULT_GATEWAY_PORT],
|
|
27
53
|
['portMonitor', DEFAULT_PORT_MONITOR_PORT],
|
|
@@ -84,6 +110,15 @@ const WS_HANDSHAKE_HEADERS = new Set([
|
|
|
84
110
|
'sec-websocket-protocol',
|
|
85
111
|
]);
|
|
86
112
|
|
|
113
|
+
function envBytes(name, fallback) {
|
|
114
|
+
const value = Number(process.env[name]);
|
|
115
|
+
return Number.isFinite(value) && value > 0 ? value : fallback;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function msAgo(ts, now) {
|
|
119
|
+
return ts ? `${now - ts}ms ago` : 'never';
|
|
120
|
+
}
|
|
121
|
+
|
|
87
122
|
function tunnelUrl(rawUrl) {
|
|
88
123
|
const url = new URL(rawUrl);
|
|
89
124
|
url.searchParams.delete('token');
|
|
@@ -326,6 +361,23 @@ function createEventTunnel({ record, foreground = false }) {
|
|
|
326
361
|
let connectStartedAt = 0;
|
|
327
362
|
let lastGatewayFrameAt = 0;
|
|
328
363
|
let malformedFrames = 0;
|
|
364
|
+
// v2+ gateways acknowledge hello with their protocol version; the deployed
|
|
365
|
+
// v1 gateway never sends one, so chunking stays off against it.
|
|
366
|
+
let peerProtocolVersion = 1;
|
|
367
|
+
// Heartbeat forensics: cheap timestamps so a timeout can say whether the
|
|
368
|
+
// network went silent, we stopped sending, or backpressure stalled us.
|
|
369
|
+
let connectedAt = 0;
|
|
370
|
+
let lastPingSentAt = 0;
|
|
371
|
+
let lastPongAt = 0;
|
|
372
|
+
let lastFrameReceivedAt = 0;
|
|
373
|
+
let lastFrameSentAt = 0;
|
|
374
|
+
const sendHwmBytes = envBytes('AMALGM_TUNNEL_HWM_BYTES', DEFAULT_SEND_HWM_BYTES);
|
|
375
|
+
const bulkQueueMaxBytes = envBytes('AMALGM_TUNNEL_BULK_MAX_BYTES', DEFAULT_BULK_QUEUE_MAX_BYTES);
|
|
376
|
+
const supersededRetryMs = envBytes('AMALGM_TUNNEL_SUPERSEDED_RETRY_MS', DEFAULT_SUPERSEDED_RETRY_MS);
|
|
377
|
+
const stableResetMs = envBytes('AMALGM_TUNNEL_STABLE_RESET_MS', DEFAULT_STABLE_RESET_MS);
|
|
378
|
+
const bulkQueue = [];
|
|
379
|
+
let bulkQueuedBytes = 0;
|
|
380
|
+
let drainTimer = null;
|
|
329
381
|
const upstreamSockets = new Map();
|
|
330
382
|
const upstreamStreams = new Map();
|
|
331
383
|
|
|
@@ -342,12 +394,146 @@ function createEventTunnel({ record, foreground = false }) {
|
|
|
342
394
|
console.warn(`[event-tunnel] ${message}`);
|
|
343
395
|
}
|
|
344
396
|
|
|
345
|
-
function
|
|
397
|
+
function sendPayload(payload) {
|
|
346
398
|
if (!ws || ws.readyState !== WebSocket.OPEN) return false;
|
|
347
|
-
ws.send(
|
|
399
|
+
ws.send(payload);
|
|
400
|
+
lastFrameSentAt = Date.now();
|
|
348
401
|
return true;
|
|
349
402
|
}
|
|
350
403
|
|
|
404
|
+
// Control path: hello, heartbeats, stream events, small responses. Always
|
|
405
|
+
// sends immediately so it can never starve behind a queued response body.
|
|
406
|
+
function send(frame) {
|
|
407
|
+
return sendPayload(JSON.stringify(frame));
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
// A bulk queue entry: `bytes` is the exact frame size (known up front, so
|
|
411
|
+
// the queue budget applies BEFORE anything is materialized) and `build`
|
|
412
|
+
// produces the JSON payload only when the frame is about to hit the wire.
|
|
413
|
+
// A chunked response therefore peaks at body + one in-flight frame, never
|
|
414
|
+
// body + every frame (~2.5x the body) like the eager version did.
|
|
415
|
+
function payloadItem(payload) {
|
|
416
|
+
return { bytes: payload.length, build: () => payload };
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
// Bulk path: big response bodies queue behind ws.bufferedAmount so they
|
|
420
|
+
// drain between control frames instead of head-of-line-blocking them.
|
|
421
|
+
// The queue is bounded — a dropped response must fail loudly for its
|
|
422
|
+
// req_id, never hang silently.
|
|
423
|
+
function enqueueBulk(items, reqId) {
|
|
424
|
+
// Same contract as send(): a response finishing after the connection
|
|
425
|
+
// died is dropped, never parked for the next connection's req ids.
|
|
426
|
+
if (!ws || ws.readyState !== WebSocket.OPEN) return;
|
|
427
|
+
const total = items.reduce((sum, item) => sum + item.bytes, 0);
|
|
428
|
+
if (bulkQueuedBytes + total > bulkQueueMaxBytes) {
|
|
429
|
+
warn(`bulk queue overflow (queued=${bulkQueuedBytes} incoming=${total} max=${bulkQueueMaxBytes}); dropping response for req ${reqId}`);
|
|
430
|
+
send({
|
|
431
|
+
type: 'res',
|
|
432
|
+
req_id: reqId,
|
|
433
|
+
status: 503,
|
|
434
|
+
headers: { 'content-type': 'text/plain; charset=utf-8' },
|
|
435
|
+
body_b64: Buffer.from('Tunnel backpressure: response dropped').toString('base64'),
|
|
436
|
+
});
|
|
437
|
+
return;
|
|
438
|
+
}
|
|
439
|
+
for (const item of items) bulkQueue.push(item);
|
|
440
|
+
bulkQueuedBytes += total;
|
|
441
|
+
drainBulk();
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
function drainBulk() {
|
|
445
|
+
while (bulkQueue.length > 0) {
|
|
446
|
+
if (!ws || ws.readyState !== WebSocket.OPEN) return;
|
|
447
|
+
if (ws.bufferedAmount > sendHwmBytes) {
|
|
448
|
+
// ws has no drain event — poll bufferedAmount, but only while blocked.
|
|
449
|
+
if (!drainTimer) {
|
|
450
|
+
drainTimer = setTimeout(() => {
|
|
451
|
+
drainTimer = null;
|
|
452
|
+
drainBulk();
|
|
453
|
+
}, DRAIN_POLL_MS);
|
|
454
|
+
if (typeof drainTimer.unref === 'function') drainTimer.unref();
|
|
455
|
+
}
|
|
456
|
+
return;
|
|
457
|
+
}
|
|
458
|
+
const item = bulkQueue.shift();
|
|
459
|
+
bulkQueuedBytes -= item.bytes;
|
|
460
|
+
sendPayload(item.build());
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
function resetBulkQueue(reason) {
|
|
465
|
+
if (drainTimer) {
|
|
466
|
+
clearTimeout(drainTimer);
|
|
467
|
+
drainTimer = null;
|
|
468
|
+
}
|
|
469
|
+
if (bulkQueue.length > 0) {
|
|
470
|
+
warn(`dropping ${bulkQueue.length} queued bulk frames (${bulkQueuedBytes} bytes): ${reason}`);
|
|
471
|
+
}
|
|
472
|
+
bulkQueue.length = 0;
|
|
473
|
+
bulkQueuedBytes = 0;
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
// Base64 output length for n raw bytes. Base64's alphabet needs no JSON
|
|
477
|
+
// escaping, so frame sizes computed with this are exact.
|
|
478
|
+
function base64Length(bytes) {
|
|
479
|
+
return Math.ceil(bytes / 3) * 4;
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
// Small bodies ship as today's single res frame, byte-identical. Big ones
|
|
483
|
+
// ride the bulk queue, and on v2 peers split into res_start/res_chunk/
|
|
484
|
+
// res_end so control frames interleave between chunks. Chunk frames are
|
|
485
|
+
// sized exactly but built lazily at send time (see payloadItem): the
|
|
486
|
+
// budget check runs before any frame exists, and an over-budget response
|
|
487
|
+
// is dropped without ever materializing its frames.
|
|
488
|
+
function sendResponse(reqId, status, headers, body) {
|
|
489
|
+
if (peerProtocolVersion < 2 || body.length <= RES_CHUNK_BYTES) {
|
|
490
|
+
// The v1-peer big-body case has no choice but to build the full frame:
|
|
491
|
+
// the v1 wire format is a single res frame.
|
|
492
|
+
const payload = JSON.stringify({
|
|
493
|
+
type: 'res',
|
|
494
|
+
req_id: reqId,
|
|
495
|
+
status,
|
|
496
|
+
headers,
|
|
497
|
+
body_b64: body.toString('base64'),
|
|
498
|
+
});
|
|
499
|
+
if (payload.length > BULK_FRAME_BYTES) enqueueBulk([payloadItem(payload)], reqId);
|
|
500
|
+
else sendPayload(payload);
|
|
501
|
+
return;
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
const items = [payloadItem(JSON.stringify({
|
|
505
|
+
type: 'res_start',
|
|
506
|
+
req_id: reqId,
|
|
507
|
+
status,
|
|
508
|
+
headers,
|
|
509
|
+
body_bytes: body.length,
|
|
510
|
+
}))];
|
|
511
|
+
let index = 0;
|
|
512
|
+
for (let offset = 0; offset < body.length; offset += RES_CHUNK_BYTES) {
|
|
513
|
+
const chunkIndex = index;
|
|
514
|
+
const chunkStart = offset;
|
|
515
|
+
const chunkLength = Math.min(RES_CHUNK_BYTES, body.length - offset);
|
|
516
|
+
const envelopeBytes = JSON.stringify({
|
|
517
|
+
type: 'res_chunk',
|
|
518
|
+
req_id: reqId,
|
|
519
|
+
index: chunkIndex,
|
|
520
|
+
body_b64: '',
|
|
521
|
+
}).length;
|
|
522
|
+
items.push({
|
|
523
|
+
bytes: envelopeBytes + base64Length(chunkLength),
|
|
524
|
+
build: () => JSON.stringify({
|
|
525
|
+
type: 'res_chunk',
|
|
526
|
+
req_id: reqId,
|
|
527
|
+
index: chunkIndex,
|
|
528
|
+
body_b64: body.subarray(chunkStart, chunkStart + RES_CHUNK_BYTES).toString('base64'),
|
|
529
|
+
}),
|
|
530
|
+
});
|
|
531
|
+
index += 1;
|
|
532
|
+
}
|
|
533
|
+
items.push(payloadItem(JSON.stringify({ type: 'res_end', req_id: reqId, chunks: index })));
|
|
534
|
+
enqueueBulk(items, reqId);
|
|
535
|
+
}
|
|
536
|
+
|
|
351
537
|
function terminateSocket(reason) {
|
|
352
538
|
if (!ws) return;
|
|
353
539
|
warn(`${reason}; reconnecting`);
|
|
@@ -362,6 +548,23 @@ function createEventTunnel({ record, foreground = false }) {
|
|
|
362
548
|
}
|
|
363
549
|
}
|
|
364
550
|
|
|
551
|
+
function helloFrame() {
|
|
552
|
+
const appRoutes = readAppRoutes();
|
|
553
|
+
return {
|
|
554
|
+
type: 'hello',
|
|
555
|
+
app_version: require('../package.json').version,
|
|
556
|
+
runtime_version: require('../package.json').version,
|
|
557
|
+
runtime_label: record.runtime_label || AMALGM_RUNTIME_LABEL,
|
|
558
|
+
branch: record.branch || AMALGM_BRANCH,
|
|
559
|
+
instance_id: INSTANCE_ID,
|
|
560
|
+
boot_ts: BOOT_TS,
|
|
561
|
+
protocol_version: PROTOCOL_VERSION,
|
|
562
|
+
runtime_gateway_port: runtimeGatewayPort(),
|
|
563
|
+
app_routes: appRoutes,
|
|
564
|
+
artifact_routes: legacyArtifactRoutes(appRoutes),
|
|
565
|
+
};
|
|
566
|
+
}
|
|
567
|
+
|
|
365
568
|
function startWatchdog() {
|
|
366
569
|
if (watchdogTimer) return;
|
|
367
570
|
|
|
@@ -378,25 +581,25 @@ function createEventTunnel({ record, foreground = false }) {
|
|
|
378
581
|
|
|
379
582
|
if (ws.readyState !== WebSocket.OPEN) return;
|
|
380
583
|
|
|
381
|
-
|
|
382
|
-
send({
|
|
383
|
-
type: 'hello',
|
|
384
|
-
app_version: require('../package.json').version,
|
|
385
|
-
runtime_label: record.runtime_label || AMALGM_RUNTIME_LABEL,
|
|
386
|
-
branch: record.branch || AMALGM_BRANCH,
|
|
387
|
-
runtime_gateway_port: runtimeGatewayPort(),
|
|
388
|
-
app_routes: appRoutes,
|
|
389
|
-
artifact_routes: legacyArtifactRoutes(appRoutes),
|
|
390
|
-
});
|
|
584
|
+
send(helloFrame());
|
|
391
585
|
|
|
392
586
|
try {
|
|
393
587
|
ws.ping();
|
|
588
|
+
lastPingSentAt = now;
|
|
394
589
|
} catch {
|
|
395
590
|
terminateSocket('ping failed');
|
|
396
591
|
return;
|
|
397
592
|
}
|
|
398
593
|
|
|
399
594
|
if (lastGatewayFrameAt && now - lastGatewayFrameAt > HEARTBEAT_TIMEOUT_MS) {
|
|
595
|
+
// One structured line to tell apart "network went silent" (stale
|
|
596
|
+
// lastFrameIn/lastPong), "we stopped sending" (stale lastFrameOut),
|
|
597
|
+
// and a backpressure stall (buffered pinned high).
|
|
598
|
+
warn(
|
|
599
|
+
`heartbeat timeout: lastPong=${msAgo(lastPongAt, now)}, lastFrameIn=${msAgo(lastFrameReceivedAt, now)}, `
|
|
600
|
+
+ `lastFrameOut=${msAgo(lastFrameSentAt, now)}, lastPingSent=${msAgo(lastPingSentAt, now)}, `
|
|
601
|
+
+ `buffered=${ws.bufferedAmount}, bulkQueued=${bulkQueuedBytes}`,
|
|
602
|
+
);
|
|
400
603
|
terminateSocket('heartbeat timeout');
|
|
401
604
|
}
|
|
402
605
|
}, WATCHDOG_INTERVAL_MS);
|
|
@@ -440,13 +643,12 @@ function createEventTunnel({ record, foreground = false }) {
|
|
|
440
643
|
function handleRequest(frame) {
|
|
441
644
|
const targetPort = resolveTargetPort(frame);
|
|
442
645
|
if (!targetPort) {
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
});
|
|
646
|
+
sendResponse(
|
|
647
|
+
frame.req_id,
|
|
648
|
+
403,
|
|
649
|
+
{ 'content-type': 'application/json' },
|
|
650
|
+
Buffer.from(JSON.stringify({ error: 'Target port is not allowed' })),
|
|
651
|
+
);
|
|
450
652
|
return;
|
|
451
653
|
}
|
|
452
654
|
const body = frame.body_b64 ? Buffer.from(frame.body_b64, 'base64') : Buffer.alloc(0);
|
|
@@ -462,25 +664,18 @@ function createEventTunnel({ record, foreground = false }) {
|
|
|
462
664
|
const chunks = [];
|
|
463
665
|
res.on('data', (chunk) => chunks.push(Buffer.from(chunk)));
|
|
464
666
|
res.on('end', () => {
|
|
465
|
-
|
|
466
|
-
type: 'res',
|
|
467
|
-
req_id: frame.req_id,
|
|
468
|
-
status: res.statusCode || 502,
|
|
469
|
-
headers: responseHeaders(res.headers),
|
|
470
|
-
body_b64: Buffer.concat(chunks).toString('base64'),
|
|
471
|
-
});
|
|
667
|
+
sendResponse(frame.req_id, res.statusCode || 502, responseHeaders(res.headers), Buffer.concat(chunks));
|
|
472
668
|
});
|
|
473
669
|
},
|
|
474
670
|
);
|
|
475
671
|
|
|
476
672
|
req.on('error', (error) => {
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
});
|
|
673
|
+
sendResponse(
|
|
674
|
+
frame.req_id,
|
|
675
|
+
502,
|
|
676
|
+
{ 'content-type': 'text/plain; charset=utf-8' },
|
|
677
|
+
Buffer.from(error.message || 'Local request failed'),
|
|
678
|
+
);
|
|
484
679
|
});
|
|
485
680
|
|
|
486
681
|
req.end(body);
|
|
@@ -596,6 +791,10 @@ function createEventTunnel({ record, foreground = false }) {
|
|
|
596
791
|
|
|
597
792
|
function handleFrame(frame) {
|
|
598
793
|
if (!frame || typeof frame.type !== 'string') return;
|
|
794
|
+
if (frame.type === 'hello_ack') {
|
|
795
|
+
peerProtocolVersion = Number(frame.protocol_version) || 1;
|
|
796
|
+
return;
|
|
797
|
+
}
|
|
599
798
|
if (frame.type === 'ping') {
|
|
600
799
|
send({ type: 'pong' });
|
|
601
800
|
return;
|
|
@@ -639,19 +838,20 @@ function createEventTunnel({ record, foreground = false }) {
|
|
|
639
838
|
startWatchdog();
|
|
640
839
|
|
|
641
840
|
ws.on('open', () => {
|
|
642
|
-
backoffMs
|
|
841
|
+
// Deliberately NOT resetting backoffMs here: it resets in the close
|
|
842
|
+
// handler only once the connection has survived stableResetMs. An
|
|
843
|
+
// instant reset on open let two live runtimes ping-pong one gateway
|
|
844
|
+
// slot at ~1s cadence forever.
|
|
643
845
|
connectStartedAt = 0;
|
|
644
|
-
|
|
645
|
-
|
|
846
|
+
connectedAt = Date.now();
|
|
847
|
+
lastGatewayFrameAt = connectedAt;
|
|
848
|
+
lastPingSentAt = 0;
|
|
849
|
+
lastPongAt = 0;
|
|
850
|
+
lastFrameReceivedAt = 0;
|
|
851
|
+
lastFrameSentAt = 0;
|
|
852
|
+
log(`connected ${record.computer_id} instance=${INSTANCE_ID}`);
|
|
646
853
|
void postPresence(record, true);
|
|
647
|
-
|
|
648
|
-
send({
|
|
649
|
-
type: 'hello',
|
|
650
|
-
app_version: require('../package.json').version,
|
|
651
|
-
runtime_gateway_port: runtimeGatewayPort(),
|
|
652
|
-
app_routes: appRoutes,
|
|
653
|
-
artifact_routes: legacyArtifactRoutes(appRoutes),
|
|
654
|
-
});
|
|
854
|
+
send(helloFrame());
|
|
655
855
|
if (!routeTimer) {
|
|
656
856
|
routeTimer = setInterval(advertiseApps, 30000);
|
|
657
857
|
if (typeof routeTimer.unref === 'function') routeTimer.unref();
|
|
@@ -659,9 +859,11 @@ function createEventTunnel({ record, foreground = false }) {
|
|
|
659
859
|
});
|
|
660
860
|
ws.on('pong', () => {
|
|
661
861
|
lastGatewayFrameAt = Date.now();
|
|
862
|
+
lastPongAt = lastGatewayFrameAt;
|
|
662
863
|
});
|
|
663
864
|
ws.on('message', (data) => {
|
|
664
865
|
lastGatewayFrameAt = Date.now();
|
|
866
|
+
lastFrameReceivedAt = lastGatewayFrameAt;
|
|
665
867
|
try {
|
|
666
868
|
handleFrame(JSON.parse(data.toString()));
|
|
667
869
|
} catch (error) {
|
|
@@ -675,14 +877,30 @@ function createEventTunnel({ record, foreground = false }) {
|
|
|
675
877
|
}
|
|
676
878
|
});
|
|
677
879
|
ws.on('close', (code, reason) => {
|
|
880
|
+
const lifetimeMs = connectedAt ? Date.now() - connectedAt : 0;
|
|
678
881
|
connectStartedAt = 0;
|
|
882
|
+
connectedAt = 0;
|
|
679
883
|
lastGatewayFrameAt = 0;
|
|
680
|
-
|
|
681
|
-
//
|
|
682
|
-
//
|
|
683
|
-
//
|
|
884
|
+
peerProtocolVersion = 1;
|
|
885
|
+
// Streams, sockets, and queued response frames belong to THIS gateway
|
|
886
|
+
// connection: their req/conn ids mean nothing to the next one, and its
|
|
887
|
+
// cancel frames will never reference them. Dropping them here is the
|
|
888
|
+
// only teardown they get — orphaned streams otherwise hold local
|
|
889
|
+
// connections open forever.
|
|
890
|
+
resetBulkQueue('event tunnel connection closed');
|
|
684
891
|
dropActiveProxies('event tunnel connection closed');
|
|
685
|
-
warn(`closed code=${code} reason=${reason ? reason.toString() : ''}`);
|
|
892
|
+
warn(`closed code=${code} reason=${reason ? reason.toString() : ''} lifetime=${lifetimeMs}ms`);
|
|
893
|
+
// Backoff resets only after a connection proved stable; a short-lived
|
|
894
|
+
// connection keeps (and grows) the previous backoff so contention
|
|
895
|
+
// degrades to slow retries instead of a 1s war.
|
|
896
|
+
if (lifetimeMs >= stableResetMs) backoffMs = 1000;
|
|
897
|
+
if (code === SUPERSEDED_CLOSE_CODE) {
|
|
898
|
+
warn(
|
|
899
|
+
`superseded: gateway refused this instance (a newer runtime instance holds the slot). `
|
|
900
|
+
+ `own instance=${INSTANCE_ID} boot_ts=${BOOT_TS}; retrying in ${supersededRetryMs}ms`,
|
|
901
|
+
);
|
|
902
|
+
backoffMs = supersededRetryMs;
|
|
903
|
+
}
|
|
686
904
|
scheduleReconnect();
|
|
687
905
|
});
|
|
688
906
|
ws.on('error', (error) => {
|
|
@@ -716,6 +934,7 @@ function createEventTunnel({ record, foreground = false }) {
|
|
|
716
934
|
if (reconnectTimer) clearTimeout(reconnectTimer);
|
|
717
935
|
if (routeTimer) clearInterval(routeTimer);
|
|
718
936
|
stopWatchdog();
|
|
937
|
+
resetBulkQueue('event tunnel stopped');
|
|
719
938
|
dropActiveProxies('event tunnel stopped');
|
|
720
939
|
void postPresence(record, false);
|
|
721
940
|
if (ws) {
|
package/package.json
CHANGED
|
@@ -48,6 +48,10 @@ async function handleStateRoutes(ctx) {
|
|
|
48
48
|
await stateRest.handleDocUpdate(await ctx.readJsonBody(), ctx.sendJson);
|
|
49
49
|
return true;
|
|
50
50
|
}
|
|
51
|
+
if (ctx.pathname === '/state/docs/write') {
|
|
52
|
+
await stateRest.handleDocWrite(await ctx.readJsonBody(), ctx.sendJson);
|
|
53
|
+
return true;
|
|
54
|
+
}
|
|
51
55
|
return false;
|
|
52
56
|
}
|
|
53
57
|
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
* them into the in-memory registry on first use.
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
|
-
const { cachedStatement, openLocalDb } = require('./db');
|
|
16
|
+
const { cachedStatement, ensureAppResourcesSchema, openLocalDb } = require('./db');
|
|
17
17
|
const registry = require('./registry');
|
|
18
18
|
const { insertStateEvent, publishStateEvent } = require('./events');
|
|
19
19
|
|
|
@@ -23,25 +23,6 @@ const VALID_OPS = new Set(['insert', 'update', 'delete', 'replace']);
|
|
|
23
23
|
|
|
24
24
|
let loaded = false;
|
|
25
25
|
|
|
26
|
-
function ensureSchema(database) {
|
|
27
|
-
database.exec(`
|
|
28
|
-
CREATE TABLE IF NOT EXISTS app_resources (
|
|
29
|
-
name TEXT PRIMARY KEY,
|
|
30
|
-
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
|
|
31
|
-
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
|
|
32
|
-
);
|
|
33
|
-
|
|
34
|
-
CREATE TABLE IF NOT EXISTS app_resource_rows (
|
|
35
|
-
resource TEXT NOT NULL,
|
|
36
|
-
id TEXT NOT NULL,
|
|
37
|
-
value_json TEXT NOT NULL,
|
|
38
|
-
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
|
|
39
|
-
PRIMARY KEY (resource, id),
|
|
40
|
-
FOREIGN KEY (resource) REFERENCES app_resources(name) ON DELETE CASCADE
|
|
41
|
-
);
|
|
42
|
-
`);
|
|
43
|
-
}
|
|
44
|
-
|
|
45
26
|
function isValidAppResourceName(name) {
|
|
46
27
|
return typeof name === 'string' && APP_RESOURCE_NAME_RE.test(name);
|
|
47
28
|
}
|
|
@@ -71,7 +52,7 @@ function wireIntoRegistry(name) {
|
|
|
71
52
|
function ensureAppResourcesLoaded() {
|
|
72
53
|
if (loaded) return;
|
|
73
54
|
const database = openLocalDb();
|
|
74
|
-
|
|
55
|
+
ensureAppResourcesSchema(database);
|
|
75
56
|
const names = database.prepare('SELECT name FROM app_resources ORDER BY name ASC').all();
|
|
76
57
|
for (const { name } of names) wireIntoRegistry(name);
|
|
77
58
|
loaded = true;
|
|
@@ -469,6 +469,54 @@ function migrate(database = openLocalDb()) {
|
|
|
469
469
|
require('./migrations').runLegacyMigrations(database);
|
|
470
470
|
}
|
|
471
471
|
|
|
472
|
+
// ---------------------------------------------------------------------------
|
|
473
|
+
// Lazily created tables. The DDL lives here so this file owns every schema
|
|
474
|
+
// statement, but these tables are only created when their feature is first
|
|
475
|
+
// touched — each ensure below is called from that first-use site (docs.js,
|
|
476
|
+
// app-resources.js), preserving the original lazy-creation semantics.
|
|
477
|
+
|
|
478
|
+
// Live documents (state/docs.js): persisted Y.Doc state per open file.
|
|
479
|
+
function ensureDocStatesSchema(database = openLocalDb()) {
|
|
480
|
+
database.exec(`
|
|
481
|
+
CREATE TABLE IF NOT EXISTS doc_states (
|
|
482
|
+
path TEXT PRIMARY KEY,
|
|
483
|
+
state BLOB NOT NULL,
|
|
484
|
+
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
|
|
485
|
+
);
|
|
486
|
+
`);
|
|
487
|
+
// last_disk_hash records the disk content as we last read or wrote it.
|
|
488
|
+
// On reopen it distinguishes "disk changed externally" (fold it in) from
|
|
489
|
+
// "disk is merely behind our persisted state" (write the doc back out) —
|
|
490
|
+
// without it, a restart would revert edits that hadn't hit the debounced
|
|
491
|
+
// disk write yet.
|
|
492
|
+
try {
|
|
493
|
+
database.exec('ALTER TABLE doc_states ADD COLUMN last_disk_hash TEXT');
|
|
494
|
+
} catch {
|
|
495
|
+
// Column already exists.
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
// App-registered resources (state/app-resources.js): the engine-side mirror
|
|
500
|
+
// of rows that out-of-process apps push over the local-live rails.
|
|
501
|
+
function ensureAppResourcesSchema(database = openLocalDb()) {
|
|
502
|
+
database.exec(`
|
|
503
|
+
CREATE TABLE IF NOT EXISTS app_resources (
|
|
504
|
+
name TEXT PRIMARY KEY,
|
|
505
|
+
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
|
|
506
|
+
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
|
|
507
|
+
);
|
|
508
|
+
|
|
509
|
+
CREATE TABLE IF NOT EXISTS app_resource_rows (
|
|
510
|
+
resource TEXT NOT NULL,
|
|
511
|
+
id TEXT NOT NULL,
|
|
512
|
+
value_json TEXT NOT NULL,
|
|
513
|
+
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
|
|
514
|
+
PRIMARY KEY (resource, id),
|
|
515
|
+
FOREIGN KEY (resource) REFERENCES app_resources(name) ON DELETE CASCADE
|
|
516
|
+
);
|
|
517
|
+
`);
|
|
518
|
+
}
|
|
519
|
+
|
|
472
520
|
function closeLocalDb() {
|
|
473
521
|
if (!db) return;
|
|
474
522
|
db.close();
|
|
@@ -478,5 +526,7 @@ function closeLocalDb() {
|
|
|
478
526
|
module.exports = {
|
|
479
527
|
cachedStatement,
|
|
480
528
|
closeLocalDb,
|
|
529
|
+
ensureAppResourcesSchema,
|
|
530
|
+
ensureDocStatesSchema,
|
|
481
531
|
openLocalDb,
|
|
482
532
|
};
|