@cydm/happy-elves 0.1.0-beta.491 → 0.1.0-beta.494

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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cydm/happy-elves-cli",
3
- "version": "0.1.0-beta.491",
3
+ "version": "0.1.0-beta.494",
4
4
  "private": true,
5
5
  "type": "module"
6
6
  }
@@ -84,6 +84,22 @@ export async function handleServerMessage(ws, config, message) {
84
84
  capability: message.capability,
85
85
  },
86
86
  });
87
+ // A detached relay operation failure is recoverable only after the
88
+ // authenticated stream is recreated. Keeping this socket open would
89
+ // leave durable packets stranded: the relay has already rejected the
90
+ // replay/live send, while the daemon's normal outbox drain is only
91
+ // kicked on a new socket. Close the stream for storage-generation
92
+ // failures so start() performs its bounded reconnect loop. Do not do
93
+ // this for ordinary command validation errors.
94
+ if (shouldReconnectAfterRelayError(message.code) && ws.readyState === WebSocket.OPEN) {
95
+ try {
96
+ ws.close();
97
+ }
98
+ catch {
99
+ // The close event/error path still performs cleanup if the socket
100
+ // implementation rejects a redundant close.
101
+ }
102
+ }
87
103
  return;
88
104
  }
89
105
  if (message.type === "machine:createSession") {
@@ -268,6 +284,14 @@ async function handleRelayWireMessage(ws, config, message) {
268
284
  packetId: message.packetId,
269
285
  },
270
286
  });
287
+ if (shouldReconnectAfterRelayError(message.code) && ws.readyState === WebSocket.OPEN) {
288
+ try {
289
+ ws.close();
290
+ }
291
+ catch {
292
+ // Cleanup is also driven by the close/error listeners below.
293
+ }
294
+ }
271
295
  return;
272
296
  }
273
297
  if (message.type !== "server:packet")
@@ -338,6 +362,9 @@ async function handleRelayWireMessage(ws, config, message) {
338
362
  ackIncomingPacket(ws, message);
339
363
  }
340
364
  }
365
+ function shouldReconnectAfterRelayError(code) {
366
+ return code === "RELAY_STORAGE_TIMEOUT" || code === "RELAY_STORAGE_UNAVAILABLE" || code === "RELAY_STORAGE_CLOSED";
367
+ }
341
368
  function errorCode(error) {
342
369
  if (!error || typeof error !== "object")
343
370
  return undefined;
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cydm/happy-elves-daemon",
3
- "version": "0.1.0-beta.491",
3
+ "version": "0.1.0-beta.494",
4
4
  "private": true,
5
5
  "type": "module"
6
6
  }
