pi-better-background-tasks 0.2.20 → 0.4.0

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.
package/src/process.ts CHANGED
@@ -183,14 +183,15 @@ export function runCommandOnce(
183
183
  validateCommandSpec(spec);
184
184
  const startedAt = Date.now();
185
185
  const child = spawnArgs(spec, true, ["ignore", "pipe", "pipe"]);
186
- let stdout = "";
187
- let stderr = "";
186
+ const cap = Math.max(1, Math.floor(maxBufferBytes));
187
+ const stdoutCapture = createCaptureBuffer();
188
+ const stderrCapture = createCaptureBuffer();
188
189
  let timedOut = false;
189
- child.stdout?.on("data", (chunk: Buffer) => {
190
- if (Buffer.byteLength(stdout) < maxBufferBytes) stdout += chunk.toString("utf8");
190
+ child.stdout?.on("data", (chunk: Buffer | string) => {
191
+ captureChunk(stdoutCapture, chunk, cap);
191
192
  });
192
- child.stderr?.on("data", (chunk: Buffer) => {
193
- if (Buffer.byteLength(stderr) < maxBufferBytes) stderr += chunk.toString("utf8");
193
+ child.stderr?.on("data", (chunk: Buffer | string) => {
194
+ captureChunk(stderrCapture, chunk, cap);
194
195
  });
195
196
  return new Promise((resolve, reject) => {
196
197
  // The group outlives the leader as long as any member is alive, so the
@@ -239,19 +240,105 @@ export function runCommandOnce(
239
240
  });
240
241
  child.on("close", (exitCode, signal) => {
241
242
  settle();
243
+ const stdout = finishCapture(stdoutCapture);
244
+ const stderr = finishCapture(stderrCapture);
245
+ const captureTruncated = stdout.discardedBytes > 0 || stderr.discardedBytes > 0;
242
246
  resolve({
243
247
  exitCode,
244
248
  signal,
245
- stdout,
246
- stderr,
249
+ stdout: stdout.text,
250
+ stderr: stderr.text,
247
251
  startedAt,
248
252
  endedAt: Date.now(),
249
253
  ...(timedOut ? { timedOut: true } : {}),
254
+ ...(stdout.discardedBytes > 0 ? { stdoutDiscardedBytes: stdout.discardedBytes } : {}),
255
+ ...(stderr.discardedBytes > 0 ? { stderrDiscardedBytes: stderr.discardedBytes } : {}),
256
+ ...(captureTruncated ? { captureTruncated: true } : {}),
250
257
  });
251
258
  });
252
259
  });
253
260
  }
254
261
 
262
+ interface CaptureBuffer {
263
+ chunks: Buffer[];
264
+ storedBytes: number;
265
+ discardedBytes: number;
266
+ truncated: boolean;
267
+ }
268
+
269
+ function createCaptureBuffer(): CaptureBuffer {
270
+ return { chunks: [], storedBytes: 0, discardedBytes: 0, truncated: false };
271
+ }
272
+
273
+ function asBuffer(chunk: Buffer | string): Buffer {
274
+ return Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
275
+ }
276
+
277
+ /** Largest UTF-8 prefix of `buffer` that fits in `maxBytes`. */
278
+ export function utf8PrefixLength(buffer: Buffer, maxBytes: number): number {
279
+ if (maxBytes <= 0) return 0;
280
+ if (buffer.length <= maxBytes) return buffer.length;
281
+ let end = maxBytes;
282
+ while (end > 0 && (buffer[end]! & 0xc0) === 0x80) end -= 1;
283
+ if (end === 0 && (buffer[0]! & 0xc0) === 0x80) return 0;
284
+ const lead = buffer[end]!;
285
+ const need = lead <= 0x7f ? 1
286
+ : (lead & 0xe0) === 0xc0 ? 2
287
+ : (lead & 0xf0) === 0xe0 ? 3
288
+ : (lead & 0xf8) === 0xf0 ? 4
289
+ : 1;
290
+ return end + need <= maxBytes ? end + need : end;
291
+ }
292
+
293
+ function captureChunk(target: CaptureBuffer, chunk: Buffer | string, maxBytes: number): void {
294
+ const buffer = asBuffer(chunk);
295
+ if (target.truncated) {
296
+ target.discardedBytes += buffer.length;
297
+ return;
298
+ }
299
+ const room = maxBytes - target.storedBytes;
300
+ if (buffer.length <= room) {
301
+ target.chunks.push(buffer);
302
+ target.storedBytes += buffer.length;
303
+ return;
304
+ }
305
+ const take = utf8PrefixLength(buffer, room);
306
+ if (take > 0) {
307
+ target.chunks.push(buffer.subarray(0, take));
308
+ target.storedBytes += take;
309
+ }
310
+ target.discardedBytes += buffer.length - take;
311
+ target.truncated = true;
312
+ }
313
+
314
+ /**
315
+ * Length of `buffer` without a trailing UTF-8 sequence that the cap cut short.
316
+ * Chunk boundaries can split a code point; once capture overflows, the bytes
317
+ * that would have completed it were discarded, so the dangling lead bytes are
318
+ * discarded too instead of decoding to U+FFFD.
319
+ */
320
+ export function completeUtf8Length(buffer: Buffer): number {
321
+ let start = buffer.length - 1;
322
+ while (start >= 0 && buffer.length - start < 4 && (buffer[start]! & 0xc0) === 0x80) start -= 1;
323
+ if (start < 0) return buffer.length;
324
+ const lead = buffer[start]!;
325
+ const need = lead <= 0x7f ? 1
326
+ : (lead & 0xe0) === 0xc0 ? 2
327
+ : (lead & 0xf0) === 0xe0 ? 3
328
+ : (lead & 0xf8) === 0xf0 ? 4
329
+ : 1;
330
+ return start + need > buffer.length ? start : buffer.length;
331
+ }
332
+
333
+ function finishCapture(target: CaptureBuffer): { text: string; discardedBytes: number } {
334
+ const stored = Buffer.concat(target.chunks, target.storedBytes);
335
+ const keep = target.truncated ? completeUtf8Length(stored) : stored.length;
336
+ return {
337
+ text: stored.subarray(0, keep).toString("utf8"),
338
+ discardedBytes: target.discardedBytes + (stored.length - keep),
339
+ };
340
+ }
341
+
255
342
  /**
256
343
  * Stop a task's process tree.
257
344
  *
package/src/registry.ts CHANGED
@@ -92,15 +92,92 @@ export function onMetaChanged(listener: () => void): () => void {
92
92
  }
93
93
 
94
94
  export function readMeta(id: string): BackgroundTaskMeta | undefined {
95
+ return inspectMeta(id).meta;
96
+ }
97
+
98
+ export interface MetaInspection {
99
+ id: string;
100
+ meta?: BackgroundTaskMeta;
101
+ found: boolean;
102
+ readable: boolean;
103
+ error?: string;
104
+ }
105
+
106
+ function errorText(error: unknown): string {
107
+ return error instanceof Error ? error.message : String(error);
108
+ }
109
+
110
+ /** Parsed JSON that lacks the identifying fields is corrupt, not a task. */
111
+ function metaShapeProblem(meta: unknown, id: string): string | undefined {
112
+ if (!meta || typeof meta !== "object" || Array.isArray(meta)) return "not an object";
113
+ const value = meta as Partial<BackgroundTaskMeta>;
114
+ if (value.id !== id) return `id ${JSON.stringify(value.id ?? null)} does not match ${JSON.stringify(id)}`;
115
+ if (typeof value.status !== "string" || !value.status) return "missing status";
116
+ if (typeof value.logPath !== "string" || !value.logPath) return "missing logPath";
117
+ if (typeof value.startedAt !== "number" || !Number.isFinite(value.startedAt)) return "missing startedAt";
118
+ return undefined;
119
+ }
120
+
121
+ /**
122
+ * Distinguish missing, unreadable, and healthy metadata. Callers must not treat
123
+ * an unreadable file as proof that a task does not exist.
124
+ */
125
+ export function inspectMeta(id: string): MetaInspection {
126
+ const path = metaPathFor(id);
95
127
  try {
96
128
  registryIo.metadataFileReads += 1;
97
- const meta = JSON.parse(readFileSync(metaPathFor(id), "utf8")) as BackgroundTaskMeta;
98
- metaCache.set(id, meta);
99
- return meta;
100
- } catch {
129
+ const raw = readFileSync(path, "utf8");
130
+ try {
131
+ const meta = JSON.parse(raw) as BackgroundTaskMeta;
132
+ const invalid = metaShapeProblem(meta, id);
133
+ if (invalid) {
134
+ metaCache.delete(id);
135
+ return { id, found: true, readable: false, error: `invalid metadata: ${invalid}` };
136
+ }
137
+ metaCache.set(id, meta);
138
+ return { id, meta, found: true, readable: true };
139
+ } catch (error) {
140
+ metaCache.delete(id);
141
+ return { id, found: true, readable: false, error: `invalid JSON: ${errorText(error)}` };
142
+ }
143
+ } catch (error) {
101
144
  metaCache.delete(id);
102
- return undefined;
145
+ const code = (error as NodeJS.ErrnoException).code;
146
+ if (code === "ENOENT") {
147
+ try {
148
+ statSync(taskDir(id));
149
+ return { id, found: true, readable: false, error: "metadata file is missing" };
150
+ } catch {
151
+ return { id, found: false, readable: false };
152
+ }
153
+ }
154
+ return { id, found: true, readable: false, error: errorText(error) };
155
+ }
156
+ }
157
+
158
+ export interface TaskIndex {
159
+ records: MetaInspection[];
160
+ indexError?: string;
161
+ }
162
+
163
+ /** List every task directory, including unreadable metadata. */
164
+ export function listTaskRecords(): TaskIndex {
165
+ let ids: string[];
166
+ try {
167
+ registryIo.fullDirectoryReads += 1;
168
+ ids = readdirSync(tasksDir());
169
+ } catch (error) {
170
+ const code = (error as NodeJS.ErrnoException).code;
171
+ if (code === "ENOENT") return { records: [] };
172
+ return { records: [], indexError: errorText(error) };
103
173
  }
174
+ return {
175
+ records: ids.map((id) => inspectMeta(id)).sort((a, b) => {
176
+ const aTime = a.meta?.startedAt ?? 0;
177
+ const bTime = b.meta?.startedAt ?? 0;
178
+ return bTime - aTime;
179
+ }),
180
+ };
104
181
  }
105
182
 
106
183
  export function removeMeta(meta: BackgroundTaskMeta): boolean {
@@ -164,11 +241,11 @@ function readOwnedMeta(id: string): BackgroundTaskMeta | undefined {
164
241
  return metaCache.get(id) ?? readMeta(id);
165
242
  }
166
243
 
167
- function originOf(meta: BackgroundTaskMeta): BackgroundTaskCallbackOrigin {
244
+ export function originOf(meta: BackgroundTaskMeta): BackgroundTaskCallbackOrigin {
168
245
  return meta.callbackOrigin ?? { cwd: meta.cwd };
169
246
  }
170
247
 
171
- function belongsToOrigin(meta: BackgroundTaskMeta, origin: BackgroundTaskCallbackOrigin): boolean {
248
+ export function belongsToOrigin(meta: BackgroundTaskMeta, origin: BackgroundTaskCallbackOrigin): boolean {
172
249
  const candidate = originOf(meta);
173
250
  if (candidate.cwd !== origin.cwd) return false;
174
251
  if (candidate.sessionId || origin.sessionId) return candidate.sessionId === origin.sessionId;