pi-crew 0.9.32 → 0.9.33

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.
@@ -225,6 +225,18 @@ export function __test__clearSequenceCache(): void {
225
225
  sequenceCache.clear();
226
226
  }
227
227
 
228
+ /** @internal — clear the in-process seqCounters Map so nextSequence seeds
229
+ * fresh from the sidecar/file (simulates a process restart for testing). */
230
+ export function __test__clearSeqCounters(): void {
231
+ seqCounters.clear();
232
+ }
233
+
234
+ /** @internal — the raw nextSequence for testing (forces re-seed from disk
235
+ * by requiring the caller to have already cleared both caches). */
236
+ export function __test__nextSequence(eventsPath: string): number {
237
+ return nextSequence(eventsPath);
238
+ }
239
+
228
240
  /** @internal — the max sequence cache entries bound. */
229
241
  export const MAX_SEQUENCE_CACHE_ENTRIES_VALUE = MAX_SEQUENCE_CACHE_ENTRIES;
230
242
 
@@ -277,13 +289,22 @@ function nextSequence(eventsPath: string): number {
277
289
  const stored = readStoredSequence(eventsPath);
278
290
  const fileShrunk = cached && stat.size < cached.size;
279
291
  if (stored !== undefined && !fileShrunk) {
292
+ // EL-1: the sidecar can regress via sync/async interleave —
293
+ // appendEventAsync reserves a seq, yields during appendFile/fsync, a
294
+ // concurrent sync appendEvent reserves+persists a higher seq, then the
295
+ // async path persists its lower seq — regressing the sidecar below the
296
+ // file's true max. Trusting the regressed sidecar alone would return a
297
+ // duplicate seq on the next append. Take max with scanSequence so a
298
+ // regressed sidecar cannot cause duplicate sequence numbers.
299
+ const fileMax = scanSequence(eventsPath);
300
+ const safeSeq = Math.max(stored, fileMax);
280
301
  sequenceCache.set(eventsPath, {
281
302
  size: stat.size,
282
303
  mtimeMs: stat.mtimeMs,
283
- seq: stored,
304
+ seq: safeSeq,
284
305
  lastAccessMs: Date.now(),
285
306
  });
286
- return stored + 1;
307
+ return safeSeq + 1;
287
308
  }
288
309
  const current = scanSequence(eventsPath);
289
310
  sequenceCache.set(eventsPath, {
@@ -1040,6 +1061,40 @@ export async function flushEventLogBuffer(): Promise<void> {
1040
1061
  for (const eventsPath of [...bufferedQueues.keys()]) await flushOneEventLogBuffer(eventsPath);
1041
1062
  }
1042
1063
 
1064
+ /**
1065
+ * EL-2: Synchronously flush every queued buffered event across all paths.
1066
+ * Used by the `exit` / `uncaughtException` / `SIGTERM` / `SIGINT` handlers,
1067
+ * which CANNOT await async work (process is terminating). The async
1068
+ * flushEventLogBuffer() previously called from these handlers created
1069
+ * floating promises that never resolved, and the process exited before
1070
+ * any buffered events were written — losing all events buffered via
1071
+ * appendEventBuffered (task.progress etc.). This sync variant writes the
1072
+ * buffered batches using the sync event-log lock + appendFileSync + fsync
1073
+ * + persistSequence, recovering buffered events before termination.
1074
+ * In-flight asyncQueues (appendEventAsync writes already dispatched to
1075
+ * the thread pool) remain best-effort and cannot be awaited on `exit` —
1076
+ * we clear them to drop stale state. SIGKILL cannot be intercepted.
1077
+ */
1078
+ export function flushBufferedQueuesSync(): void {
1079
+ for (const eventsPath of [...bufferedQueues.keys()]) {
1080
+ const queue = bufferedQueues.get(eventsPath);
1081
+ bufferedQueues.delete(eventsPath);
1082
+ if (!queue || queue.length === 0) continue;
1083
+ try {
1084
+ withEventLogLockSync(eventsPath, () => {
1085
+ // appendEventBatchInsideLock is declared async but its body is fully
1086
+ // synchronous (fs.appendFileSync + fs.fsyncSync + persistSequence,
1087
+ // no awaits). Invoking without await runs the body synchronously and
1088
+ // returns a resolved Promise that we discard.
1089
+ void appendEventBatchInsideLock(eventsPath, queue);
1090
+ });
1091
+ } catch (error) {
1092
+ logInternalError("event-log.sync-flush", error, eventsPath);
1093
+ }
1094
+ }
1095
+ for (const eventsPath of [...bufferedTimers.keys()]) bufferedTimers.delete(eventsPath);
1096
+ }
1097
+
1043
1098
  /**
1044
1099
  * Schedule an async event append without waiting for the result.
1045
1100
  * Uses the non-blocking async queue to avoid blocking the event loop.
@@ -1059,20 +1114,13 @@ export function appendEventFireAndForget(eventsPath: string, event: AppendTeamEv
1059
1114
  // failing tests with "Promise resolution is still pending but the event loop
1060
1115
  // has already resolved" even when the test body completed cleanly.
1061
1116
  process.on("exit", () => {
1062
- if (bufferedQueues.size > 0) {
1063
- flushEventLogBuffer().catch(() => {
1064
- /* best-effort flush on exit */
1065
- });
1066
- }
1067
- // FIX (Issue 1): Drain asyncQueues on exit to minimize event loss.
1068
- // In-flight async writes are awaited (via Promise.allSettled) before
1069
- // the map is cleared. This reduces but does not eliminate event loss
1070
- // on crash — SIGKILL (kill -9) cannot be intercepted.
1071
- if (asyncQueues.size > 0) {
1072
- drainAsyncQueues().catch(() => {
1073
- /* best-effort drain on exit */
1074
- });
1075
- }
1117
+ // EL-2: synchronously flush buffered events before the process terminates.
1118
+ // `exit` is sync-only and cannot await async work; the previous async
1119
+ // flushEventLogBuffer()/drainAsyncQueues() created floating promises that
1120
+ // never resolved, and the process exited before any buffered events were
1121
+ // written. flushBufferedQueuesSync uses the sync lock + appendFileSync +
1122
+ // fsync + persistSequence to recover buffered events.
1123
+ flushBufferedQueuesSync();
1076
1124
  asyncQueues.clear();
