Skip to content

Commit 2427573

Browse files
feat: make timeouts configurable at global and per repo levels (#29)
* feat: make timeout configurable * Add changeset
1 parent cd73ead commit 2427573

File tree

8 files changed

+74
-15
lines changed

8 files changed

+74
-15
lines changed

.changeset/grumpy-pears-agree.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"layne": minor
3+
---
4+
5+
Makes timeouts configurable on global and per repo levels

src/__tests__/config.test.js

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -380,4 +380,30 @@ describe('loadScanConfig()', () => {
380380
expect(config.mode).toBe('diff_only');
381381
expect(config.contextLines).toBe(4);
382382
});
383+
384+
// --- timeoutMinutes ---
385+
386+
it('returns timeoutMinutes 10 by default', async () => {
387+
vi.mocked(readFile).mockResolvedValueOnce(JSON.stringify({}));
388+
const config = await loadScanConfig({ owner: 'org', repo: 'repo' });
389+
expect(config.timeoutMinutes).toBe(10);
390+
});
391+
392+
it('inherits $global timeoutMinutes when the repo has no timeoutMinutes', async () => {
393+
vi.mocked(readFile).mockResolvedValueOnce(JSON.stringify({
394+
'$global': { timeoutMinutes: 20 },
395+
'acme/frontend': { semgrep: { extraArgs: ['--config', 'auto'] } },
396+
}));
397+
const config = await loadScanConfig({ owner: 'acme', repo: 'frontend' });
398+
expect(config.timeoutMinutes).toBe(20);
399+
});
400+
401+
it('repo-level timeoutMinutes overrides $global timeoutMinutes', async () => {
402+
vi.mocked(readFile).mockResolvedValueOnce(JSON.stringify({
403+
'$global': { timeoutMinutes: 20 },
404+
'acme/monorepo': { timeoutMinutes: 30 },
405+
}));
406+
const config = await loadScanConfig({ owner: 'acme', repo: 'monorepo' });
407+
expect(config.timeoutMinutes).toBe(30);
408+
});
383409
});

