gcf-board 0.2.16 → 0.2.17

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.
@@ -90,8 +90,13 @@ async function sendPage(res, web, pathname) {
90
90
  }
91
91
  export function createApp(o) {
92
92
  const watchers = new Map();
93
+ let closed = false;
93
94
  const watchAll = async () => {
94
- for (const p of await listProjects())
95
+ const projects = await listProjects();
96
+ // A watcher set up after the server closed would keep the process alive.
97
+ if (closed)
98
+ return;
99
+ for (const p of projects)
95
100
  if (!watchers.has(p.name))
96
101
  watchers.set(p.name, watchProject(p));
97
102
  };
@@ -303,15 +308,18 @@ export function createApp(o) {
303
308
  const server = createServer((req, res) => {
304
309
  handle(req, res).catch((e) => {
305
310
  if (res.headersSent)
306
- return res.end();
307
- send(res, e instanceof HttpError ? e.status : 500, { error: e.message });
311
+ res.end();
312
+ else
313
+ send(res, e instanceof HttpError ? e.status : 500, { error: e.message });
308
314
  });
309
315
  });
310
316
  // A broken projects.json must not take the server down: the page shows the error instead.
311
317
  server.on('listening', () => watchAll().catch((e) => console.error(`gcf-board: ${e.message}`)));
312
318
  server.on('close', () => {
319
+ closed = true;
313
320
  for (const stop of watchers.values())
314
321
  stop();
322
+ watchers.clear();
315
323
  });
316
324
  return server;
317
325
  }
@@ -1,7 +1,8 @@
1
1
  // The board starts agents that commit and push: whoever talks to it runs code on this machine.
2
- // It listens on 127.0.0.1 only, and every request needs the token printed at start (kept in a
3
- // cookie after the first visit). Host and Origin are checked too: a page in another tab can
4
- // reach 127.0.0.1 (DNS rebinding, a cross-site POST), but it cannot fake those headers.
2
+ // It listens on 127.0.0.1 only. The URL printed at start carries the token; that visit gets a
3
+ // session cookie (one per server process, never the token itself), and every request needs it.
4
+ // Host and Origin are checked too: a page in another tab can reach 127.0.0.1 (DNS rebinding, a
5
+ // cross-site POST), but it cannot fake those headers.
5
6
  import { randomBytes, timingSafeEqual } from 'node:crypto';
6
7
  import { existsSync } from 'node:fs';
7
8
  import { mkdir, readFile } from 'node:fs/promises';
@@ -71,6 +71,8 @@ export function runCli(entry, cwd, args, timeoutMs = 120_000) {
71
71
  spawnSync('taskkill', ['/pid', String(child.pid), '/T', '/F'], { stdio: 'ignore' });
72
72
  else
73
73
  child.kill();
74
+ // A grandchild holding the pipes keeps close from firing: the answer does not wait for it.
75
+ setTimeout(() => resolve({ ok: false, code: null, out, err }), 5_000).unref();
74
76
  }, timeoutMs);
75
77
  child.on('error', (e) => {
76
78
  clearTimeout(timer);
@@ -127,8 +127,9 @@ function schedule(run, at) {
127
127
  catch (e) {
128
128
  // The board's own run of an item takes it out of the schedule first, so this is rare;
129
129
  // the queue still goes on.
130
+ const why = e instanceof Error ? e.message : String(e);
130
131
  if (s)
131
- trace(project, 'resume-failed', `${s.run.ids.join(', ')}: ${e.message}`);
132
+ trace(project, 'resume-failed', `${s.run.ids.join(', ')}: ${why}`);
132
133
  advance(project);
133
134
  }
134
135
  broadcast({ type: 'board', project });
@@ -142,9 +143,7 @@ function schedule(run, at) {
142
143
  * report.
143
144
  */
144
145
  function limited(run, result) {
145
- const items = result
146
- ?.items;
147
- const hit = (items ?? []).filter((i) => i.limit_resets !== undefined);
146
+ const hit = (result?.items ?? []).filter((i) => i.limit_resets !== undefined);
148
147
  if (!hit.length)
149
148
  return '';
150
149
  const project = run.p.name;
@@ -204,6 +203,34 @@ export function stalled(ok, result) {
204
203
  const items = result?.items ?? [];
205
204
  return (items.length > 0 && items.every((i) => /another run works in the main/.test(i.error ?? '')));
206
205
  }
206
+ /**
207
+ * The harness report a run printed, or null for none. A report of another shape counts as none:
208
+ * the close listener that reads it would throw, and take the server down with it.
209
+ */
210
+ export function harnessReport(out) {
211
+ let r;
212
+ try {
213
+ r = JSON.parse(out);
214
+ }
215
+ catch {
216
+ // No report: the error says why.
217
+ return null;
218
+ }
219
+ if (typeof r !== 'object' || r === null || Array.isArray(r))
220
+ return null;
221
+ const { gate, items, orphans } = r;
222
+ const list = (v) => v === undefined || (Array.isArray(v) && v.every(isItem));
223
+ return (gate === undefined || typeof gate === 'string') && list(items) && list(orphans)
224
+ ? r
225
+ : null;
226
+ }
227
+ // The fields read as more than text: the id, and the reset a resume is scheduled at.
228
+ function isItem(i) {
229
+ if (typeof i !== 'object' || i === null)
230
+ return false;
231
+ const { id, limit_resets: resets } = i;
232
+ return typeof id === 'string' && (resets == null || typeof resets === 'string');
233
+ }
207
234
  function spawnRun(q, mode) {
208
235
  const { entry, p, ids } = q;
209
236
  const run = { project: p.name, ids, started: new Date().toISOString(), mode };
@@ -224,13 +251,7 @@ function spawnRun(q, mode) {
224
251
  // A spawn that fails fires both error and close: one end only.
225
252
  if (!active.delete(run))
226
253
  return;
227
- let result = null;
228
- try {
229
- result = JSON.parse(out);
230
- }
231
- catch {
232
- // No report: the error says why.
233
- }
254
+ const result = harnessReport(out);
234
255
  // A run of a project removed meanwhile only reports: it pauses and schedules nothing.
235
256
  const kept = !forgotten.has(run);
236
257
  const stop = kept && mode === 'main' && queues.get(p.name)?.length && stalled(ok, result);
@@ -244,8 +265,7 @@ function spawnRun(q, mode) {
244
265
  (stop ? 'the queue is paused: fix the cause, then resume it on the board' : '');
245
266
  if (stop)
246
267
  trace(p.name, 'queue-paused', `after ${ids.join(', ')}: ${note}`);
247
- const handed = result?.items;
248
- for (const i of handed ?? [])
268
+ for (const i of result?.items ?? [])
249
269
  if (kept && i.handoffs)
250
270
  trace(p.name, 'handoff', `${i.id}: ${i.handoffs} context handoff(s) to a fresh session`);
251
271
  const brief = err && briefError(err);