1077
1125
  });
1078
1126
 
@@ -1099,20 +1147,18 @@ process.on("beforeExit", async () => {
1099
1147
  }
1100
1148
  }
1101
1149
  });
1102
- process.on("SIGTERM", () => setImmediate(() => flushEventLogBuffer()));
1103
- process.on("SIGINT", () => setImmediate(() => flushEventLogBuffer()));
1150
+ process.on("SIGTERM", () => setImmediate(() => flushBufferedQueuesSync()));
1151
+ process.on("SIGINT", () => setImmediate(() => flushBufferedQueuesSync()));
1104
1152
  // FIX (Issue 1): Handle uncaught exceptions to flush buffered events before
1105
1153
  // the process terminates. The async queues use promise chains that will be
1106
1154
  // abandoned on crash; clearing the map prevents memory leaks and stale state.
1107
1155
  // Note: SIGKILL (kill -9) cannot be intercepted and is not handled.
1108
1156
  process.on("uncaughtException", (error) => {
1109
- try {
1110
- flushEventLogBuffer();
1111
- } catch {
1112
- /* best-effort */
1113
- }
1114
- // FIX (Issue 1): Drain asyncQueues before clearing to minimize event loss.
1115
- drainAsyncQueues();
1157
+ // EL-2: synchronously flush buffered events before re-throwing (which
1158
+ // terminates the process). The previous async flushEventLogBuffer() +
1159
+ // drainAsyncQueues() couldn't complete before process exit; use the sync
1160
+ // variant to recover buffered events.
1161
+ flushBufferedQueuesSync();
1116
1162
  asyncQueues.clear();
1117
1163
  // Re-throw to preserve default uncaught exception behavior (process exit)
1118
1164
  throw error;