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.
@@ -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
- for (const upstream of upstreamSockets.values()) {
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "amalgm",
3
- "version": "0.1.150",
3
+ "version": "0.1.151",
4
4
  "description": "Amalgm local computer runtime: login, MCP, chat, events, previews, and tunnels.",
5
5
  "license": "UNLICENSED",
6
6
  "private": false,
@@ -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
- `).run(entry.path, Buffer.from(Y.encodeStateAsUpdate(entry.doc)));
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
- const persisted = database.prepare('SELECT state FROM doc_states WHERE path = ?').get(resolved);
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 disk-origin) disk out.
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
- // The disk is the source of truth at open: seeds new docs, and folds in
297
- // anything written while the doc was closed (agents, git, other tools).
298
- reconcileFromDisk(Y, entry);
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
- const unsubscribe = subscribeStateEvents((event) => {
149
- if (event.seq > after) sendSseEvent(res, event);
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
- const heartbeat = setInterval(() => {
159
- sendSseControl(res, 'ping', { t: Date.now() });
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
- clearInterval(heartbeat);
164
- unsubscribe();
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