amalgm 0.1.238 → 0.1.239

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,7 +17,6 @@ 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;
21
20
 
22
21
  function createSharedRealtimeTunnel({
23
22
  send,
@@ -25,7 +24,6 @@ function createSharedRealtimeTunnel({
25
24
  localPort,
26
25
  localHeaders,
27
26
  warn,
28
- repoObjectRequestTimeoutMs = REPO_OBJECT_REQUEST_TIMEOUT_MS,
29
27
  }) {
30
28
  const mutationInflight = new Map();
31
29
  const promotionInflight = new Map();
@@ -144,6 +142,10 @@ function createSharedRealtimeTunnel({
144
142
  attachmentRetryTimer.unref?.();
145
143
  }
146
144
 
145
+ function alreadyInflight(inflight, matches) {
146
+ return [...inflight.values()].some(matches);
147
+ }
148
+
147
149
  async function drainMutations() {
148
150
  if (!enabled || mutationDrain) return;
149
151
  mutationDrain = (async () => {
@@ -154,6 +156,10 @@ function createSharedRealtimeTunnel({
154
156
  for (const claim of Array.isArray(result?.mutations) ? result.mutations : []) {
155
157
  const envelope = claim?.envelope;
156
158
  if (!envelope?.resourceId || !envelope?.mutationId) continue;
159
+ if (alreadyInflight(mutationInflight, (entry) => (
160
+ entry.resourceId === envelope.resourceId
161
+ && entry.mutationId === envelope.mutationId
162
+ ))) continue;
157
163
  const requestId = `shared_${crypto.randomBytes(12).toString('hex')}`;
158
164
  if (!sendLarge({ type: 'shared.mutation.submit', request_id: requestId, envelope })) {
159
165
  await failMutationClaim(claim, 'shared tunnel backpressure: mutation was not queued');
@@ -201,6 +207,9 @@ function createSharedRealtimeTunnel({
201
207
  for (const claim of Array.isArray(result?.promotions) ? result.promotions : []) {
202
208
  const promotion = claim?.frame;
203
209
  if (!promotion?.promotionId || !promotion?.resourceId) continue;
210
+ if (alreadyInflight(promotionInflight, (entry) => (
211
+ entry.promotionId === promotion.promotionId
212
+ ))) continue;
204
213
  const requestId = `promote_${crypto.randomBytes(12).toString('hex')}`;
205
214
  const queued = sendLarge({
206
215
  type: 'shared.resource.promote',
@@ -247,6 +256,9 @@ function createSharedRealtimeTunnel({
247
256
  });
248
257
  for (const attachment of Array.isArray(result?.attachments) ? result.attachments : []) {
249
258
  if (!attachment?.attachmentId || !attachment?.resourceId) continue;
259
+ if (alreadyInflight(attachmentInflight, (entry) => (
260
+ entry.attachmentId === attachment.attachmentId
261
+ ))) continue;
250
262
  const requestId = `attach_${crypto.randomBytes(12).toString('hex')}`;
251
263
  if (!send({
252
264
  type: 'shared.resource.attach',
@@ -279,17 +291,10 @@ function createSharedRealtimeTunnel({
279
291
  repoObjectTimer.unref?.();
280
292
  }
281
293
 
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
-
290
294
  async function drainRepoObjects() {
291
295
  if (!enabled) return;
292
- expireRepoObjectRequests();
296
+ // One ordered WebSocket carries one request until its reply. A disconnect
297
+ // clears this map and the durable local queue resends after reconnect.
293
298
  if (repoUploadInflight.size === 0) {
294
299
  const result = await localStateJson('POST', '/state/repo-objects/uploads/claim', {});
295
300
  const upload = result?.upload;
@@ -306,7 +311,6 @@ function createSharedRealtimeTunnel({
306
311
  repoUploadInflight.set(requestId, {
307
312
  resourceId: upload.resourceId,
308
313
  sha256: upload.sha256,
309
- sentAt: Date.now(),
310
314
  });
311
315
  }
312
316
  }
@@ -325,7 +329,6 @@ function createSharedRealtimeTunnel({
325
329
  repoDownloadInflight.set(requestId, {
326
330
  resourceId: download.resourceId,
327
331
  sha256: download.sha256,
328
- sentAt: Date.now(),
329
332
  });
330
333
  }
331
334
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "amalgm",
3
- "version": "0.1.238",
3
+ "version": "0.1.239",
4
4
  "description": "Amalgm local computer runtime: login, MCP, chat, events, previews, and tunnels.",
5
5
  "license": "UNLICENSED",
6
6
  "private": false,
@@ -123,7 +123,7 @@ test('repo object chunks travel through the shared tunnel one at a time', async
123
123
  }
124
124
  });
125
125
 
126
- test('repo object chunks retry when an acknowledgement is lost', async () => {
126
+ test('repo object chunks retry after the tunnel reconnects', async () => {
127
127
  const uploadBytes = Buffer.from('retry this content-addressed chunk');
128
128
  const uploadSha = crypto.createHash('sha256').update(uploadBytes).digest('hex');
129
129
  const resourceId = 'shr_cp1abcdefghijklmnopqrstuvwx';
@@ -165,14 +165,25 @@ test('repo object chunks retry when an acknowledgement is lost', async () => {
165
165
  localPort: () => server.address().port,
166
166
  localHeaders: (headers) => headers,
167
167
  warn: () => {},
168
- repoObjectRequestTimeoutMs: 25,
169
168
  });
170
169
 
171
170
  try {
171
+ tunnel.enable(['shared.mutation.v1']);
172
+ await until(
173
+ () => frames.filter((frame) => frame.type === 'repo.object.put').length === 1,
174
+ 'the original upload',
175
+ );
176
+ await new Promise((resolve) => { setTimeout(resolve, 50); });
177
+ assert.equal(
178
+ frames.filter((frame) => frame.type === 'repo.object.put').length,
179
+ 1,
180
+ 'a healthy ordered tunnel never duplicates an in-flight upload',
181
+ );
182
+ tunnel.stop();
172
183
  tunnel.enable(['shared.mutation.v1']);
173
184
  await until(
174
185
  () => frames.filter((frame) => frame.type === 'repo.object.put').length >= 2,
175
- 'the unacknowledged upload retry',
186
+ 'the upload after reconnect',
176
187
  );
177
188
  const puts = frames.filter((frame) => frame.type === 'repo.object.put');
178
189
  assert.notEqual(puts[0].request_id, puts[1].request_id);
@@ -189,3 +200,66 @@ test('repo object chunks retry when an acknowledgement is lost', async () => {
189
200
  server.close();
190
201
  }
191
202
  });
203
+
204
+ test('a healthy tunnel sends one request for one durable mutation', async () => {
205
+ const envelope = {
206
+ resourceId: 'shr_cp1abcdefghijklmnopqrstuvwx',
207
+ authorityEpoch: 1,
208
+ mutationId: 'mut_one_ordered_request',
209
+ deviceId: 'machine-a',
210
+ baseVersion: 0,
211
+ contract: 'code-project-stream@1',
212
+ schemaVersion: 1,
213
+ operationKind: 'code.publish',
214
+ operation: { kind: 'code.publish', record: { value: 'one change' } },
215
+ };
216
+ let wakeResponse = null;
217
+ const server = http.createServer((req, res) => {
218
+ req.resume();
219
+ req.on('end', () => {
220
+ res.setHeader('content-type', 'application/json');
221
+ if (req.url === '/state/shared/replicas') {
222
+ res.end('{"replicas":[]}');
223
+ } else if (req.url.startsWith('/state/stream')) {
224
+ res.writeHead(200, { 'content-type': 'text/event-stream' });
225
+ res.write('\n');
226
+ wakeResponse = res;
227
+ } else if (req.url === '/state/mutations/claim') {
228
+ res.end(JSON.stringify({ mutations: [{ envelope }] }));
229
+ } else if (req.url.endsWith('/claim')) {
230
+ res.end('{}');
231
+ } else {
232
+ res.end('{}');
233
+ }
234
+ });
235
+ });
236
+ await new Promise((resolve) => { server.listen(0, '127.0.0.1', resolve); });
237
+
238
+ const frames = [];
239
+ const tunnel = createSharedRealtimeTunnel({
240
+ send: (frame) => { frames.push(frame); return true; },
241
+ sendLarge: (frame) => { frames.push(frame); return true; },
242
+ localPort: () => server.address().port,
243
+ localHeaders: (headers) => headers,
244
+ warn: () => {},
245
+ });
246
+ const submits = () => frames.filter((frame) => frame.type === 'shared.mutation.submit');
247
+
248
+ try {
249
+ tunnel.enable(['shared.mutation.v1']);
250
+ await until(() => submits().length === 1 && wakeResponse, 'the original mutation request');
251
+ for (let index = 0; index < 3; index += 1) {
252
+ wakeResponse.write('event: state\ndata: {"sharedResourceId":"shr_cp1abcdefghijklmnopqrstuvwx"}\n\n');
253
+ await new Promise((resolve) => { setTimeout(resolve, 20); });
254
+ }
255
+ assert.equal(submits().length, 1, 'wake signals never duplicate an in-flight mutation');
256
+
257
+ tunnel.stop();
258
+ tunnel.enable(['shared.mutation.v1']);
259
+ await until(() => submits().length === 2, 'the mutation after reconnect');
260
+ assert.notEqual(submits()[0].request_id, submits()[1].request_id);
261
+ } finally {
262
+ tunnel.stop();
263
+ server.close();
264
+ }
265
+ });