amalgm 0.1.150 → 0.1.151
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 +25 -16
- package/package.json +1 -1
- package/runtime/scripts/amalgm-mcp/state/docs.js +74 -11
- package/runtime/scripts/amalgm-mcp/state/rest.js +43 -10
- package/runtime/scripts/amalgm-mcp/tests/state-docs.test.js +57 -0
- package/runtime/scripts/amalgm-mcp/tests/state-stream.test.js +100 -0
- package/runtime/scripts/local-gateway.js +13 -0
package/lib/tunnel-events.js
CHANGED
|
@@ -667,6 +667,11 @@ function createEventTunnel({ record, foreground = false }) {
|
|
|
667
667
|
ws.on('close', (code, reason) => {
|
|
668
668
|
connectStartedAt = 0;
|
|
669
669
|
lastGatewayFrameAt = 0;
|
|
670
|
+
// Streams and sockets belong to THIS gateway connection: their req/conn
|
|
671
|
+
// ids mean nothing to the next one, and its cancel frames will never
|
|
672
|
+
// reference them. Dropping them here is the only teardown they get —
|
|
673
|
+
// orphaned streams otherwise hold local connections open forever.
|
|
674
|
+
dropActiveProxies('event tunnel connection closed');
|
|
670
675
|
warn(`closed code=${code} reason=${reason ? reason.toString() : ''}`);
|
|
671
676
|
scheduleReconnect();
|
|
672
677
|
});
|
|
@@ -675,6 +680,25 @@ function createEventTunnel({ record, foreground = false }) {
|
|
|
675
680
|
});
|
|
676
681
|
}
|
|
677
682
|
|
|
683
|
+
function dropActiveProxies(reason) {
|
|
684
|
+
for (const upstream of upstreamSockets.values()) {
|
|
685
|
+
try {
|
|
686
|
+
upstream.close();
|
|
687
|
+
} catch {
|
|
688
|
+
// noop
|
|
689
|
+
}
|
|
690
|
+
}
|
|
691
|
+
upstreamSockets.clear();
|
|
692
|
+
for (const stream of upstreamStreams.values()) {
|
|
693
|
+
try {
|
|
694
|
+
stream.destroy(new Error(reason));
|
|
695
|
+
} catch {
|
|
696
|
+
// noop
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
upstreamStreams.clear();
|
|
700
|
+
}
|
|
701
|
+
|
|
678
702
|
return {
|
|
679
703
|
start: connect,
|
|
680
704
|
stop() {
|
|
@@ -682,22 +706,7 @@ function createEventTunnel({ record, foreground = false }) {
|
|
|
682
706
|
if (reconnectTimer) clearTimeout(reconnectTimer);
|
|
683
707
|
if (routeTimer) clearInterval(routeTimer);
|
|
684
708
|
stopWatchdog();
|
|
685
|
-
|
|
686
|
-
try {
|
|
687
|
-
upstream.close();
|
|
688
|
-
} catch {
|
|
689
|
-
// noop
|
|
690
|
-
}
|
|
691
|
-
}
|
|
692
|
-
upstreamSockets.clear();
|
|
693
|
-
for (const stream of upstreamStreams.values()) {
|
|
694
|
-
try {
|
|
695
|
-
stream.destroy(new Error('event tunnel stopped'));
|
|
696
|
-
} catch {
|
|
697
|
-
// noop
|
|
698
|
-
}
|
|
699
|
-
}
|
|
700
|
-
upstreamStreams.clear();
|
|
709
|
+
dropActiveProxies('event tunnel stopped');
|
|
701
710
|
void postPresence(record, false);
|
|
702
711
|
if (ws) {
|
|
703
712
|
try {
|
package/package.json
CHANGED
|
@@ -30,6 +30,7 @@
|
|
|
30
30
|
* stale watcher events can never splice, so they can never re-emit.
|
|
31
31
|
*/
|
|
32
32
|
|
|
33
|
+
const crypto = require('crypto');
|
|
33
34
|
const fs = require('fs');
|
|
34
35
|
const path = require('path');
|
|
35
36
|
const { openLocalDb } = require('./db');
|
|
@@ -75,6 +76,20 @@ function ensureSchema(database) {
|
|
|
75
76
|
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
|
|
76
77
|
);
|
|
77
78
|
`);
|
|
79
|
+
// last_disk_hash records the disk content as we last read or wrote it.
|
|
80
|
+
// On reopen it distinguishes "disk changed externally" (fold it in) from
|
|
81
|
+
// "disk is merely behind our persisted state" (write the doc back out) —
|
|
82
|
+
// without it, a restart would revert edits that hadn't hit the debounced
|
|
83
|
+
// disk write yet.
|
|
84
|
+
try {
|
|
85
|
+
database.exec('ALTER TABLE doc_states ADD COLUMN last_disk_hash TEXT');
|
|
86
|
+
} catch {
|
|
87
|
+
// Column already exists.
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function hashText(text) {
|
|
92
|
+
return crypto.createHash('sha256').update(text, 'utf8').digest('hex');
|
|
78
93
|
}
|
|
79
94
|
|
|
80
95
|
function docResourceName(absolutePath) {
|
|
@@ -143,12 +158,17 @@ function persistDocState(Y, entry) {
|
|
|
143
158
|
const database = openLocalDb();
|
|
144
159
|
ensureSchema(database);
|
|
145
160
|
database.prepare(`
|
|
146
|
-
INSERT INTO doc_states (path, state, updated_at)
|
|
147
|
-
VALUES (?, ?, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
|
|
161
|
+
INSERT INTO doc_states (path, state, updated_at, last_disk_hash)
|
|
162
|
+
VALUES (?, ?, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'), ?)
|
|
148
163
|
ON CONFLICT(path) DO UPDATE SET
|
|
149
164
|
state = excluded.state,
|
|
150
|
-
updated_at = excluded.updated_at
|
|
151
|
-
|
|
165
|
+
updated_at = excluded.updated_at,
|
|
166
|
+
last_disk_hash = excluded.last_disk_hash
|
|
167
|
+
`).run(
|
|
168
|
+
entry.path,
|
|
169
|
+
Buffer.from(Y.encodeStateAsUpdate(entry.doc)),
|
|
170
|
+
entry.lastDiskText == null ? null : hashText(entry.lastDiskText),
|
|
171
|
+
);
|
|
152
172
|
}
|
|
153
173
|
|
|
154
174
|
function scheduleDiskWrite(Y, entry) {
|
|
@@ -183,13 +203,49 @@ function reconcileFromDisk(Y, entry) {
|
|
|
183
203
|
entry.lastDiskText = onDisk;
|
|
184
204
|
const ytext = entry.doc.getText(TEXT_KEY);
|
|
185
205
|
// origin 'disk' keeps the update handler from writing the same bytes back.
|
|
186
|
-
spliceTextInto(Y, entry.doc, ytext, onDisk, 'disk');
|
|
206
|
+
const changed = spliceTextInto(Y, entry.doc, ytext, onDisk, 'disk');
|
|
207
|
+
// The splice's update handler persists; a no-op splice still needs the
|
|
208
|
+
// refreshed lastDiskText hash recorded.
|
|
209
|
+
if (!changed) persistDocState(Y, entry);
|
|
187
210
|
} catch (error) {
|
|
188
211
|
// File deleted or unreadable — the doc simply stops following the disk.
|
|
189
212
|
console.warn(`[LiveDoc] Reconcile from disk failed for "${entry.path}":`, error?.message || error);
|
|
190
213
|
}
|
|
191
214
|
}
|
|
192
215
|
|
|
216
|
+
/**
|
|
217
|
+
* First look at the disk after (re)opening a doc. Three cases:
|
|
218
|
+
* - no persisted history → the disk seeds the doc (first ever open);
|
|
219
|
+
* - disk matches the hash we recorded before dying → disk is unchanged;
|
|
220
|
+
* if the persisted doc is ahead (edits inside the unflushed debounce
|
|
221
|
+
* window), write the doc back out instead of splicing stale disk text
|
|
222
|
+
* over those edits;
|
|
223
|
+
* - disk differs from the recorded hash → a genuine external edit while we
|
|
224
|
+
* were closed; fold it in like any other disk change.
|
|
225
|
+
*/
|
|
226
|
+
function seedFromDisk(Y, entry, persisted) {
|
|
227
|
+
const knownHash = persisted?.last_disk_hash;
|
|
228
|
+
if (!knownHash) {
|
|
229
|
+
reconcileFromDisk(Y, entry);
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
232
|
+
let onDisk = null;
|
|
233
|
+
try {
|
|
234
|
+
onDisk = fs.readFileSync(entry.path, 'utf8');
|
|
235
|
+
} catch {
|
|
236
|
+
// Unreadable/deleted: same posture as reconcile — stop following disk.
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
if (hashText(onDisk) !== knownHash) {
|
|
240
|
+
reconcileFromDisk(Y, entry);
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
entry.lastDiskText = onDisk;
|
|
244
|
+
if (entry.doc.getText(TEXT_KEY).toString() !== onDisk) {
|
|
245
|
+
scheduleDiskWrite(Y, entry);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
193
249
|
function watchDocFile(Y, entry) {
|
|
194
250
|
const dir = path.dirname(entry.path);
|
|
195
251
|
const base = path.basename(entry.path);
|
|
@@ -270,17 +326,23 @@ function loadDocEntry(pathInput) {
|
|
|
270
326
|
lastDiskText: null,
|
|
271
327
|
};
|
|
272
328
|
|
|
273
|
-
|
|
329
|
+
let persisted = database.prepare('SELECT state, last_disk_hash FROM doc_states WHERE path = ?').get(resolved) || null;
|
|
274
330
|
if (persisted?.state) {
|
|
275
331
|
try {
|
|
276
332
|
Y.applyUpdate(doc, new Uint8Array(persisted.state), 'persisted');
|
|
277
333
|
} catch (error) {
|
|
278
334
|
console.warn(`[LiveDoc] Discarding corrupt persisted state for "${resolved}":`, error?.message || error);
|
|
335
|
+
// The hash goes with it: without the state, "disk unchanged" would
|
|
336
|
+
// mean writing an empty doc over the file.
|
|
337
|
+
persisted = null;
|
|
279
338
|
}
|
|
280
339
|
}
|
|
281
340
|
|
|
282
341
|
// Every applied change — client update, disk reconcile, or seed — flows
|
|
283
|
-
// through this one pipeline: event out, then (except
|
|
342
|
+
// through this one pipeline: event out, state persisted, then (except
|
|
343
|
+
// disk-origin) disk out. Persisting on every update means a kill — even
|
|
344
|
+
// SIGKILL — can never lose an acknowledged edit; at worst the disk artifact
|
|
345
|
+
// lags by the debounce window until the doc is next opened.
|
|
284
346
|
doc.on('update', (update, origin) => {
|
|
285
347
|
appendStateEvent({
|
|
286
348
|
resource: entry.resource,
|
|
@@ -289,13 +351,14 @@ function loadDocEntry(pathInput) {
|
|
|
289
351
|
patch: { yjs: Buffer.from(update).toString('base64') },
|
|
290
352
|
source: origin === 'disk' ? 'doc:disk' : 'doc:update',
|
|
291
353
|
});
|
|
354
|
+
persistDocState(Y, entry);
|
|
292
355
|
if (origin !== 'disk') scheduleDiskWrite(Y, entry);
|
|
293
|
-
else persistDocState(Y, entry);
|
|
294
356
|
});
|
|
295
357
|
|
|
296
|
-
//
|
|
297
|
-
//
|
|
298
|
-
|
|
358
|
+
// Disk at open: an externally changed file folds in as an edit, but a file
|
|
359
|
+
// that merely lags our persisted state (the process died inside the disk
|
|
360
|
+
// write debounce) gets the doc written back out — never spliced over it.
|
|
361
|
+
seedFromDisk(Y, entry, persisted);
|
|
299
362
|
|
|
300
363
|
openDocs.set(resolved, entry);
|
|
301
364
|
watchDocFile(Y, entry);
|
|
@@ -145,24 +145,57 @@ function handleStream(req, res, query) {
|
|
|
145
145
|
return;
|
|
146
146
|
}
|
|
147
147
|
|
|
148
|
-
|
|
149
|
-
|
|
148
|
+
// One teardown for every way the stream can die. Idempotent because close
|
|
149
|
+
// and error can both fire, and a failed write calls it directly: a stream
|
|
150
|
+
// whose socket is gone must release its subscriber immediately — waiting
|
|
151
|
+
// on a 'close' that never fires is how dead streams pile up.
|
|
152
|
+
let heartbeat = null;
|
|
153
|
+
let unsubscribe = null;
|
|
154
|
+
let tornDown = false;
|
|
155
|
+
const teardown = () => {
|
|
156
|
+
if (tornDown) return;
|
|
157
|
+
tornDown = true;
|
|
158
|
+
if (heartbeat) clearInterval(heartbeat);
|
|
159
|
+
if (unsubscribe) unsubscribe();
|
|
160
|
+
try {
|
|
161
|
+
res.end();
|
|
162
|
+
} catch {
|
|
163
|
+
// Socket already gone.
|
|
164
|
+
}
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
const writeSafe = (send) => {
|
|
168
|
+
if (tornDown) return;
|
|
169
|
+
if (res.destroyed || res.writableEnded) {
|
|
170
|
+
teardown();
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
try {
|
|
174
|
+
send();
|
|
175
|
+
} catch {
|
|
176
|
+
teardown();
|
|
177
|
+
}
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
unsubscribe = subscribeStateEvents((event) => {
|
|
181
|
+
if (event.seq > after) writeSafe(() => sendSseEvent(res, event));
|
|
150
182
|
});
|
|
151
183
|
|
|
152
184
|
for (const event of backlog) {
|
|
153
|
-
sendSseEvent(res, event);
|
|
185
|
+
writeSafe(() => sendSseEvent(res, event));
|
|
154
186
|
}
|
|
155
187
|
|
|
156
188
|
// A named event (not an SSE comment) so EventSource clients can observe it
|
|
157
|
-
// and detect a silently dead stream, e.g. tunnel frame drops.
|
|
158
|
-
|
|
159
|
-
|
|
189
|
+
// and detect a silently dead stream, e.g. tunnel frame drops. It doubles as
|
|
190
|
+
// the server-side liveness probe: writing to a dead socket tears down.
|
|
191
|
+
heartbeat = setInterval(() => {
|
|
192
|
+
writeSafe(() => sendSseControl(res, 'ping', { t: Date.now() }));
|
|
160
193
|
}, 25_000);
|
|
161
194
|
|
|
162
|
-
req.on('close',
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
195
|
+
req.on('close', teardown);
|
|
196
|
+
req.on('error', teardown);
|
|
197
|
+
res.on('close', teardown);
|
|
198
|
+
res.on('error', teardown);
|
|
166
199
|
}
|
|
167
200
|
|
|
168
201
|
module.exports = {
|
|
@@ -191,3 +191,60 @@ test('guards: binary, missing, oversized, and malformed inputs are rejected', ()
|
|
|
191
191
|
assert.throws(() => docs.applyDocUpdates(target, [123]), /base64-encoded/);
|
|
192
192
|
assert.throws(() => docs.applyDocUpdates(target, ['aGVsbG8=']), /invalid Yjs update/);
|
|
193
193
|
});
|
|
194
|
+
|
|
195
|
+
// ---------------------------------------------------------------------------
|
|
196
|
+
// Durability: a crash (SIGKILL — no flush, no graceful close) must never lose
|
|
197
|
+
// an acknowledged edit. State is persisted on every update; the recorded
|
|
198
|
+
// last-disk hash lets reopen tell "disk is merely behind" (write the doc back
|
|
199
|
+
// out) from "disk changed externally" (fold it in).
|
|
200
|
+
// ---------------------------------------------------------------------------
|
|
201
|
+
|
|
202
|
+
function crashAndReloadDocs() {
|
|
203
|
+
// Simulate SIGKILL: drop the module (and its open docs, timers untouched)
|
|
204
|
+
// without any flush. The reloaded module sees only what was persisted.
|
|
205
|
+
delete require.cache[require.resolve('../state/docs')];
|
|
206
|
+
return require('../state/docs');
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
test('a kill inside the disk-write debounce loses nothing: reopen restores the edit', async () => {
|
|
210
|
+
const target = writeDoc('crash.md', 'before the crash\n');
|
|
211
|
+
const opened = docs.openDoc(target);
|
|
212
|
+
const client = clientFromState(opened.state);
|
|
213
|
+
const update = encodeLocalEdit(client, (ytext) => ytext.insert(0, 'acknowledged edit / '));
|
|
214
|
+
docs.applyDocUpdates(target, [update]);
|
|
215
|
+
|
|
216
|
+
// Crash immediately — the 300ms disk write has NOT run; disk is stale.
|
|
217
|
+
assert.equal(fs.readFileSync(target, 'utf8'), 'before the crash\n');
|
|
218
|
+
const reloaded = crashAndReloadDocs();
|
|
219
|
+
|
|
220
|
+
const reopened = reloaded.openDoc(target);
|
|
221
|
+
const fresh = clientFromState(reopened.state);
|
|
222
|
+
assert.equal(
|
|
223
|
+
fresh.getText('content').toString(),
|
|
224
|
+
'acknowledged edit / before the crash\n',
|
|
225
|
+
'persisted state must win over a disk that is merely behind',
|
|
226
|
+
);
|
|
227
|
+
|
|
228
|
+
// The stale disk artifact catches up via the rescheduled write-back.
|
|
229
|
+
await sleep(500);
|
|
230
|
+
assert.equal(fs.readFileSync(target, 'utf8'), 'acknowledged edit / before the crash\n');
|
|
231
|
+
reloaded.closeAllDocs();
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
test('an external edit made while closed still folds in on reopen', () => {
|
|
235
|
+
const target = writeDoc('closed-edit.md', 'line one\n');
|
|
236
|
+
const opened = docs.openDoc(target);
|
|
237
|
+
const client = clientFromState(opened.state);
|
|
238
|
+
docs.applyDocUpdates(target, [encodeLocalEdit(client, (ytext) => ytext.insert(0, 'mine: '))]);
|
|
239
|
+
docs.closeAllDocs(); // graceful: disk now has "mine: line one\n"
|
|
240
|
+
|
|
241
|
+
fs.writeFileSync(target, 'mine: line one\nagent line\n', 'utf8');
|
|
242
|
+
|
|
243
|
+
const reopened = docs.openDoc(target);
|
|
244
|
+
const fresh = clientFromState(reopened.state);
|
|
245
|
+
assert.equal(
|
|
246
|
+
fresh.getText('content').toString(),
|
|
247
|
+
'mine: line one\nagent line\n',
|
|
248
|
+
'a genuinely changed disk is folded in, not overwritten',
|
|
249
|
+
);
|
|
250
|
+
});
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const test = require('node:test');
|
|
4
|
+
const assert = require('node:assert/strict');
|
|
5
|
+
const fs = require('fs');
|
|
6
|
+
const os = require('os');
|
|
7
|
+
const path = require('path');
|
|
8
|
+
const { EventEmitter } = require('events');
|
|
9
|
+
|
|
10
|
+
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'amalgm-stream-test-'));
|
|
11
|
+
process.env.AMALGM_DIR = path.join(tempRoot, '.amalgm');
|
|
12
|
+
|
|
13
|
+
const { closeLocalDb } = require('../state/db');
|
|
14
|
+
const { appendStateEvent } = require('../state/events');
|
|
15
|
+
const rest = require('../state/rest');
|
|
16
|
+
|
|
17
|
+
test.after(() => {
|
|
18
|
+
closeLocalDb();
|
|
19
|
+
fs.rmSync(tempRoot, { recursive: true, force: true });
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
function fakeRequest() {
|
|
23
|
+
return new EventEmitter();
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* A response whose socket "dies" after `failAfterWrites` writes — every write
|
|
28
|
+
* past that throws, like Node does once the peer is gone and the stream is
|
|
29
|
+
* destroyed.
|
|
30
|
+
*/
|
|
31
|
+
function fakeResponse({ failAfterWrites = Infinity } = {}) {
|
|
32
|
+
const res = new EventEmitter();
|
|
33
|
+
res.writes = [];
|
|
34
|
+
res.destroyed = false;
|
|
35
|
+
res.writableEnded = false;
|
|
36
|
+
res.writeHead = () => res;
|
|
37
|
+
res.write = (chunk) => {
|
|
38
|
+
if (res.writes.length >= failAfterWrites) {
|
|
39
|
+
res.destroyed = true;
|
|
40
|
+
throw new Error('write after socket gone');
|
|
41
|
+
}
|
|
42
|
+
res.writes.push(String(chunk));
|
|
43
|
+
return true;
|
|
44
|
+
};
|
|
45
|
+
res.end = () => {
|
|
46
|
+
res.writableEnded = true;
|
|
47
|
+
};
|
|
48
|
+
return res;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
test('a live subscriber receives events; close releases it', () => {
|
|
52
|
+
const req = fakeRequest();
|
|
53
|
+
const res = fakeResponse();
|
|
54
|
+
rest.handleStream(req, res, {});
|
|
55
|
+
|
|
56
|
+
appendStateEvent({ resource: 'automations', op: 'replace', value: [], source: 'test' });
|
|
57
|
+
const during = res.writes.filter((w) => w.includes('event: state')).length;
|
|
58
|
+
assert.ok(during >= 1, 'subscribed stream should receive the event');
|
|
59
|
+
|
|
60
|
+
req.emit('close');
|
|
61
|
+
appendStateEvent({ resource: 'automations', op: 'replace', value: [], source: 'test' });
|
|
62
|
+
const after = res.writes.filter((w) => w.includes('event: state')).length;
|
|
63
|
+
assert.equal(after, during, 'a closed stream must not receive further events');
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test('a write failure tears the stream down: no orphaned subscriber', () => {
|
|
67
|
+
const req = fakeRequest();
|
|
68
|
+
// Allow the SSE preamble through, then kill the socket.
|
|
69
|
+
const res = fakeResponse({ failAfterWrites: 1 });
|
|
70
|
+
rest.handleStream(req, res, {});
|
|
71
|
+
|
|
72
|
+
// First event write fails → teardown. The subscriber must be gone: further
|
|
73
|
+
// events must not attempt writes (attempts throw and would surface here as
|
|
74
|
+
// an unhandled exception from the emitter callback).
|
|
75
|
+
appendStateEvent({ resource: 'automations', op: 'replace', value: [], source: 'test' });
|
|
76
|
+
const attemptsAfterDeath = res.writes.length;
|
|
77
|
+
appendStateEvent({ resource: 'automations', op: 'replace', value: [], source: 'test' });
|
|
78
|
+
appendStateEvent({ resource: 'automations', op: 'replace', value: [], source: 'test' });
|
|
79
|
+
assert.equal(res.writes.length, attemptsAfterDeath, 'dead stream must stop writing');
|
|
80
|
+
assert.equal(res.writableEnded, true, 'teardown ends the response');
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test('close and error firing together tear down exactly once', () => {
|
|
84
|
+
const req = fakeRequest();
|
|
85
|
+
const res = fakeResponse();
|
|
86
|
+
rest.handleStream(req, res, {});
|
|
87
|
+
// Backlog replay from earlier tests lands before teardown; only NEW events
|
|
88
|
+
// after teardown matter.
|
|
89
|
+
const afterReplay = res.writes.filter((w) => w.includes('event: state')).length;
|
|
90
|
+
req.emit('close');
|
|
91
|
+
res.emit('close');
|
|
92
|
+
req.emit('error', new Error('boom'));
|
|
93
|
+
res.emit('error', new Error('boom'));
|
|
94
|
+
appendStateEvent({ resource: 'automations', op: 'replace', value: [], source: 'test' });
|
|
95
|
+
assert.equal(
|
|
96
|
+
res.writes.filter((w) => w.includes('event: state')).length,
|
|
97
|
+
afterReplay,
|
|
98
|
+
'no events after teardown',
|
|
99
|
+
);
|
|
100
|
+
});
|
|
@@ -723,6 +723,14 @@ async function proxyHttp(req, res, target) {
|
|
|
723
723
|
res.destroy(err);
|
|
724
724
|
});
|
|
725
725
|
|
|
726
|
+
// A dropped downstream (dead tunnel frame, closed tab) must take the
|
|
727
|
+
// upstream request with it: streaming responses heartbeat forever, so the
|
|
728
|
+
// inactivity timeout never fires and orphans would hold runtime
|
|
729
|
+
// connections open indefinitely.
|
|
730
|
+
res.on('close', () => {
|
|
731
|
+
if (!res.writableFinished) upstream.destroy();
|
|
732
|
+
});
|
|
733
|
+
|
|
726
734
|
upstream.end(body);
|
|
727
735
|
}
|
|
728
736
|
|
|
@@ -758,6 +766,11 @@ async function proxyExternalHttp(req, res, target) {
|
|
|
758
766
|
res.destroy(err);
|
|
759
767
|
});
|
|
760
768
|
|
|
769
|
+
// Same close propagation as proxyHttp: no downstream, no upstream.
|
|
770
|
+
res.on('close', () => {
|
|
771
|
+
if (!res.writableFinished) upstream.destroy();
|
|
772
|
+
});
|
|
773
|
+
|
|
761
774
|
upstream.end(body);
|
|
762
775
|
}
|
|
763
776
|
|