@@ -72,10 +72,21 @@ export function initPacketRelayTables(db) {
72
72
 
73
73
  CREATE INDEX IF NOT EXISTS idx_packets_to_node
74
74
  ON packets(space_id, to_node_id, id);
75
+ -- Replay admission must be able to reject already-acked packets without
76
+ -- touching the encrypted payload column. The old query selected
77
+ -- packets.* while walking every packet addressed to a node, so a node
78
+ -- with a large, fully-acked history could read gigabytes of overflow
79
+ -- pages before returning an empty batch.
80
+ CREATE INDEX IF NOT EXISTS idx_packets_unacked_lookup
81
+ ON packets(space_id, to_node_id, id, expires_at, packet_id);
75
82
  CREATE INDEX IF NOT EXISTS idx_packets_channel
76
83
  ON packets(space_id, channel_id, stream_id, id);
84
+ CREATE INDEX IF NOT EXISTS idx_packets_channel_id
85
+ ON packets(space_id, channel_id, id);
77
86
  CREATE INDEX IF NOT EXISTS idx_packets_expires
78
87
  ON packets(expires_at);
88
+ CREATE INDEX IF NOT EXISTS idx_packets_received_at
89
+ ON packets(received_at);
79
90
  CREATE INDEX IF NOT EXISTS idx_retained_space_key
80
91
  ON retained_packets(space_id, retained_key);
81
92
  `);
@@ -192,24 +203,62 @@ export function listRetainedPackets(db, input) {
192
203
  export function listPacketsForSubscription(db, input) {
193
204
  const cursor = input.cursor ?? 0;
194
205
  const limit = input.limit ?? 500;
195
- const rows = statement(db, `SELECT *
196
- FROM packets
197
- WHERE space_id = ?
198
- AND id > ?
199
- AND (expires_at IS NULL OR expires_at > ?)
200
- AND (
201
- to_node_id = ?
202
- OR (? IS NOT NULL AND channel_id = ?)
203
- )
204
- AND (? IS NULL OR stream_id = ?)
205
- ORDER BY id ASC
206
- LIMIT ?`).all(input.spaceId, cursor, input.now, input.nodeId, input.subscription.channelId ?? null, input.subscription.channelId ?? null, input.subscription.streamId ?? null, input.subscription.streamId ?? null, limit);
207
- return rows.map(packetFromRow);
206
+ const streamId = input.subscription.streamId;
207
+ const rows = [];
208
+ // Keep the historical OR semantics, but execute each arm independently so
209
+ // SQLite can seek through idx_packets_to_node or idx_packets_channel instead
210
+ // of scanning and sorting every packet in the account. A packet can match
211
+ // both arms; the merge below de-duplicates by the stable packets.id cursor.
212
+ // The node arm is intentionally unconditional. `nodeId` is the authenticated
213
+ // connection identity and was always part of the old OR predicate, even for
214
+ // an empty/channel-only subscription. `subscription.toNodeId` is a snapshot
215
+ // label, not a permission to redirect this reader to another node.
216
+ {
217
+ const sql = streamId === undefined
218
+ ? `SELECT * FROM packets
219
+ WHERE space_id = ? AND to_node_id = ? AND id > ?
220
+ AND (expires_at IS NULL OR expires_at > ?)
221
+ ORDER BY id ASC LIMIT ?`
222
+ : `SELECT * FROM packets
223
+ WHERE space_id = ? AND to_node_id = ? AND stream_id = ? AND id > ?
224
+ AND (expires_at IS NULL OR expires_at > ?)
225
+ ORDER BY id ASC LIMIT ?`;
226
+ const values = streamId === undefined
227
+ ? [input.spaceId, input.nodeId, cursor, input.now, limit]
228
+ : [input.spaceId, input.nodeId, streamId, cursor, input.now, limit];
229
+ rows.push(...statement(db, sql).all(...values));
230
+ }
231
+ if (input.subscription.channelId !== undefined) {
232
+ const sql = streamId === undefined
233
+ ? `SELECT * FROM packets
234
+ WHERE space_id = ? AND channel_id = ? AND id > ?
235
+ AND (expires_at IS NULL OR expires_at > ?)
236
+ ORDER BY id ASC LIMIT ?`
237
+ : `SELECT * FROM packets
238
+ WHERE space_id = ? AND channel_id = ? AND stream_id = ? AND id > ?
239
+ AND (expires_at IS NULL OR expires_at > ?)
240
+ ORDER BY id ASC LIMIT ?`;
241
+ const values = streamId === undefined
242
+ ? [input.spaceId, input.subscription.channelId, cursor, input.now, limit]
243
+ : [input.spaceId, input.subscription.channelId, streamId, cursor, input.now, limit];
244
+ rows.push(...statement(db, sql).all(...values));
245
+ }
246
+ const uniqueRows = new Map();
247
+ for (const row of rows)
248
+ uniqueRows.set(row.id, row);
249
+ return [...uniqueRows.values()]
250
+ .sort((left, right) => left.id - right.id)
251
+ .slice(0, limit)
252
+ .map(packetFromRow);
208
253
  }
209
254
  export function listUnackedPacketsForNode(db, input) {
210
255
  const cursor = input.cursor ?? 0;
211
256
  const limit = input.limit ?? 500;
212
- const rows = statement(db, `SELECT packets.*
257
+ // First find candidate row ids using a covering routing index. Do not
258
+ // select packets.* in this anti-join: encrypted_payload is commonly a
259
+ // multi-kilobyte value stored in overflow pages, and loading it for every
260
+ // already-acked packet turns an empty replay into a large disk read.
261
+ const candidateRows = statement(db, `SELECT packets.id
213
262
  FROM packets
214
263
  LEFT JOIN packet_acks
215
264
  ON packet_acks.space_id = packets.space_id
@@ -222,13 +271,42 @@ export function listUnackedPacketsForNode(db, input) {
222
271
  AND packet_acks.packet_id IS NULL
223
272
  ORDER BY packets.id ASC
224
273
  LIMIT ?`).all(input.nodeId, input.spaceId, cursor, input.nodeId, input.now, limit);
274
+ if (candidateRows.length === 0)
275
+ return [];
276
+ // Fetch the encrypted rows only for the bounded set that really needs to
277
+ // be replayed. The ids are already ordered, but retain an explicit ORDER
278
+ // BY so the FIFO/server-sequence contract remains clear to SQLite.
279
+ const placeholders = candidateRows.map(() => "?").join(", ");
280
+ const rows = statement(db, `SELECT * FROM packets
281
+ WHERE space_id = ? AND id IN (${placeholders})
282
+ ORDER BY id ASC`).all(input.spaceId, ...candidateRows.map((row) => row.id));
225
283
  return rows.map(packetFromRow);
226
284
  }
