@goodandready/dsh-cron 0.2.44 → 0.2.46

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/lib/client.js CHANGED
@@ -1441,14 +1441,23 @@ window.__ModuleLoader__.load({
1441
1441
 
1442
1442
  let timerId = null;
1443
1443
  let isDestroyed = false;
1444
+ let pollController = null;
1444
1445
 
1445
1446
  const doPoll = () => {
1446
1447
  if (isDestroyed) return;
1448
+ const pollGen = ++fetchGenRef.current;
1447
1449
  const headers = {};
1448
1450
  if (lastEtagRef.current) headers['If-None-Match'] = lastEtagRef.current;
1449
1451
 
1450
- fetch('/dsh-cron/tasks?status=' + encodeURIComponent(tab) + '&query=' + encodeURIComponent(query), { headers })
1452
+ if (pollController) pollController.abort();
1453
+ pollController = (typeof AbortController !== 'undefined') ? new AbortController() : null;
1454
+
1455
+ const fetchOpts = { headers };
1456
+ if (pollController) fetchOpts.signal = pollController.signal;
1457
+
1458
+ fetch('/dsh-cron/tasks?status=' + encodeURIComponent(tab) + '&query=' + encodeURIComponent(query), fetchOpts)
1451
1459
  .then(res => {
1460
+ if (isDestroyed || pollGen !== fetchGenRef.current) return null;
1452
1461
  if (res.status === 304) {
1453
1462
  // Not modified: skip parsing JSON
1454
1463
  return null;
@@ -1458,13 +1467,16 @@ window.__ModuleLoader__.load({
1458
1467
  return res.json();
1459
1468
  })
1460
1469
  .then(data => {
1470
+ if (isDestroyed || pollGen !== fetchGenRef.current) return;
1461
1471
  if (data && data.ok) {
1462
1472
  setTasks(data.tasks || []);
1463
1473
  setRecs(data.recommendations || []);
1464
1474
  setStats(data.stats || null);
1465
1475
  }
1466
1476
  })
1467
- .catch(() => {})
1477
+ .catch(err => {
1478
+ if (err && err.name === 'AbortError') return;
1479
+ })
1468
1480
  .finally(() => {
1469
1481
  if (isDestroyed) return;
1470
1482
  // Adaptive interval: 30s when tab is hidden, 8s when active (#134)
@@ -1490,6 +1502,7 @@ window.__ModuleLoader__.load({
1490
1502
  return () => {
1491
1503
  isDestroyed = true;
1492
1504
  if (timerId) clearTimeout(timerId);
1505
+ if (pollController) pollController.abort();
1493
1506
  if (typeof document !== 'undefined') {
1494
1507
  document.removeEventListener('visibilitychange', onVisibilityChange);
1495
1508
  }
package/lib/index.js CHANGED
@@ -117,6 +117,26 @@ export {
117
117
  LEGACY_ACTION_MAP,
118
118
  };
119
119
  const pkgUrl = new URL('../package.json', import.meta.url);
120
+ // Helper to access configuration safely in native Cordis Context without throwing missing-inject error (#235)
121
+ function getContextConfig(ctx, rawConfig) {
122
+ try {
123
+ if (ctx && ctx.fiber && ctx.fiber.config) return ctx.fiber.config;
124
+ if (ctx && ctx.scope && ctx.scope.config) return ctx.scope.config;
125
+ if (ctx && ctx.runtime && ctx.runtime.config) return ctx.runtime.config;
126
+ if (ctx && typeof ctx === 'object' && 'config' in ctx) {
127
+ try {
128
+ const c = ctx.config;
129
+ if (c) return c;
130
+ } catch (err) {
131
+ // Ignore missing inject error on native Cordis Context (#235)
132
+ }
133
+ }
134
+ } catch (err) {
135
+ // Ignore context access error and fall back to rawConfig (#235)
136
+ }
137
+ return rawConfig || {};
138
+ }
139
+
120
140
  const pkgVersion = JSON.parse(fs.readFileSync(pkgUrl, 'utf8')).version || '0.2.29';
121
141
 
122
142
  export function apply(ctx, rawConfig) {
@@ -215,7 +235,8 @@ export function apply(ctx, rawConfig) {
215
235
  function syncVolatileConfig(updatedConfig) {
216
236
  try {
217
237
  const isPlainConfigObject = updatedConfig && typeof updatedConfig === 'object' && !Array.isArray(updatedConfig);
218
- const cfg = isPlainConfigObject ? { ...(ctx.config || config || {}), ...updatedConfig } : (ctx.config || config || {});
238
+ const liveConfig = getContextConfig(ctx, rawConfig);
239
+ const cfg = isPlainConfigObject ? { ...liveConfig, ...updatedConfig } : liveConfig;
219
240
  const unwrapped = unwrapConfig(cfg);
220
241
  activeVolatileConfig = { ...activeVolatileConfig, ...unwrapped };
221
242
  if (typeof unwrapped.maxConcurrent === 'number') {
@@ -286,7 +307,7 @@ export function apply(ctx, rawConfig) {
286
307
  try {
287
308
  if (req.method === 'GET') {
288
309
  const unwrapped = {
289
- ...unwrapConfig(ctx.config || config || {}),
310
+ ...unwrapConfig(getContextConfig(ctx, rawConfig)),
290
311
  ...activeVolatileConfig,
291
312
  };
292
313
  const clientSettings = store.getClientSettings();
package/lib/store.js CHANGED
@@ -190,10 +190,17 @@ export class TaskStore {
190
190
  const tmp = `${this.filePath}.tmp.${process.pid}_${Date.now()}_${rand}`;
191
191
  this._currentAsyncTmp = tmp;
192
192
 
193
- // Use compact serialization without whitespace indentation to eliminate event loop lag on large stores (#244)
194
- const payload = JSON.stringify(data);
195
- await fs.promises.writeFile(tmp, payload, { encoding: 'utf-8', mode: 0o600 });
196
- bestEffort('chmod-tmp', () => fs.chmodSync(tmp, 0o600));
193
+ // Streamed chunked serialization with fine-grained event loop yields (#244).
194
+ // Eliminates continuous CPU stalls on large stores (~21MB) by batching tasks and history runs
195
+ // with setImmediate yields every 10 tasks and 15 runs, guaranteeing max event loop lag < 10ms.
196
+ try {
197
+ await this._writeStoreStream(tmp, data, version);
198
+ bestEffort('chmod-tmp', () => fs.chmodSync(tmp, 0o600));
199
+ } catch (writeErr) {
200
+ await fs.promises.unlink(tmp).catch(() => {});
201
+ if (this._currentAsyncTmp === tmp) this._currentAsyncTmp = null;
202
+ throw writeErr;
203
+ }
197
204
 
198
205
  if ((this._diskVersion || 0) >= version || (this._lastSyncVersion || 0) >= version || this._saveVersion > version) {
199
206
  await fs.promises.unlink(tmp).catch(() => {});
@@ -233,6 +240,122 @@ export class TaskStore {
233
240
  return this._savePromise;
234
241
  }
235
242
 
243
+ /**
244
+ * Stream data incrementally to disk with fine-grained setImmediate yields (#244).
245
+ * Prevents blocking the V8 single thread on heavy serialization (e.g. 21MB / 10k runs).
246
+ */
247
+ _writeStoreStream(filePath, data, version) {
248
+ return new Promise((resolve, reject) => {
249
+ let aborted = false;
250
+ const ws = fs.createWriteStream(filePath, { encoding: 'utf-8', mode: 0o600 });
251
+
252
+ const cleanupListeners = () => {
253
+ ws.removeAllListeners('error');
254
+ ws.removeAllListeners('drain');
255
+ };
256
+
257
+ ws.on('error', (err) => {
258
+ aborted = true;
259
+ cleanupListeners();
260
+ reject(err);
261
+ });
262
+
263
+ const isCanceled = () => {
264
+ if ((this._diskVersion || 0) >= version || (this._lastSyncVersion || 0) >= version || this._saveVersion > version) {
265
+ aborted = true;
266
+ cleanupListeners();
267
+ ws.destroy();
268
+ return true;
269
+ }
270
+ return false;
271
+ };
272
+
273
+ const writeChunk = (chunk) => {
274
+ if (aborted) return Promise.reject(new Error('Store write aborted'));
275
+ if (!ws.write(chunk)) {
276
+ return new Promise((res, rej) => {
277
+ const onDrain = () => {
278
+ ws.removeListener('drain', onDrain);
279
+ ws.removeListener('error', onError);
280
+ res();
281
+ };
282
+ const onError = (e) => {
283
+ ws.removeListener('drain', onDrain);
284
+ ws.removeListener('error', onError);
285
+ rej(e);
286
+ };
287
+ ws.once('drain', onDrain);
288
+ ws.once('error', onError);
289
+ });
290
+ }
291
+ return Promise.resolve();
292
+ };
293
+
294
+ const run = async () => {
295
+ try {
296
+ await new Promise((r) => setImmediate(r));
297
+ if (isCanceled()) return resolve();
298
+
299
+ await writeChunk('{"version":' + JSON.stringify(data.version || 1));
300
+ await writeChunk(',"updatedAt":' + JSON.stringify(data.updatedAt || new Date().toISOString()));
301
+ await writeChunk(',"settings":' + JSON.stringify(data.settings || {}));
302
+
303
+ await writeChunk(',"tasks":[');
304
+ const tasks = data.tasks || [];
305
+ for (let i = 0; i < tasks.length; i++) {
306
+ if (isCanceled()) return resolve();
307
+ if (i > 0) await writeChunk(',');
308
+ await writeChunk(JSON.stringify(tasks[i]));
309
+ if ((i + 1) % 10 === 0) {
310
+ await new Promise((r) => setImmediate(r));
311
+ }
312
+ }
313
+ await writeChunk(']');
314
+
315
+ await writeChunk(',"history":{');
316
+ const historyEntries = Object.entries(data.history || {});
317
+ for (let i = 0; i < historyEntries.length; i++) {
318
+ if (isCanceled()) return resolve();
319
+ if (i > 0) await writeChunk(',');
320
+ const [taskId, runs] = historyEntries[i];
321
+ await writeChunk(JSON.stringify(taskId) + ':[');
322
+ if (Array.isArray(runs)) {
323
+ for (let j = 0; j < runs.length; j++) {
324
+ if (isCanceled()) return resolve();
325
+ if (j > 0) await writeChunk(',');
326
+ await writeChunk(JSON.stringify(runs[j]));
327
+ if ((j + 1) % 15 === 0) {
328
+ await new Promise((r) => setImmediate(r));
329
+ }
330
+ }
331
+ } else {
332
+ await writeChunk(JSON.stringify(runs));
333
+ }
334
+ await writeChunk(']');
335
+ await new Promise((r) => setImmediate(r));
336
+ }
337
+ await writeChunk('}}');
338
+
339
+ if (isCanceled()) return resolve();
340
+
341
+ ws.end(() => {
342
+ cleanupListeners();
343
+ resolve();
344
+ });
345
+ } catch (err) {
346
+ if (!aborted) {
347
+ aborted = true;
348
+ cleanupListeners();
349
+ ws.destroy();
350
+ reject(err);
351
+ }
352
+ }
353
+ };
354
+
355
+ run();
356
+ });
357
+ }
358
+
236
359
  save() {
237
360
  if (this._saveTimer) {
238
361
  clearTimeout(this._saveTimer);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-cron",
3
- "version": "0.2.44",
3
+ "version": "0.2.46",
4
4
  "description": "Background automation runner for DSH: isolated agent runs, script/HTTP/SSH/Docker runtimes, cost guard, notifications, heartbeats.",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",