conductor

CI task system
git clone git://git.finwo.net/app/conductor
Log | Files | Refs | README | LICENSE

commit 0e88af4ad9f9bad7da64710469749dd53dbf05cd
parent eb19ce1fda4f1cd3330dc53566ef88bf47e67b03
Author: finwo <finwo@pm.me>
Date:   Tue, 29 Sep 2026 11:28:37 +0200

Stop a worker holding tasks the conductor thinks are running

Diffstat:
Msrc/worker/agent.js | 19+++++++++++++++++++
Msrc/worker/task.js | 34++++++++++++++++++----------------
Mtest/worker.test.js | 145++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
3 files changed, 181 insertions(+), 17 deletions(-)

diff --git a/src/worker/agent.js b/src/worker/agent.js @@ -97,6 +97,19 @@ export async function main(argv = process.argv.slice(2)) { }; for (const signal of ['SIGINT', 'SIGTERM']) process.once(signal, () => shutdown(signal)); + async function handBack(task) { + try { + await client.done(task.endpoints.done, { + success: false, + state: 'failed', + exitCode: null, + error: 'worker shut down before the task started', + }); + } catch (e) { + logger.warn(`could not hand back ${task.name}: ${e.message}`); + } + } + // One loop per concurrency slot. Each polls independently, so a slow task // in one slot does not stall the others. async function slot(index) { @@ -122,6 +135,12 @@ export async function main(argv = process.argv.slice(2)) { continue; } + // Signal landed mid-claim; starting it would orphan it until the reaper. + if (stopping) { + await handBack(task); + return; + } + running.add(task.id); logger.info(`[${index}] ${task.name} (${task.image})`); const started = Date.now(); diff --git a/src/worker/task.js b/src/worker/task.js @@ -110,6 +110,22 @@ export async function runTask({ cfg, client, runtime, task, logger = console }) let failure = null; let flushScanners = null; + // Before setup: the reaper clock starts at claim, and a pull can outrun it. + const beatEvery = Math.max(5, Number(task.heartbeat_interval) || 30) * 1000; + heartbeat = setInterval(async () => { + try { + const result = await client.heartbeat(task.endpoints.heartbeat); + if (result.cancelled && !cancelled) { + cancelled = true; + log.note('[conductor] cancelled, stopping the container'); + if (containerCreated) await runtime.kill(taskContainer); + } + } catch (e) { + logger.warn?.(`heartbeat failed for ${task.name}: ${e.message}`); + } + }, beatEvery); + heartbeat.unref?.(); + try { scratch = await fs.mkdtemp(path.join(os.tmpdir(), 'conductor-task-')); @@ -165,6 +181,8 @@ export async function runTask({ cfg, client, runtime, task, logger = console }) await fs.writeFile(scriptPath, buildScript(task.script), { mode: 0o755 }); await runtime.copyIn(taskContainer, scriptPath, SCRIPT_PATH); + if (cancelled) throw new Error('cancelled during setup'); + const { child } = runtime.startCreated(taskContainer); // One per stream; the two interleave. @@ -175,22 +193,6 @@ export async function runTask({ cfg, client, runtime, task, logger = console }) for (const scanner of scanners) log.write(scanner.end()); }; - // Report in, and find out whether the run was cancelled underneath us. - const beatEvery = Math.max(5, Number(task.heartbeat_interval) || 30) * 1000; - heartbeat = setInterval(async () => { - try { - const result = await client.heartbeat(task.endpoints.heartbeat); - if (result.cancelled && !cancelled) { - cancelled = true; - log.note('[conductor] cancelled, stopping the container'); - await runtime.kill(taskContainer); - } - } catch (e) { - logger.warn?.(`heartbeat failed for ${task.name}: ${e.message}`); - } - }, beatEvery); - heartbeat.unref?.(); - const limit = (Number(task.timeout) || cfg.default_timeout) * 1000; timeoutTimer = setTimeout(async () => { timedOut = true; diff --git a/test/worker.test.js b/test/worker.test.js @@ -9,11 +9,15 @@ import os from 'node:os'; import path from 'node:path'; import { spawn } from 'node:child_process'; import { fileURLToPath } from 'node:url'; +import { EventEmitter } from 'node:events'; +import { PassThrough, Readable } from 'node:stream'; +import { gzipSync } from 'node:zlib'; +import { createServer } from 'node:http'; import { loadWorkerConfig, featureNames } from '../src/worker/config.js'; import { buildScript, shellQuote } from '../src/worker/script.js'; import { containerName, createRuntime } from '../src/worker/docker.js'; import { createLogStream } from '../src/worker/logstream.js'; -import { resolveFeatures, patternPrefix, copyOutPaths } from '../src/worker/task.js'; +import { resolveFeatures, patternPrefix, copyOutPaths, runTask } from '../src/worker/task.js'; import { collectArtifacts, shouldCollect } from '../src/worker/artifacts.js'; async function tempDir(prefix = 'conductor-worker-') { @@ -460,3 +464,142 @@ test('a failing conductor does not break the task', async () => { await log.close(); assert.match(log.error.message, /unreachable/); }); + +const quiet = { info: () => {}, warn: () => {}, error: () => {}, debug: () => {} }; + +function setupTask(overrides = {}) { + return { + id: 'task-setup', + name: 'build', + image: 'busybox', + sha: 'a'.repeat(40), + attempt: 1, + script: 'true', + env: {}, + masked: [], + timeout: 600, + heartbeat_interval: 5, + workdir: '/workspace', + endpoints: { + log: 'http://x/log', + source: 'http://x/source.tar.gz', + heartbeat: 'http://x/heartbeat', + done: 'http://x/done', + artifact: 'http://x/artifacts', + }, + ...overrides, + }; +} + +function exitingChild(code = 0) { + const child = new EventEmitter(); + child.stdout = new PassThrough(); + child.stderr = new PassThrough(); + setImmediate(() => { + child.stdout.end(); + child.stderr.end(); + child.emit('close', code); + }); + return child; +} + +test('a heartbeat goes out while a task is still being set up', async () => { + const beats = []; + const client = { + async appendLog(url, chunk, offset) { return { size: offset + chunk.length }; }, + async heartbeat() { beats.push(Date.now()); return { cancelled: false }; }, + async source() { return Readable.from([gzipSync(Buffer.alloc(0))]); }, + async done() { return {}; }, + }; + + // Stands in for an image pull that outlasts the conductor's reaper clock. + const runtime = { + async createNetwork() {}, + async removeNetwork() {}, + async create() { + const until = Date.now() + 8000; + while (Date.now() < until && beats.length === 0) { + await new Promise((r) => { setTimeout(r, 25); }); + } + }, + async copyStreamIn(name, stream) { + stream.resume(); + await new Promise((r) => { stream.on('end', r); stream.on('error', r); }); + }, + async copyIn() {}, + async copyOut() {}, + async kill() {}, + async remove() {}, + startCreated: () => ({ child: exitingChild(0), args: [] }), + }; + + const result = await runTask({ + cfg: { name: 'w', shell: 'sh', default_timeout: 600, features: {} }, + client, + runtime, + task: setupTask(), + logger: quiet, + }); + + assert.ok(beats.length > 0, 'the conductor heard nothing while the image was pulling'); + assert.equal(result.success, true); +}); + +test('a task claimed as the worker stops is handed back, not started', async () => { + const events = []; + let task = null; + const server = createServer((req, res) => { + events.push(req.url); + res.setHeader('content-type', 'application/json'); + if (req.url === '/api/v1/tasks/claim') { + // Slow enough that the signal lands while the claim is in flight. + setTimeout(() => res.end(JSON.stringify({ ok: true, data: { task } })), 1200); + return; + } + res.end(JSON.stringify({ ok: true, data: {} })); + }); + + await new Promise((r) => server.listen(0, '127.0.0.1', r)); + const port = server.address().port; + const at = (p) => `http://127.0.0.1:${port}${p}`; + task = setupTask({ + endpoints: { + log: at('/log'), + source: at('/source.tar.gz'), + heartbeat: at('/heartbeat'), + done: at('/done'), + artifact: at('/artifacts'), + }, + }); + + const dir = await tempDir(); + const config = path.join(dir, 'worker.json'); + await fs.writeFile(config, JSON.stringify({ + conductor_url: `http://127.0.0.1:${port}`, + token: 'irrelevant', + poll_interval: 60, + docker: 'true', + })); + + const agent = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../src/worker/agent.js'); + const child = spawn(process.execPath, [agent, '--config', config], { stdio: 'ignore' }); + + await new Promise((r) => { setTimeout(r, 700); }); + child.kill('SIGTERM'); + + const code = await new Promise((resolve) => { + const timer = setTimeout(() => resolve('timed out'), 10000); + child.on('exit', (c) => { clearTimeout(timer); resolve(c); }); + }); + child.kill('SIGKILL'); + server.close(); + + assert.equal(code, 0, 'should still exit cleanly'); + assert.ok(events.includes('/done'), `expected the task to be handed back, saw ${events.join(', ')}`); + assert.ok( + !events.includes('/source.tar.gz'), + `the task was started despite the signal, saw ${events.join(', ')}` + ); + + await fs.rm(dir, { recursive: true, force: true }); +});