227
285
  export function prunePackets(db, input) {
228
286
  if (input.maxAgeMs <= 0)
229
287
  return;
230
288
  const oldestReceivedAt = input.now - input.maxAgeMs;
231
- statement(db, "DELETE FROM packets WHERE (expires_at IS NOT NULL AND expires_at <= ?) OR received_at <= ?").run(input.now, oldestReceivedAt);
289
+ // Retention runs on the same serialized storage worker as packet replay.
290
+ // Bound each pass so a large historical database cannot occupy the worker
291
+ // past the operation deadline. The two indexed arms avoid the old OR
292
+ // predicate's full packets-table scan when there is nothing to prune.
293
+ const candidates = statement(db, `WITH expired AS (
294
+ SELECT id FROM packets
295
+ WHERE expires_at IS NOT NULL AND expires_at <= ?
296
+ LIMIT 1000
297
+ ), stale AS (
298
+ SELECT id FROM packets
299
+ WHERE received_at <= ?
300
+ LIMIT 1000
301
+ )
302
+ SELECT id
303
+ FROM (SELECT id FROM expired UNION SELECT id FROM stale)
304
+ ORDER BY id ASC
305
+ LIMIT 1000`).all(input.now, oldestReceivedAt);
306
+ if (candidates.length === 0)
307
+ return;
308
+ const placeholders = candidates.map(() => "?").join(", ");
309
+ statement(db, `DELETE FROM packets WHERE id IN (${placeholders})`).run(...candidates.map((row) => row.id));
232
310
  }