src/__tests__/worker.test.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,7 @@ vi.mock('../config.js', () => ({
7272
loadScanConfig: vi.fn().mockResolvedValue({
7373
mode: 'changed_files',
7474
contextLines: 8,
75+
timeoutMinutes: 10,
7576
semgrep: { enabled: true, extraArgs: ['--config', 'auto'] },
7677
trufflehog: { enabled: true, extraArgs: [] },
7778
claude: { enabled: false, model: 'claude-haiku-4-5-20251001' },

src/config-validator.js

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,8 @@
88
* directly via `npm run validate-config`.
99
*/
1010

11-
const KNOWN_REPO_KEYS = new Set(['mode', 'contextLines', 'semgrep', 'trufflehog', 'claude', 'notifications', 'labels', 'trigger', 'comment', 'exceptionApprovers']);
12-
const KNOWN_GLOBAL_KEYS = new Set(['mode', 'contextLines', 'notifications', 'labels', 'trigger', 'comment', 'exceptionApprovers']);
11+
const KNOWN_REPO_KEYS = new Set(['mode', 'contextLines', 'timeoutMinutes', 'semgrep', 'trufflehog', 'claude', 'notifications', 'labels', 'trigger', 'comment', 'exceptionApprovers']);
12+
const KNOWN_GLOBAL_KEYS = new Set(['mode', 'contextLines', 'timeoutMinutes', 'notifications', 'labels', 'trigger', 'comment', 'exceptionApprovers']);
1313
const VALID_MODES = new Set(['changed_files', 'diff_only']);
1414
const VALID_TRIGGER_ONS = new Set(['pull_request', 'workflow_run', 'workflow_job']);
1515
const VALID_CONCLUSIONS = new Set(['success', 'failure', 'neutral', 'cancelled', 'skipped', 'timed_out', 'action_required']);
@@ -83,6 +83,10 @@ function validateScanMode(block, ctx, errors) {
8383
if (!Number.isInteger(block.contextLines) || block.contextLines < 0)
8484
errors.push(`${ctx}.contextLines: must be a non-negative integer`);
8585
}
86+
if (block.timeoutMinutes !== undefined) {
87+
if (!Number.isInteger(block.timeoutMinutes) || block.timeoutMinutes < 1)
88+
errors.push(`${ctx}.timeoutMinutes: must be a positive integer`);
89+
}
8690
}
8791

8892
function validateScanner(block, ctx, errors) {

src/config.js

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,9 @@ const __dirname = dirname(fileURLToPath(import.meta.url));
77
const REPOS_CONFIG_PATH = join(__dirname, '..', 'config', 'layne.json');
88

99
export const DEFAULT_CONFIG = Object.freeze({
10-
mode: 'changed_files',
11-
contextLines: 8,
10+
mode: 'changed_files',
11+
contextLines: 8,
12+
timeoutMinutes: 10,
1213
semgrep: Object.freeze({
1314
enabled: true,
1415
extraArgs: ['--config', 'auto'],
@@ -78,8 +79,9 @@ export async function loadScanConfig({ owner, repo }) {
7879
const repoExceptionApprovers = repoOverrides.exceptionApprovers ?? null;
7980

8081
return {
81-
mode: repoOverrides.mode ?? reposConfig['$global']?.mode ?? DEFAULT_CONFIG.mode,
82-
contextLines: repoOverrides.contextLines ?? reposConfig['$global']?.contextLines ?? DEFAULT_CONFIG.contextLines,
82+
mode: repoOverrides.mode ?? reposConfig['$global']?.mode ?? DEFAULT_CONFIG.mode,
83+
contextLines: repoOverrides.contextLines ?? reposConfig['$global']?.contextLines ?? DEFAULT_CONFIG.contextLines,
84+
timeoutMinutes: repoOverrides.timeoutMinutes ?? reposConfig['$global']?.timeoutMinutes ?? DEFAULT_CONFIG.timeoutMinutes,
8385
semgrep: { ...DEFAULT_CONFIG.semgrep, ...(repoOverrides.semgrep ?? {}) },
8486
trufflehog: { ...DEFAULT_CONFIG.trufflehog, ...(repoOverrides.trufflehog ?? {}) },
8587
claude: { ...DEFAULT_CONFIG.claude, ...(repoOverrides.claude ?? {}) },

src/worker.js

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,6 @@ import {
3131
queueFailed,
3232
} from './metrics.js';
3333

34-
const SCAN_TIMEOUT_MS = 10 * 60 * 1000; // 10 minutes
3534
const NOTIFY_COUNT_TTL = 30 * 24 * 60 * 60; // 30 days in seconds
3635
const METRICS_ENABLED = process.env.METRICS_ENABLED === 'true';
3736
const METRICS_PORT = parseInt(process.env.METRICS_PORT ?? '9091', 10);
@@ -83,20 +82,24 @@ function appendDiscardedCandidateSummary(summary, discardedCount) {
8382
* without needing a live BullMQ worker or Redis connection.
8483
*/
8584
export async function processJob(job) {
85+
const { owner, repo } = job.data;
86+
const scanConfig = await loadScanConfig({ owner, repo });
87+
const timeoutMs = scanConfig.timeoutMinutes * 60 * 1000;
88+
8689
// Resolving sentinel rather than a rejecting promise so there is no risk of an
8790
// unhandled rejection if the race settles before handlers attach.
8891
let timer;
8992
const timeoutSentinel = Symbol('timeout');
9093
const timeoutPromise = new Promise(resolve => {
91-
timer = setTimeout(() => resolve(timeoutSentinel), SCAN_TIMEOUT_MS);
94+
timer = setTimeout(() => resolve(timeoutSentinel), timeoutMs);
9295
});
9396

9497
try {
95-
const result = await Promise.race([runScan(job).then(() => null), timeoutPromise]);
98+
const result = await Promise.race([runScan(job, scanConfig).then(() => null), timeoutPromise]);
9699

97100
if (result === timeoutSentinel) {
98101
scanTimeoutsTotal.inc();
99-
throw new Error(`Scan timed out after ${SCAN_TIMEOUT_MS / 60000} minutes`);
102+
throw new Error(`Scan timed out after ${timeoutMs / 60000} minutes`);
100103
}
101104
} catch (err) {
102105
const safeMessage = sanitizeError(err.message);
@@ -134,7 +137,7 @@ export async function processJob(job) {
134137
}
135138
}
136139

137-
async function runScan(job) {
140+
async function runScan(job, scanConfig) {
138141
const {
139142
installationId,
140143
owner,
@@ -174,8 +177,6 @@ async function runScan(job) {
174177
const changedLineRanges = await getChangedLineRanges({ workspacePath, baseSha: mergeBaseSha, headSha });
175178
const changedFiles = await checkoutFiles({ workspacePath, headSha, files: rawChanged });
176179

177-
const scanConfig = await loadScanConfig({ owner, repo });
178-
179180
const scanContext = await createScanContext({ workspacePath, changedFiles, baseSha: mergeBaseSha, headSha, scanConfig });
180181
debug('worker', `dispatching ${scanContext.scanFiles.length} file(s) to scanners (mode: ${scanContext.mode})`);
181182

website/docs/configuration.md

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,7 @@ Not all keys merge the same way when a per-repo entry overrides `$global`. The r
6464

6565
| Key | How per-repo overrides `$global` |
6666
|---|---|
67-
| `mode`, `contextLines` | Per-repo value replaces global value |
67+
| `mode`, `contextLines`, `timeoutMinutes` | Per-repo value replaces global value |
6868
| `semgrep`, `trufflehog`, `claude` | Merged at the key level - per-repo values overwrite matching keys, unset keys inherit from global |
6969
| `trigger` | Full replacement - per-repo `trigger` replaces the global block entirely |
7070
| `labels` | Full replacement - per-repo `labels` replaces the global block entirely |
@@ -106,6 +106,26 @@ Number of surrounding lines to include around each changed hunk when `mode` is `
106106
- **Default:** `8`
107107
- Ignored when `mode` is `"changed_files"`
108108

109+
### `timeoutMinutes`
110+
111+
Hard time limit for a single scan job. If the limit is reached, the job is rethrown so BullMQ can retry it. The Check Run is only marked as failed on the final attempt.
112+
113+
- **Default:** `10`
114+
- Accepts any positive integer
115+
116+
Raise this for large monorepos where Semgrep takes a long time, or lower it to fail fast on repos that should scan quickly.
117+
118+
```json title="config/layne.json"
119+
{
120+
"$global": {
121+
"timeoutMinutes": 10
122+
},
123+
"org/monorepo": {
124+
"timeoutMinutes": 25
125+
}
126+
}
127+
```
128+
109129
### Examples
110130

111131
**Use `diff_only` globally, fall back to full scan for a compliance-critical repo:**

website/docs/reference.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ For how findings are converted to GitHub annotations and how severities affect P
3535

3636
## Scan timeout
3737

38-
Each job has a hard 10-minute timeout. If a scan exceeds this limit:
38+
Each job has a configurable timeout (default 10 minutes, controlled by [`timeoutMinutes`](configuration.md#timeoutminutes) in `layne.json`). If a scan exceeds this limit:
3939
- The job is rethrown so BullMQ can retry it
4040
- The Check Run is only marked as failed on the **final** attempt (not on intermediate retries)
4141
- `layne_scan_timeouts_total` is incremented (when metrics are enabled)

0 commit comments

Comments
 (0)