plum-e2e 2.6.0 → 2.6.2
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/backend/app.js +4 -1
- package/backend/lib/retryRunner.js +61 -0
- package/backend/prisma/migrations/20260714070000_add_project_max_retries/migration.sql +2 -0
- package/backend/prisma/schema.prisma +1 -0
- package/backend/routes/node.routes.js +20 -0
- package/backend/routes/runners.routes.js +19 -10
- package/backend/routes/settings.routes.js +2 -2
- package/backend/routes/trigger.routes.js +103 -55
- package/backend/scripts/manage-runners.mjs +55 -20
- package/backend/server.js +21 -2
- package/backend/services/cronService.js +185 -70
- package/backend/services/reportService.js +74 -6
- package/backend/services/runnerService.js +25 -0
- package/backend/services/settingsService.js +3 -2
- package/backend/websockets/socketHandler.js +199 -90
- package/frontend/src/lib/api/runners.js +10 -0
- package/frontend/src/lib/api/settings.js +3 -3
- package/frontend/src/lib/constants.js +1 -0
- package/frontend/src/routes/reports/[id]/+page.svelte +8 -0
- package/frontend/src/routes/settings/+page.svelte +85 -3
- package/package.json +1 -1
package/backend/app.js
CHANGED
|
@@ -22,7 +22,10 @@ const { SCREENSHOTS_DIR } = require('./lib/reportFilename');
|
|
|
22
22
|
const app = express();
|
|
23
23
|
|
|
24
24
|
app.use(cors({ origin: '*' }));
|
|
25
|
-
|
|
25
|
+
// Dispatching a run to a node ships the whole tests/ tree (base64-encoded,
|
|
26
|
+
// fixtures included) as one JSON body — Express's 100kb default 413s well
|
|
27
|
+
// before a real test suite does.
|
|
28
|
+
app.use(express.json({ limit: '500mb' }));
|
|
26
29
|
|
|
27
30
|
// Serve screenshot files written during report processing
|
|
28
31
|
app.use('/screenshots', express.static(SCREENSHOTS_DIR));
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* This file is part of Plum.
|
|
3
|
+
*
|
|
4
|
+
* Plum is free software: you can redistribute it and/or modify
|
|
5
|
+
* it under the terms of the GNU General Public License as published by
|
|
6
|
+
* the Free Software Foundation, either version 3 of the License, or
|
|
7
|
+
* (at your option) any later version.
|
|
8
|
+
*
|
|
9
|
+
* Plum is distributed in the hope that it will be useful,
|
|
10
|
+
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
11
|
+
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
12
|
+
* GNU General Public License for more details.
|
|
13
|
+
*
|
|
14
|
+
* You should have received a copy of the GNU General Public License
|
|
15
|
+
* along with Plum. If not, see https://www.gnu.org/licenses/.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
const reportService = require('../services/reportService');
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Runs a suite attempt via the caller-supplied `spawnAttempt`, re-running only
|
|
22
|
+
* the scenarios that failed (up to `maxRetries` extra rounds) until either
|
|
23
|
+
* everything passes or retries are exhausted. Agnostic to how an attempt is
|
|
24
|
+
* actually executed — the same loop drives both a local `npm run test` spawn
|
|
25
|
+
* and a remote `dispatchAndPoll` call, since both reduce to
|
|
26
|
+
* `(tagOverride, round) => Promise<{ code, rawJson }>`.
|
|
27
|
+
*
|
|
28
|
+
* @param {{
|
|
29
|
+
* maxRetries: number,
|
|
30
|
+
* spawnAttempt: (tagOverride: string|null, round: number) => Promise<{ code: number, rawJson: object[] }>,
|
|
31
|
+
* onLog: (text: string) => void
|
|
32
|
+
* }} opts
|
|
33
|
+
* @returns {Promise<{ code: number, rawJson: object[], attempts: Record<string, number> }>}
|
|
34
|
+
*/
|
|
35
|
+
async function runWithRetries({ maxRetries, spawnAttempt, onLog }) {
|
|
36
|
+
const accumulated = [];
|
|
37
|
+
const attempts = {};
|
|
38
|
+
let round = 1;
|
|
39
|
+
let tagOverride = null;
|
|
40
|
+
let code = 0;
|
|
41
|
+
|
|
42
|
+
// eslint-disable-next-line no-constant-condition
|
|
43
|
+
while (true) {
|
|
44
|
+
const result = await spawnAttempt(tagOverride, round);
|
|
45
|
+
code = result.code;
|
|
46
|
+
reportService.mergeRawAttempt(accumulated, result.rawJson, round, attempts);
|
|
47
|
+
|
|
48
|
+
const failedIds = reportService.getFailedIdTags(result.rawJson);
|
|
49
|
+
if (failedIds.length === 0 || round > maxRetries) break;
|
|
50
|
+
|
|
51
|
+
onLog(
|
|
52
|
+
`\n[RETRY] ${failedIds.length} test(s) failed — retrying (attempt ${round + 1}/${maxRetries + 1})\n`
|
|
53
|
+
);
|
|
54
|
+
tagOverride = failedIds.join(' or ');
|
|
55
|
+
round++;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return { code, rawJson: accumulated, attempts };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
module.exports = { runWithRetries };
|
|
@@ -45,6 +45,26 @@ router.post('/shutdown', authGuard, (req, res) => {
|
|
|
45
45
|
setTimeout(() => process.exit(0), 200);
|
|
46
46
|
});
|
|
47
47
|
|
|
48
|
+
// Self-restart for node-mode processes: spawns a replacement using this
|
|
49
|
+
// process's own env (RUNNER_ID/PORT/NODE_TOKEN), then exits. The replacement
|
|
50
|
+
// binds the same port, retrying past the brief EADDRINUSE window left by this
|
|
51
|
+
// process shutting down (see the listen retry in server.js).
|
|
52
|
+
router.post('/restart', authGuard, (req, res) => {
|
|
53
|
+
if (process.env.PLUM_MODE !== 'node') {
|
|
54
|
+
return res.status(403).json({ error: 'Not a node runner' });
|
|
55
|
+
}
|
|
56
|
+
const id = process.env.RUNNER_ID;
|
|
57
|
+
if (!id) {
|
|
58
|
+
return res.status(400).json({ error: 'Runner has no RUNNER_ID — cannot self-restart' });
|
|
59
|
+
}
|
|
60
|
+
res.json({ ok: true });
|
|
61
|
+
setTimeout(() => {
|
|
62
|
+
const { startNode } = require('../lib/runnerProcess');
|
|
63
|
+
startNode({ id, port: process.env.PORT || '3001', token: process.env.NODE_TOKEN });
|
|
64
|
+
process.exit(0);
|
|
65
|
+
}, 200);
|
|
66
|
+
});
|
|
67
|
+
|
|
48
68
|
// Start a remote test job
|
|
49
69
|
router.post('/execute', authGuard, (req, res) => {
|
|
50
70
|
const { tags, browser = 'chromium', workers = 1, tests = null, env: userEnv = {} } = req.body;
|
|
@@ -64,16 +64,7 @@ router.put('/:id', async (req, res) => {
|
|
|
64
64
|
|
|
65
65
|
router.delete('/:id', async (req, res) => {
|
|
66
66
|
try {
|
|
67
|
-
|
|
68
|
-
if (runner) {
|
|
69
|
-
try {
|
|
70
|
-
await fetch(`${runner.url}/api/shutdown`, {
|
|
71
|
-
method: 'POST',
|
|
72
|
-
headers: { Authorization: `Bearer ${runner.token}` },
|
|
73
|
-
signal: AbortSignal.timeout(3000)
|
|
74
|
-
});
|
|
75
|
-
} catch {}
|
|
76
|
-
}
|
|
67
|
+
await runnerService.stop(req.params.id);
|
|
77
68
|
await runnerService.remove(req.params.id);
|
|
78
69
|
res.json({ message: 'Runner deleted' });
|
|
79
70
|
} catch (e) {
|
|
@@ -90,4 +81,22 @@ router.post('/:id/ping', async (req, res) => {
|
|
|
90
81
|
}
|
|
91
82
|
});
|
|
92
83
|
|
|
84
|
+
router.post('/:id/stop', async (req, res) => {
|
|
85
|
+
try {
|
|
86
|
+
const result = await runnerService.stop(req.params.id);
|
|
87
|
+
res.json(result);
|
|
88
|
+
} catch (e) {
|
|
89
|
+
res.status(500).json({ ok: false, error: e.message });
|
|
90
|
+
}
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
router.post('/:id/restart', async (req, res) => {
|
|
94
|
+
try {
|
|
95
|
+
const result = await runnerService.restart(req.params.id);
|
|
96
|
+
res.json(result);
|
|
97
|
+
} catch (e) {
|
|
98
|
+
res.status(500).json({ ok: false, error: e.message });
|
|
99
|
+
}
|
|
100
|
+
});
|
|
101
|
+
|
|
93
102
|
module.exports = router;
|
|
@@ -33,8 +33,8 @@ router.get('/project', async (req, res, next) => {
|
|
|
33
33
|
|
|
34
34
|
router.post('/project', async (req, res, next) => {
|
|
35
35
|
try {
|
|
36
|
-
const { name, logoUrl, timezone } = req.body;
|
|
37
|
-
const project = await settingsService.updateProject({ name, logoUrl, timezone });
|
|
36
|
+
const { name, logoUrl, timezone, maxRetries } = req.body;
|
|
37
|
+
const project = await settingsService.updateProject({ name, logoUrl, timezone, maxRetries });
|
|
38
38
|
res.json(project);
|
|
39
39
|
} catch (e) {
|
|
40
40
|
next(e);
|
|
@@ -26,6 +26,10 @@ const { jwtAuth } = require('../middleware/jwtAuth');
|
|
|
26
26
|
const prisma = require('../services/prisma');
|
|
27
27
|
const { startSsPoller } = require('../lib/screenshotPoller');
|
|
28
28
|
const { TRIGGER_TYPE } = require('../constants/triggers');
|
|
29
|
+
const settingsService = require('../services/settingsService');
|
|
30
|
+
const reportService = require('../services/reportService');
|
|
31
|
+
const { readCucumberReportFile } = require('../lib/reportFilename');
|
|
32
|
+
const { runWithRetries } = require('../lib/retryRunner');
|
|
29
33
|
|
|
30
34
|
const BACKEND_DIR = path.resolve(__dirname, '..');
|
|
31
35
|
const JOB_TTL_MS = 60 * 60 * 1000; // 1 hour
|
|
@@ -51,28 +55,12 @@ router.post('/', jwtAuth, async (req, res, next) => {
|
|
|
51
55
|
|
|
52
56
|
const { tag = '', browser = 'chromium', workers = 1, baseUrl, testRunId, source } = req.body;
|
|
53
57
|
const trigger = source === 'mcp' ? TRIGGER_TYPE.MCP : TRIGGER_TYPE.EXTERNAL;
|
|
58
|
+
const { maxRetries } = await settingsService.getProject();
|
|
54
59
|
|
|
55
60
|
const jobId = randomUUID();
|
|
56
61
|
const startedAt = Date.now();
|
|
57
62
|
jobs.set(jobId, { status: 'running', exitCode: null, reportId: null, startedAt });
|
|
58
63
|
|
|
59
|
-
const ssDir = path.join(os.tmpdir(), `plum-trigger-ss-${jobId}`);
|
|
60
|
-
fs.mkdirSync(ssDir, { recursive: true });
|
|
61
|
-
|
|
62
|
-
const env = {
|
|
63
|
-
...process.env,
|
|
64
|
-
TAG: tag,
|
|
65
|
-
TRIGGER: trigger,
|
|
66
|
-
BROWSER: browser,
|
|
67
|
-
REPORT_RUNNERS: String(workers),
|
|
68
|
-
PLUM_SS_DIR: ssDir
|
|
69
|
-
};
|
|
70
|
-
if (Number(workers) > 1) env.PARALLEL = String(workers);
|
|
71
|
-
if (testRunId) env.TEST_RUN_ID = testRunId;
|
|
72
|
-
if (baseUrl) env.BASE_URL = baseUrl;
|
|
73
|
-
|
|
74
|
-
const proc = spawn('npm', ['run', 'test'], { env, shell: true, cwd: BACKEND_DIR });
|
|
75
|
-
|
|
76
64
|
if (_io) {
|
|
77
65
|
_io.emit('bg-run-start', {
|
|
78
66
|
runId: jobId,
|
|
@@ -82,48 +70,108 @@ router.post('/', jwtAuth, async (req, res, next) => {
|
|
|
82
70
|
});
|
|
83
71
|
}
|
|
84
72
|
|
|
85
|
-
const
|
|
86
|
-
if (_io) _io.emit('bg-run-
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
73
|
+
const onLog = (text) => {
|
|
74
|
+
if (_io) _io.emit('bg-run-log', { runId: jobId, log: text });
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
function runAttempt(currentTag, suppressSave) {
|
|
78
|
+
return new Promise((resolve) => {
|
|
79
|
+
const ssDir = path.join(os.tmpdir(), `plum-trigger-ss-${jobId}-${Date.now()}`);
|
|
80
|
+
fs.mkdirSync(ssDir, { recursive: true });
|
|
81
|
+
|
|
82
|
+
const env = {
|
|
83
|
+
...process.env,
|
|
84
|
+
TAG: currentTag,
|
|
85
|
+
TRIGGER: trigger,
|
|
86
|
+
BROWSER: browser,
|
|
87
|
+
REPORT_RUNNERS: String(workers),
|
|
88
|
+
PLUM_SS_DIR: ssDir
|
|
89
|
+
};
|
|
90
|
+
if (Number(workers) > 1) env.PARALLEL = String(workers);
|
|
91
|
+
if (testRunId) env.TEST_RUN_ID = testRunId;
|
|
92
|
+
if (baseUrl) env.BASE_URL = baseUrl;
|
|
93
|
+
if (suppressSave) env.PLUM_MODE = 'node';
|
|
94
|
+
|
|
95
|
+
const proc = spawn('npm', ['run', 'test'], { env, shell: true, cwd: BACKEND_DIR });
|
|
96
|
+
|
|
97
|
+
const ssPoller = startSsPoller(ssDir, ({ stepName, data }) => {
|
|
98
|
+
if (_io) _io.emit('bg-run-screenshot', { runId: jobId, stepName, data });
|
|
107
99
|
});
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
100
|
+
|
|
101
|
+
proc.stdout.on('data', (d) => onLog(d.toString()));
|
|
102
|
+
proc.stderr.on('data', (d) => onLog(`[ERROR] ${d.toString()}`));
|
|
103
|
+
|
|
104
|
+
proc.on('close', (code) => {
|
|
105
|
+
clearInterval(ssPoller);
|
|
106
|
+
fs.rm(ssDir, { recursive: true, force: true }, () => {});
|
|
107
|
+
resolve({ code, raw: suppressSave ? readCucumberReportFile() : null });
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
if (maxRetries === 0) {
|
|
113
|
+
runAttempt(tag, false).then(async ({ code }) => {
|
|
114
|
+
let reportId = null;
|
|
115
|
+
try {
|
|
116
|
+
// Find the latest report created after this job started
|
|
117
|
+
const report = await prisma.report.findFirst({
|
|
118
|
+
where: { createdAt: { gte: new Date(startedAt) } },
|
|
119
|
+
orderBy: { createdAt: 'desc' },
|
|
120
|
+
select: { id: true, status: true }
|
|
121
|
+
});
|
|
122
|
+
reportId = report?.id ?? null;
|
|
123
|
+
if (reportId) {
|
|
124
|
+
await prisma.report.update({
|
|
125
|
+
where: { id: reportId },
|
|
126
|
+
data: { duration: Date.now() - startedAt }
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
jobs.set(jobId, {
|
|
130
|
+
status: code === 130 ? 'cancelled' : 'done',
|
|
131
|
+
exitCode: code,
|
|
132
|
+
reportId,
|
|
133
|
+
startedAt
|
|
113
134
|
});
|
|
135
|
+
} catch {
|
|
136
|
+
jobs.set(jobId, { status: 'done', exitCode: code, reportId: null, startedAt });
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
if (_io) _io.emit('bg-run-done', { runId: jobId, code, reportId });
|
|
140
|
+
});
|
|
141
|
+
} else {
|
|
142
|
+
runWithRetries({
|
|
143
|
+
maxRetries,
|
|
144
|
+
spawnAttempt: async (tagOverride) => {
|
|
145
|
+
const { code, raw } = await runAttempt(tagOverride ?? tag, true);
|
|
146
|
+
return { code, rawJson: raw ? JSON.parse(raw) : [] };
|
|
147
|
+
},
|
|
148
|
+
onLog
|
|
149
|
+
}).then(async ({ code, rawJson, attempts }) => {
|
|
150
|
+
let reportId = null;
|
|
151
|
+
try {
|
|
152
|
+
const report = await reportService.saveReport({
|
|
153
|
+
rawCucumberJson: rawJson,
|
|
154
|
+
tags: tag,
|
|
155
|
+
triggerType: trigger,
|
|
156
|
+
browser,
|
|
157
|
+
testRunId: testRunId ?? null,
|
|
158
|
+
duration: Date.now() - startedAt,
|
|
159
|
+
attempts
|
|
160
|
+
});
|
|
161
|
+
reportId = report.id;
|
|
162
|
+
jobs.set(jobId, {
|
|
163
|
+
status: code === 130 ? 'cancelled' : 'done',
|
|
164
|
+
exitCode: code,
|
|
165
|
+
reportId,
|
|
166
|
+
startedAt
|
|
167
|
+
});
|
|
168
|
+
} catch {
|
|
169
|
+
jobs.set(jobId, { status: 'done', exitCode: code, reportId: null, startedAt });
|
|
114
170
|
}
|
|
115
|
-
jobs.set(jobId, {
|
|
116
|
-
status: code === 130 ? 'cancelled' : 'done',
|
|
117
|
-
exitCode: code,
|
|
118
|
-
reportId,
|
|
119
|
-
startedAt
|
|
120
|
-
});
|
|
121
|
-
} catch {
|
|
122
|
-
jobs.set(jobId, { status: 'done', exitCode: code, reportId: null, startedAt });
|
|
123
|
-
}
|
|
124
171
|
|
|
125
|
-
|
|
126
|
-
|
|
172
|
+
if (_io) _io.emit('bg-run-done', { runId: jobId, code, reportId });
|
|
173
|
+
});
|
|
174
|
+
}
|
|
127
175
|
|
|
128
176
|
res.status(202).json({ jobId, status: 'running' });
|
|
129
177
|
} catch (e) {
|
|
@@ -88,6 +88,17 @@ async function deleteRunner(id) {
|
|
|
88
88
|
}
|
|
89
89
|
}
|
|
90
90
|
|
|
91
|
+
/**
|
|
92
|
+
* Stops/restarts a runner over the network via the primary's control routes,
|
|
93
|
+
* which hit the runner's own /api/shutdown|restart endpoints — works for any
|
|
94
|
+
* reachable runner, not just ones whose process this manager owns by PID.
|
|
95
|
+
*/
|
|
96
|
+
async function controlRunner(id, action) {
|
|
97
|
+
const res = await fetch(`${API_URL}/runners/${id}/${action}`, { method: 'POST' });
|
|
98
|
+
const body = await res.json().catch(() => ({}));
|
|
99
|
+
if (!res.ok || body.ok === false) throw new Error(body.error || `HTTP ${res.status}`);
|
|
100
|
+
}
|
|
101
|
+
|
|
91
102
|
/**
|
|
92
103
|
* Resolves the display + control state for every runner: reachability (ping),
|
|
93
104
|
* whether we own a live process for it, and whether we can control it at all.
|
|
@@ -156,12 +167,8 @@ function prepareNodeEnv() {
|
|
|
156
167
|
async function runAction(r) {
|
|
157
168
|
const options = [];
|
|
158
169
|
|
|
159
|
-
if (
|
|
160
|
-
|
|
161
|
-
pc.dim(`"${r.name}" runs on another machine — it can be pinged but not controlled here.`)
|
|
162
|
-
);
|
|
163
|
-
options.push({ value: 'ping', label: 'Ping' });
|
|
164
|
-
} else if (r.managed) {
|
|
170
|
+
if (r.managed) {
|
|
171
|
+
// Local, and this manager owns its process — control it directly by PID.
|
|
165
172
|
options.push(
|
|
166
173
|
{ value: 'stop', label: pc.red('Stop') },
|
|
167
174
|
{ value: 'restart', label: pc.yellow('Restart') },
|
|
@@ -169,14 +176,18 @@ async function runAction(r) {
|
|
|
169
176
|
{ value: 'ping', label: 'Ping' }
|
|
170
177
|
);
|
|
171
178
|
} else if (r.online) {
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
179
|
+
// Remote, or local but started outside this manager (no PID to own) —
|
|
180
|
+
// either way the runner's own /api/shutdown|restart endpoints are
|
|
181
|
+
// reachable over the network via the primary's control routes.
|
|
182
|
+
options.push(
|
|
183
|
+
{ value: 'stop', label: pc.red('Stop') },
|
|
184
|
+
{ value: 'restart', label: pc.yellow('Restart') },
|
|
185
|
+
{ value: 'ping', label: 'Ping' }
|
|
176
186
|
);
|
|
177
|
-
|
|
178
|
-
} else {
|
|
187
|
+
} else if (r.local) {
|
|
179
188
|
options.push({ value: 'start', label: pc.green('Start') }, { value: 'ping', label: 'Ping' });
|
|
189
|
+
} else {
|
|
190
|
+
options.push({ value: 'ping', label: 'Ping' });
|
|
180
191
|
}
|
|
181
192
|
|
|
182
193
|
options.push(
|
|
@@ -194,15 +205,39 @@ async function runAction(r) {
|
|
|
194
205
|
const entry = startNode({ id: r.id, port, token: r.token });
|
|
195
206
|
clack.log.success(pc.green(`Started "${r.name}" on port ${port} (pid ${entry.pid})`));
|
|
196
207
|
} else if (action === 'stop') {
|
|
197
|
-
|
|
198
|
-
|
|
208
|
+
if (r.managed) {
|
|
209
|
+
const ok = stopNode(r.id);
|
|
210
|
+
clack.log.success(
|
|
211
|
+
ok ? pc.green(`Stopped "${r.name}"`) : pc.dim(`"${r.name}" was not running`)
|
|
212
|
+
);
|
|
213
|
+
} else {
|
|
214
|
+
const s = clack.spinner();
|
|
215
|
+
s.start(`Stopping "${r.name}"...`);
|
|
216
|
+
try {
|
|
217
|
+
await controlRunner(r.id, 'stop');
|
|
218
|
+
s.stop(pc.green(`Stopped "${r.name}"`));
|
|
219
|
+
} catch (e) {
|
|
220
|
+
s.stop(pc.red(`Could not stop "${r.name}": ${e.message}`));
|
|
221
|
+
}
|
|
222
|
+
}
|
|
199
223
|
} else if (action === 'restart') {
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
224
|
+
if (r.managed) {
|
|
225
|
+
const s = clack.spinner();
|
|
226
|
+
s.start(`Restarting "${r.name}"...`);
|
|
227
|
+
stopNode(r.id);
|
|
228
|
+
await new Promise((resolve) => setTimeout(resolve, 600));
|
|
229
|
+
const entry = startNode({ id: r.id, port, token: r.token });
|
|
230
|
+
s.stop(pc.green(`Restarted "${r.name}" (pid ${entry.pid})`));
|
|
231
|
+
} else {
|
|
232
|
+
const s = clack.spinner();
|
|
233
|
+
s.start(`Restarting "${r.name}"...`);
|
|
234
|
+
try {
|
|
235
|
+
await controlRunner(r.id, 'restart');
|
|
236
|
+
s.stop(pc.green(`Restarted "${r.name}"`));
|
|
237
|
+
} catch (e) {
|
|
238
|
+
s.stop(pc.red(`Could not restart "${r.name}": ${e.message}`));
|
|
239
|
+
}
|
|
240
|
+
}
|
|
206
241
|
} else if (action === 'log') {
|
|
207
242
|
const entry = runnerProcess.loadRegistry()[r.id];
|
|
208
243
|
clack.note(entry?.logFile ?? '(no log file)', 'Log file');
|
package/backend/server.js
CHANGED
|
@@ -40,6 +40,20 @@ if (!fs.existsSync(testsDir)) {
|
|
|
40
40
|
const isNodeMode = process.env.PLUM_MODE === 'node';
|
|
41
41
|
const port = parseInt(process.env.PORT || '3001', 10);
|
|
42
42
|
|
|
43
|
+
// A self-restart (POST /api/restart) spawns the replacement before this
|
|
44
|
+
// process has released the port, so the first bind attempt can briefly hit
|
|
45
|
+
// EADDRINUSE — retry instead of dying immediately.
|
|
46
|
+
let listenRetriesLeft = 20;
|
|
47
|
+
server.on('error', (err) => {
|
|
48
|
+
if (err.code === 'EADDRINUSE' && listenRetriesLeft > 0) {
|
|
49
|
+
listenRetriesLeft -= 1;
|
|
50
|
+
setTimeout(() => server.listen(port), 250);
|
|
51
|
+
} else {
|
|
52
|
+
console.error(`❌ Failed to bind port ${port}:`, err.message);
|
|
53
|
+
process.exit(1);
|
|
54
|
+
}
|
|
55
|
+
});
|
|
56
|
+
|
|
43
57
|
let cronService = null;
|
|
44
58
|
let backupCronService = null;
|
|
45
59
|
if (!isNodeMode) {
|
|
@@ -77,8 +91,13 @@ async function start() {
|
|
|
77
91
|
const cleanup = () => {
|
|
78
92
|
try {
|
|
79
93
|
const reg = loadRegistry();
|
|
80
|
-
|
|
81
|
-
|
|
94
|
+
// A self-restart already wrote the replacement's pid under this
|
|
95
|
+
// id before this process exits — only clear the entry if it's
|
|
96
|
+
// still ours, so we don't erase the new process's registration.
|
|
97
|
+
if (reg[runnerId]?.pid === process.pid) {
|
|
98
|
+
delete reg[runnerId];
|
|
99
|
+
saveRegistry(reg);
|
|
100
|
+
}
|
|
82
101
|
} catch {}
|
|
83
102
|
};
|
|
84
103
|
process.once('SIGTERM', cleanup);
|