233
311
  export function maxPacketSeq(db, spaceId) {
234
312
  const row = statement(db, "SELECT COALESCE(MAX(id), 0) AS seq FROM packets WHERE space_id = ?").get(spaceId);
@@ -13,6 +13,7 @@ import { ackPacket, getPacketById, getRetainedPacket, insertPacket, listPacketsF
13
13
  if (parentPort && workerData?.relayStorageWorker === true) {
14
14
  const dbPath = String(workerData.dbPath);
15
15
  const testStorageDelayMs = Math.max(0, Number(workerData.testStorageDelayMs) || 0);
16
+ const testStorageDelayFile = typeof workerData.testStorageDelayFile === "string" ? workerData.testStorageDelayFile : undefined;
16
17
  mkdirSync(path.dirname(dbPath), { recursive: true });
17
18
  const db = new DatabaseSync(dbPath);
18
19
  db.exec("PRAGMA journal_mode = WAL");
@@ -26,8 +27,9 @@ if (parentPort && workerData?.relayStorageWorker === true) {
26
27
  parentPort.postMessage({ type: "ready" });
27
28
  parentPort.on("message", (request) => {
28
29
  try {
29
- if (testStorageDelayMs > 0)
30
+ if (testStorageDelayMs > 0 && (!testStorageDelayFile || existsSync(testStorageDelayFile))) {
30
31
  Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, testStorageDelayMs);
32
+ }
31
33
  const value = request.op === "controlPlane"
32
34
  ? runControlPlaneAction(controlPlane, request.action, request.input)
33
35
  : request.op === "directTicketCreate"
@@ -252,6 +254,7 @@ export function createRelayDurableStorage(dbPath) {
252
254
  dbPath,
253
255
  pairingTtlMs: 600_000,
254
256
  testStorageDelayMs: process.env.HAPPY_ELVES_TEST_STORAGE_DELAY_MS,
257
+ testStorageDelayFile: process.env.HAPPY_ELVES_TEST_STORAGE_DELAY_FILE,
255
258
  },
256
259
  });
257
260
  nextWorker.unref();
@@ -158,9 +158,12 @@ export function registerWebsocketRoute(app, context, originAllowed) {
158
158
  });
159
159
  await sendSnapshot(context, openedConnection, message.snapshotMode ?? "full");
160
160
  broadcastPresence(context, openedConnection.spaceId, await presenceWithManifest(context, openedConnection.spaceId, presence));
161
- void replayNodePackets(context, openedConnection, message.cursor);
161
+ void replayNodePackets(context, openedConnection, message.cursor).catch((error) => {
162
+ reportDetachedOperationFailure(openedConnection, error);
163
+ });
162
164
  }).catch((error) => {
163
165
  app.log.warn({ error }, "failed to persist relay presence");
166
+ reportDetachedOperationFailure(openedConnection, error, true);
164
167
  });
165
168
  }).catch((error) => {
166
169
  fail(error instanceof Error ? error.message : "Websocket authentication failed", "AUTH_TOKEN_INVALID");
@@ -210,10 +213,15 @@ export function registerWebsocketRoute(app, context, originAllowed) {
210
213
  if (!connection)
211
214
  return;
212
215
  const closedConnection = connection;
216
+ closedConnection.closed = true;
213
217
  closedConnection.publishChainClosed = true;
214
218
  if (closedConnection.expiryTimer)
215
219
  globalThis.clearTimeout(closedConnection.expiryTimer);
216
- void flushPendingCursor(context, closedConnection);
220
+ if (closedConnection.cursorFlushTimer) {
221
+ globalThis.clearTimeout(closedConnection.cursorFlushTimer);
222
+ closedConnection.cursorFlushTimer = undefined;
223
+ }
224
+ void flushPendingCursor(context, closedConnection).catch(() => undefined);
217
225
  unregisterConnection(closedConnection);
218
226
  const remaining = connectionsByNode.get(nodeKey(closedConnection.spaceId, closedConnection.nodeId));
219
227
  const stillOnline = Boolean(remaining && remaining.size > 0);
@@ -544,7 +552,9 @@ function pumpSubscriptionReplays(context, connection) {
544
552
  if (!state || state.running)
545
553
  continue;
546
554
  state.queued = false;
547
- void runSubscriptionReplay(context, connection, key, state);
555
+ void runSubscriptionReplay(context, connection, key, state).catch((error) => {
556
+ reportDetachedOperationFailure(connection, error);
557
+ });
548
558
  }
549
559
  }
550
560
  async function runSubscriptionReplay(context, connection, key, state) {
@@ -613,7 +623,9 @@ function deliverPacket(context, packet) {
613
623
  if (packet.toNodeId) {
614
624
  const connections = connectionsByNode.get(nodeKey(packet.spaceId, packet.toNodeId));
615
625
  for (const connection of connections ?? []) {
616
- void sendPacket(context, connection, packet, "live");
626
+ void sendPacket(context, connection, packet, "live").catch((error) => {
627
+ reportDetachedOperationFailure(connection, error);
628
+ });
617
629
  delivered.add(connection);
618
630
  }
619
631
  }
@@ -623,7 +635,9 @@ function deliverPacket(context, packet) {
623
635
  continue;
624
636
  if (!connection.subscriptions.some((subscription) => subscriptionMatches(connection, subscription, packet)))
625
637
  continue;
626
- void sendPacket(context, connection, packet, "live");
638
+ void sendPacket(context, connection, packet, "live").catch((error) => {
639
+ reportDetachedOperationFailure(connection, error);
640
+ });
627
641
  }
628
642
  }
629
643
  async function sendPacket(context, connection, packet, source) {
@@ -666,6 +680,8 @@ async function sendPacket(context, connection, packet, source) {
666
680
  return true;
667
681
  }
668
682
  function queueCursorUpdate(context, connection, serverSeq) {
683
+ if (connection.closed)
684
+ return;
669
685
  connection.pendingCursorServerSeq = Math.max(connection.pendingCursorServerSeq ?? 0, serverSeq);
670
686
  if (connection.cursorFlushTimer)
671
687
  return;
@@ -674,13 +690,35 @@ function queueCursorUpdate(context, connection, serverSeq) {
674
690
  // keeps high-rate replay from turning every packet into an fsync.
675
691
  connection.cursorFlushTimer = setTimeout(() => {
676
692
  connection.cursorFlushTimer = undefined;
677
- void flushPendingCursor(context, connection);
693
+ void flushPendingCursor(context, connection).catch(() => undefined);
678
694
  }, 100);
679
695
  connection.cursorFlushTimer.unref?.();
680
696
  }
681
697
  async function flushPendingCursor(context, connection) {
698
+ if (connection.cursorFlushInFlight)
699
+ return await connection.cursorFlushInFlight;
700
+ const run = flushPendingCursorOnce(context, connection);
701
+ let trackedRun;
702
+ trackedRun = run.finally(() => {
703
+ if (connection.cursorFlushInFlight === trackedRun)
704
+ connection.cursorFlushInFlight = undefined;
705
+ // A newer packet can arrive while the write is in flight. Keep one
706
+ // coalesced follow-up timer, but never resurrect retries after the
707
+ // socket has closed.
708
+ if (!connection.closed && connection.pendingCursorServerSeq !== undefined && !connection.cursorFlushTimer) {
709
+ connection.cursorFlushTimer = setTimeout(() => {
710
+ connection.cursorFlushTimer = undefined;
711
+ void flushPendingCursor(context, connection).catch(() => undefined);
712
+ }, 250);
713
+ connection.cursorFlushTimer.unref?.();
714
+ }
715
+ });
716
+ connection.cursorFlushInFlight = trackedRun;
717
+ await trackedRun;
718
+ }
719
+ async function flushPendingCursorOnce(context, connection) {
682
720
  if (connection.cursorFlushTimer) {
683
- clearTimeout(connection.cursorFlushTimer);
721
+ globalThis.clearTimeout(connection.cursorFlushTimer);
684
722
  connection.cursorFlushTimer = undefined;
685
723
  }
686
724
  const serverSeq = connection.pendingCursorServerSeq;
@@ -700,7 +738,8 @@ async function flushPendingCursor(context, connection) {
700
738
  connection.pendingCursorServerSeq = Math.max(connection.pendingCursorServerSeq ?? 0, serverSeq);
701
739
  // Cursor persistence is recoverable; do not terminate the relay process
702
740
  // because a worker/database write was temporarily unavailable.
703
- setTimeout(() => { void flushPendingCursor(context, connection); }, 250).unref?.();
741
+ // The single-flight finalizer above owns the one retry timer. In
742
+ // particular, a close cannot leave an unbounded timer chain behind.
704
743
  }
705
744
  }
706
745
  function closeSlowReceiver(connection) {
@@ -874,7 +913,7 @@ function scheduleConnectionExpiry(context, connection, expiresAt) {
874
913
  if (!current || current.revoked_at)
875
914
  return;
876
915
  if (current.expires_at && current.expires_at > context.now()) {
877
- void scheduleNext();
916
+ void scheduleNext().catch(() => undefined);
878
917
  return;
879
918
  }
880
919
  if (!current.expires_at)
@@ -888,7 +927,33 @@ function scheduleConnectionExpiry(context, connection, expiresAt) {
888
927
  }).catch(() => undefined);
889
928
  }, Math.min(delay, MAX_EXPIRY_TIMER_DELAY_MS));
890
929
  };
891
- void scheduleNext();
930
+ void scheduleNext().catch(() => undefined);
931
+ }
932
+ function reportDetachedOperationFailure(connection, error, forceClose = false) {
933
+ if (connection.socket.readyState !== 1)
934
+ return;
935
+ const record = error && typeof error === "object" ? error : {};
936
+ const code = typeof record.code === "string" ? record.code : "RELAY_COMMAND_FAILED";
937
+ const message = typeof record.message === "string" ? record.message : String(error);
938
+ send(connection.socket, { type: "server:error", code, message });
939
+ // A detached replay/live send has already lost its in-memory continuation.
940
+ // Close the stream for storage-generation failures so old daemons and
941
+ // controllers, which only reconnect on close, replay their durable outbox
942
+ // and retained packets. The stored ACK boundary remains authoritative; no
943
+ // packet is acknowledged merely because the connection is being closed.
944
+ if (forceClose || isRecoverableStorageFailure(code)) {
945
+ connection.closed = true;
946
+ try {
947
+ connection.socket.close(1011, code);
948
+ }
949
+ catch {
950
+ // The close/error listener still performs cleanup for transports that
951
+ // reject a redundant close.
952
+ }
953
+ }
954
+ }
955
+ function isRecoverableStorageFailure(code) {
956
+ return code === "RELAY_STORAGE_TIMEOUT" || code === "RELAY_STORAGE_UNAVAILABLE" || code === "RELAY_STORAGE_CLOSED";
892
957
  }
893
958
  async function isScopedControllerConnection(context, connection) {
894
959
  const token = await context.tokenRecordAsync(connection.authToken);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cydm/happy-elves",
3
- "version": "0.1.0-beta.491",
3
+ "version": "0.1.0-beta.494",
4
4
  "description": "Remote controller for local coding agents with hosted or self-hosted relay support.",
5
5
  "type": "module",
6
6
  "bin": {