amalgm 0.1.207 → 0.1.209

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.
@@ -17,6 +17,7 @@ const CLAIM_LIMIT = 16;
17
17
  const CLAIM_LEASE_MS = 30_000;
18
18
  const RETRY_MS = 1_000;
19
19
  const REAUTHORIZE_MS = 30_000;
20
+ const REPO_OBJECT_REQUEST_TIMEOUT_MS = 15_000;
20
21
 
21
22
  function createSharedRealtimeTunnel({
22
23
  send,
@@ -24,6 +25,7 @@ function createSharedRealtimeTunnel({
24
25
  localPort,
25
26
  localHeaders,
26
27
  warn,
28
+ repoObjectRequestTimeoutMs = REPO_OBJECT_REQUEST_TIMEOUT_MS,
27
29
  }) {
28
30
  const mutationInflight = new Map();
29
31
  const promotionInflight = new Map();
@@ -277,8 +279,17 @@ function createSharedRealtimeTunnel({
277
279
  repoObjectTimer.unref?.();
278
280
  }
279
281
 
282
+ function expireRepoObjectRequests(now = Date.now()) {
283
+ for (const inflight of [repoUploadInflight, repoDownloadInflight]) {
284
+ for (const [requestId, object] of inflight) {
285
+ if (now - object.sentAt >= repoObjectRequestTimeoutMs) inflight.delete(requestId);
286
+ }
287
+ }
288
+ }
289
+
280
290
  async function drainRepoObjects() {
281
291
  if (!enabled) return;
292
+ expireRepoObjectRequests();
282
293
  if (repoUploadInflight.size === 0) {
283
294
  const result = await localStateJson('POST', '/state/repo-objects/uploads/claim', {});
284
295
  const upload = result?.upload;
@@ -295,6 +306,7 @@ function createSharedRealtimeTunnel({
295
306
  repoUploadInflight.set(requestId, {
296
307
  resourceId: upload.resourceId,
297
308
  sha256: upload.sha256,
309
+ sentAt: Date.now(),
298
310
  });
299
311
  }
300
312
  }
@@ -313,6 +325,7 @@ function createSharedRealtimeTunnel({
313
325
  repoDownloadInflight.set(requestId, {
314
326
  resourceId: download.resourceId,
315
327
  sha256: download.sha256,
328
+ sentAt: Date.now(),
316
329
  });
317
330
  }
318
331
  }
@@ -462,35 +475,45 @@ function createSharedRealtimeTunnel({
462
475
  }
463
476
 
464
477
  function handleRepoObjectStored(frame) {
465
- const object = repoUploadInflight.get(frame.request_id);
478
+ const object = repoUploadInflight.get(frame.request_id)
479
+ || [...repoUploadInflight.values()].find((entry) => entry.sha256 === frame.sha256);
466
480
  if (!object || object.sha256 !== frame.sha256) return;
467
481
  void localStateJson('POST', '/state/repo-objects/uploads/ack', {
468
482
  resourceId: object.resourceId,
469
483
  sha256: object.sha256,
470
484
  })
471
485
  .then(() => {
472
- repoUploadInflight.delete(frame.request_id);
486
+ for (const [requestId, entry] of repoUploadInflight) {
487
+ if (entry.sha256 === object.sha256) repoUploadInflight.delete(requestId);
488
+ }
473
489
  scheduleRepoObjects();
474
490
  })
475
491
  .catch(() => {
476
- repoUploadInflight.delete(frame.request_id);
492
+ for (const [requestId, entry] of repoUploadInflight) {
493
+ if (entry.sha256 === object.sha256) repoUploadInflight.delete(requestId);
494
+ }
477
495
  scheduleRepoObjects(RETRY_MS);
478
496
  });
479
497
  }
480
498
 
481
499
  function handleRepoObjectData(frame) {
482
- const object = repoDownloadInflight.get(frame.request_id);
500
+ const object = repoDownloadInflight.get(frame.request_id)
501
+ || [...repoDownloadInflight.values()].find((entry) => entry.sha256 === frame.sha256);
483
502
  if (!object || object.sha256 !== frame.sha256) return;
484
503
  void localStateJson('POST', '/state/repo-objects/downloads/receive', {
485
504
  resourceId: object.resourceId,
486
505
  sha256: object.sha256,
487
506
  dataBase64: frame.data_b64,
488
507
  }).then(() => {
489
- repoDownloadInflight.delete(frame.request_id);
508
+ for (const [requestId, entry] of repoDownloadInflight) {
509
+ if (entry.sha256 === object.sha256) repoDownloadInflight.delete(requestId);
510
+ }
490
511
  scheduleRepoObjects();
491
512
  }).catch((error) => {
492
513
  warn(`repo object ${object.sha256} was not stored locally: ${error?.message || error}`);
493
- repoDownloadInflight.delete(frame.request_id);
514
+ for (const [requestId, entry] of repoDownloadInflight) {
515
+ if (entry.sha256 === object.sha256) repoDownloadInflight.delete(requestId);
516
+ }
494
517
  scheduleRepoObjects(RETRY_MS);
495
518
  });
496
519
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "amalgm",
3
- "version": "0.1.207",
3
+ "version": "0.1.209",
4
4
  "description": "Amalgm local computer runtime: login, MCP, chat, events, previews, and tunnels.",
5
5
  "license": "UNLICENSED",
6
6
  "private": false,
@@ -122,3 +122,70 @@ test('repo object chunks travel through the shared tunnel one at a time', async
122
122
  server.close();
123
123
  }
124
124
  });
125
+
126
+ test('repo object chunks retry when an acknowledgement is lost', async () => {
127
+ const uploadBytes = Buffer.from('retry this content-addressed chunk');
128
+ const uploadSha = crypto.createHash('sha256').update(uploadBytes).digest('hex');
129
+ const resourceId = 'shr_cp1abcdefghijklmnopqrstuvwx';
130
+ let acknowledgements = 0;
131
+ const server = http.createServer((req, res) => {
132
+ req.resume();
133
+ req.on('end', () => {
134
+ res.setHeader('content-type', 'application/json');
135
+ if (req.url === '/state/shared/replicas') {
136
+ res.end('{"replicas":[]}');
137
+ } else if (req.url.startsWith('/state/stream')) {
138
+ res.writeHead(200, { 'content-type': 'text/event-stream' });
139
+ res.write('\n');
140
+ } else if (req.url === '/state/repo-objects/uploads/claim') {
141
+ res.end(JSON.stringify({
142
+ upload: {
143
+ resourceId,
144
+ sha256: uploadSha,
145
+ bytes: uploadBytes.length,
146
+ dataBase64: uploadBytes.toString('base64'),
147
+ },
148
+ }));
149
+ } else if (req.url === '/state/repo-objects/downloads/claim') {
150
+ res.end('{"download":null}');
151
+ } else if (req.url === '/state/repo-objects/uploads/ack') {
152
+ acknowledgements += 1;
153
+ res.end('{"changed":true,"completed":true}');
154
+ } else {
155
+ res.end('{}');
156
+ }
157
+ });
158
+ });
159
+ await new Promise((resolve) => { server.listen(0, '127.0.0.1', resolve); });
160
+
161
+ const frames = [];
162
+ const tunnel = createSharedRealtimeTunnel({
163
+ send: (frame) => { frames.push(frame); return true; },
164
+ sendLarge: (frame) => { frames.push(frame); return true; },
165
+ localPort: () => server.address().port,
166
+ localHeaders: (headers) => headers,
167
+ warn: () => {},
168
+ repoObjectRequestTimeoutMs: 25,
169
+ });
170
+
171
+ try {
172
+ tunnel.enable(['shared.mutation.v1']);
173
+ await until(
174
+ () => frames.filter((frame) => frame.type === 'repo.object.put').length >= 2,
175
+ 'the unacknowledged upload retry',
176
+ );
177
+ const puts = frames.filter((frame) => frame.type === 'repo.object.put');
178
+ assert.notEqual(puts[0].request_id, puts[1].request_id);
179
+ assert.equal(puts[0].sha256, puts[1].sha256);
180
+ tunnel.handleFrame({
181
+ type: 'repo.object.stored',
182
+ request_id: puts[0].request_id,
183
+ sha256: uploadSha,
184
+ bytes: uploadBytes.length,
185
+ });
186
+ await until(() => acknowledgements === 1, 'the late upload acknowledgement');
187
+ } finally {
188
+ tunnel.stop();
189
+ server.close();
190
+ }
191
+ });
@@ -120,6 +120,52 @@ test('only a value different from both the materialized baseline and the rail is
120
120
  branches: [{ name: 'main', oid: oidC }],
121
121
  };
122
122
  assert.equal(hasLocalContribution(local, remoteAdvance, baseline), false);
123
+ assert.equal(hasLocalContribution(
124
+ {
125
+ head: { branch: 'main', oid: oidB, detached: false },
126
+ refs: [{ name: 'main', oid: oidB }],
127
+ },
128
+ remoteAdvance,
129
+ {
130
+ branch: 'main',
131
+ baseOid: oidB,
132
+ branches: [{ name: 'main', oid: oidB }],
133
+ },
134
+ null,
135
+ {
136
+ head: { branch: 'main', oid: oidA, detached: false },
137
+ branches: [{ name: 'main', oid: oidA }],
138
+ },
139
+ ), false);
140
+ assert.equal(hasLocalContribution(
141
+ {
142
+ head: { branch: 'main', oid: oidB, detached: false },
143
+ refs: [{ name: 'main', oid: oidB }],
144
+ },
145
+ remoteAdvance,
146
+ baseline,
147
+ null,
148
+ {
149
+ head: { branch: 'main', oid: oidB, detached: false },
150
+ branches: [{ name: 'main', oid: oidB }],
151
+ },
152
+ ), false);
153
+ assert.equal(hasLocalContribution(
154
+ local,
155
+ plan,
156
+ {
157
+ ...baseline,
158
+ branches: [...baseline.branches, { name: 'feature', oid: null }],
159
+ },
160
+ null,
161
+ {
162
+ head: { branch: 'main', oid: oidA, detached: false },
163
+ branches: [
164
+ { name: 'main', oid: oidA },
165
+ { name: 'feature', oid: oidB },
166
+ ],
167
+ },
168
+ ), true);
123
169
  assert.equal(hasLocalContribution(local, plan, baseline, {
124
170
  checkpoint: 'repo-checkpoint@7',
125
171
  machine: 'machine-a',
@@ -231,7 +231,11 @@ function hasLocalContribution(card, plan, baseline, checkpoint = null, previousC
231
231
  const atBaseline = baseline
232
232
  && card.head?.branch === baseline.branch
233
233
  && card.head?.oid === baseline.baseOid;
234
- if (!onRail && !atBaseline) return true;
234
+ const alreadyPublished = previousCard && samePosition(card, previousCard);
235
+ const reversesPublishedSharedPosition = previousCard
236
+ && samePosition(previousCard, plan.card)
237
+ && !alreadyPublished;
238
+ if ((!onRail && !atBaseline && !alreadyPublished) || reversesPublishedSharedPosition) return true;
235
239
 
236
240
  const currentRefs = oidMap(card.refs);
237
241
  const railRefs = oidMap(plan.branches);
@@ -245,11 +249,15 @@ function hasLocalContribution(card, plan, baseline, checkpoint = null, previousC
245
249
  const current = currentRefs.get(name) ?? null;
246
250
  const shared = railRefs.get(name) ?? null;
247
251
  const previous = baselineRefs.get(name) ?? null;
248
- const changedFromPublished = previousRefs.has(name)
249
- && current !== (previousRefs.get(name) ?? null);
252
+ const published = previousRefs.get(name) ?? null;
253
+ const matchesBaseline = baselineRefs.has(name) && current === previous;
254
+ const matchesPublished = previousRefs.has(name) && current === published;
255
+ const reversesPublishedSharedValue = previousRefs.has(name)
256
+ && published === shared
257
+ && current !== published;
250
258
  if (
251
259
  current !== shared
252
- && (!baselineRefs.has(name) || current !== previous || changedFromPublished)
260
+ && ((!matchesBaseline && !matchesPublished) || reversesPublishedSharedValue)
253
261
  ) return true;
254
262
  }
255
263