geniejars 0.2.7
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/COMMON-ISSUES.md +55 -0
- package/LICENSE.md +9 -0
- package/README.md +218 -0
- package/geniejars.config.example.json +21 -0
- package/package.json +22 -0
- package/script/geniejars.mjs +667 -0
- package/script/setup-geniejars.mjs +208 -0
- package/script/uninstall-geniejars.mjs +122 -0
- package/src/agent.mjs +194 -0
- package/src/allocate.mjs +96 -0
- package/src/index.mjs +5 -0
- package/src/normalize.mjs +26 -0
- package/src/pool.mjs +158 -0
- package/src/prepare.mjs +219 -0
- package/src/run.mjs +162 -0
- package/src/server.mjs +262 -0
package/src/server.mjs
ADDED
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
// Shell server — runs as the manager, manages geniejars agent lifecycle.
|
|
2
|
+
// Started by `geniejars sys up`, stopped by `geniejars sys down`.
|
|
3
|
+
//
|
|
4
|
+
// Protocol (newline-delimited JSON):
|
|
5
|
+
// ensure <name> — guarantee agent is up and healthy, respond when ready
|
|
6
|
+
// status — list running agents
|
|
7
|
+
// shutdown — graceful full teardown
|
|
8
|
+
|
|
9
|
+
import net from 'net'
|
|
10
|
+
import { spawn } from 'child_process'
|
|
11
|
+
import fs from 'fs/promises'
|
|
12
|
+
import path from 'path'
|
|
13
|
+
import { fileURLToPath } from 'url'
|
|
14
|
+
import { loadConfig, allNames, readState } from './pool.mjs'
|
|
15
|
+
import { stopProcess } from './run.mjs'
|
|
16
|
+
|
|
17
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
|
18
|
+
const agentScript = path.resolve(__dirname, 'agent.mjs')
|
|
19
|
+
|
|
20
|
+
const config = await loadConfig()
|
|
21
|
+
const serverSockPath = path.join(config.socketDir, 'server.sock')
|
|
22
|
+
const shutdownGraceMs = config.shutdownGraceMs ?? 10000
|
|
23
|
+
|
|
24
|
+
// name -> { pid }
|
|
25
|
+
const agents = new Map()
|
|
26
|
+
|
|
27
|
+
// Per-name ensure serialization: name -> Promise (chain)
|
|
28
|
+
const ensureLocks = new Map()
|
|
29
|
+
|
|
30
|
+
function sleep(ms) { return new Promise(r => setTimeout(r, ms)) }
|
|
31
|
+
|
|
32
|
+
function isRunning(pid) {
|
|
33
|
+
try { process.kill(pid, 0); return true } catch { return false }
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function agentSockPath(name) {
|
|
37
|
+
return path.join(config.socketDir, `${name}.sock`)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function agentPidPath(name) {
|
|
41
|
+
return path.join(config.socketDir, `${name}.pid`)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Ping a geniejars agent socket — returns true if alive
|
|
45
|
+
async function pingAgent(name) {
|
|
46
|
+
return new Promise(resolve => {
|
|
47
|
+
const client = net.createConnection(agentSockPath(name))
|
|
48
|
+
const timeout = setTimeout(() => { client.destroy(); resolve(false) }, 1000)
|
|
49
|
+
let buf = ''
|
|
50
|
+
client.on('connect', () => { client.write(JSON.stringify({ type: 'status' }) + '\n') })
|
|
51
|
+
client.on('data', chunk => {
|
|
52
|
+
buf += chunk.toString()
|
|
53
|
+
if (buf.includes('\n')) {
|
|
54
|
+
try {
|
|
55
|
+
const msg = JSON.parse(buf.split('\n')[0].trim())
|
|
56
|
+
if (msg.type === 'status') { clearTimeout(timeout); client.destroy(); resolve(true) }
|
|
57
|
+
} catch {}
|
|
58
|
+
}
|
|
59
|
+
})
|
|
60
|
+
client.on('error', () => { clearTimeout(timeout); resolve(false) })
|
|
61
|
+
client.on('close', () => { clearTimeout(timeout); resolve(false) })
|
|
62
|
+
})
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Spawn a geniejars agent and wait until its socket is ready
|
|
66
|
+
async function spawnAgent(name) {
|
|
67
|
+
return new Promise(resolve => {
|
|
68
|
+
const child = spawn('sudo', ['-u', name, '-H', config.nodePath, agentScript, name], {
|
|
69
|
+
detached: true,
|
|
70
|
+
stdio: ['ignore', 'ignore', 'pipe'],
|
|
71
|
+
})
|
|
72
|
+
let errOutput = ''
|
|
73
|
+
child.stderr.on('data', d => { errOutput += d.toString() })
|
|
74
|
+
|
|
75
|
+
const timer = setTimeout(async () => {
|
|
76
|
+
if (!isRunning(child.pid)) {
|
|
77
|
+
resolve({ ok: false, error: errOutput.trim() || 'process exited immediately' })
|
|
78
|
+
return
|
|
79
|
+
}
|
|
80
|
+
// Poll until socket responds (up to 3s)
|
|
81
|
+
for (let i = 0; i < 10; i++) {
|
|
82
|
+
await sleep(300)
|
|
83
|
+
if (await pingAgent(name)) {
|
|
84
|
+
child.stderr.destroy()
|
|
85
|
+
child.unref()
|
|
86
|
+
await fs.writeFile(agentPidPath(name), String(child.pid))
|
|
87
|
+
resolve({ ok: true, pid: child.pid })
|
|
88
|
+
return
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
resolve({ ok: false, error: 'agent started but socket not ready' })
|
|
92
|
+
}, 300)
|
|
93
|
+
|
|
94
|
+
child.on('exit', code => {
|
|
95
|
+
clearTimeout(timer)
|
|
96
|
+
resolve({ ok: false, error: errOutput.trim() || `exited with code ${code}` })
|
|
97
|
+
})
|
|
98
|
+
})
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Ensure agent is up — serialized per name
|
|
102
|
+
async function ensureAgent(name) {
|
|
103
|
+
const prev = ensureLocks.get(name) ?? Promise.resolve()
|
|
104
|
+
const promise = prev.catch(() => {}).then(async () => {
|
|
105
|
+
// Check existing entry
|
|
106
|
+
const entry = agents.get(name)
|
|
107
|
+
if (entry && isRunning(entry.pid) && await pingAgent(name)) return // healthy
|
|
108
|
+
|
|
109
|
+
// Stale — clean up and respawn
|
|
110
|
+
agents.delete(name)
|
|
111
|
+
await fs.unlink(agentSockPath(name)).catch(() => {})
|
|
112
|
+
|
|
113
|
+
const result = await spawnAgent(name)
|
|
114
|
+
if (!result.ok) throw new Error(`Failed to start agent for ${name}: ${result.error}`)
|
|
115
|
+
agents.set(name, { pid: result.pid })
|
|
116
|
+
}).finally(() => {
|
|
117
|
+
if (ensureLocks.get(name) === promise) ensureLocks.delete(name)
|
|
118
|
+
})
|
|
119
|
+
ensureLocks.set(name, promise)
|
|
120
|
+
await promise
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// On startup: probe all geniejars sockets and rebuild agent map
|
|
124
|
+
async function reconcile() {
|
|
125
|
+
const names = allNames(config)
|
|
126
|
+
await Promise.all(names.map(async name => {
|
|
127
|
+
if (await pingAgent(name)) {
|
|
128
|
+
try {
|
|
129
|
+
const pid = parseInt(await fs.readFile(agentPidPath(name), 'utf8'), 10)
|
|
130
|
+
agents.set(name, { pid })
|
|
131
|
+
console.log(`[server] ${name}: already running (pid ${pid})`)
|
|
132
|
+
} catch {
|
|
133
|
+
agents.set(name, { pid: -1 })
|
|
134
|
+
console.log(`[server] ${name}: already running (no pid file)`)
|
|
135
|
+
}
|
|
136
|
+
} else {
|
|
137
|
+
await fs.unlink(agentSockPath(name)).catch(() => {})
|
|
138
|
+
}
|
|
139
|
+
}))
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Graceful full teardown
|
|
143
|
+
async function shutdown() {
|
|
144
|
+
console.log('[server] shutting down...')
|
|
145
|
+
|
|
146
|
+
// 1. Stop all running CMDs
|
|
147
|
+
const state = await readState(config)
|
|
148
|
+
await Promise.all(allNames(config).map(async name => {
|
|
149
|
+
if (state.pool[name]?.status !== 'occupied') return
|
|
150
|
+
try { await stopProcess(config, name) } catch {}
|
|
151
|
+
}))
|
|
152
|
+
|
|
153
|
+
// 2. SIGTERM all agents
|
|
154
|
+
for (const [, { pid }] of agents) {
|
|
155
|
+
if (isRunning(pid)) {
|
|
156
|
+
try { process.kill(pid, 'SIGTERM') } catch {}
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// 3. Wait grace period, then SIGKILL stragglers
|
|
161
|
+
const deadline = Date.now() + shutdownGraceMs
|
|
162
|
+
while (Date.now() < deadline) {
|
|
163
|
+
const anyRunning = [...agents.values()].some(({ pid }) => isRunning(pid))
|
|
164
|
+
if (!anyRunning) break
|
|
165
|
+
await sleep(200)
|
|
166
|
+
}
|
|
167
|
+
for (const [, { pid }] of agents) {
|
|
168
|
+
if (isRunning(pid)) {
|
|
169
|
+
try { process.kill(pid, 'SIGKILL') } catch {}
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// 4. Clean up and exit
|
|
174
|
+
server.close()
|
|
175
|
+
await fs.unlink(serverSockPath).catch(() => {})
|
|
176
|
+
console.log('[server] done')
|
|
177
|
+
process.exit(0)
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// Request handler
|
|
181
|
+
function send(socket, obj) {
|
|
182
|
+
if (!socket.destroyed) socket.write(JSON.stringify(obj) + '\n')
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function handleConnection(socket) {
|
|
186
|
+
let buf = ''
|
|
187
|
+
socket.on('data', chunk => {
|
|
188
|
+
buf += chunk.toString()
|
|
189
|
+
let nl
|
|
190
|
+
while ((nl = buf.indexOf('\n')) !== -1) {
|
|
191
|
+
const line = buf.slice(0, nl).trim()
|
|
192
|
+
buf = buf.slice(nl + 1)
|
|
193
|
+
if (!line) continue
|
|
194
|
+
let req
|
|
195
|
+
try { req = JSON.parse(line) } catch {
|
|
196
|
+
send(socket, { type: 'error', message: 'Invalid JSON' })
|
|
197
|
+
socket.end()
|
|
198
|
+
return
|
|
199
|
+
}
|
|
200
|
+
handleRequest(req, socket)
|
|
201
|
+
}
|
|
202
|
+
})
|
|
203
|
+
socket.on('error', () => {})
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
async function handleRequest(req, socket) {
|
|
207
|
+
switch (req.type) {
|
|
208
|
+
|
|
209
|
+
case 'ensure': {
|
|
210
|
+
if (!req.name) { send(socket, { type: 'error', message: 'ensure requires name' }); socket.end(); return }
|
|
211
|
+
try {
|
|
212
|
+
await ensureAgent(req.name)
|
|
213
|
+
send(socket, { type: 'ready' })
|
|
214
|
+
} catch (err) {
|
|
215
|
+
send(socket, { type: 'error', message: err.message })
|
|
216
|
+
}
|
|
217
|
+
socket.end()
|
|
218
|
+
break
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
case 'status': {
|
|
222
|
+
const running = []
|
|
223
|
+
for (const [name, { pid }] of agents) {
|
|
224
|
+
if (isRunning(pid)) running.push({ name, pid })
|
|
225
|
+
else agents.delete(name)
|
|
226
|
+
}
|
|
227
|
+
send(socket, { type: 'status', agents: running })
|
|
228
|
+
socket.end()
|
|
229
|
+
break
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
case 'shutdown': {
|
|
233
|
+
send(socket, { type: 'ok' })
|
|
234
|
+
socket.end()
|
|
235
|
+
setImmediate(() => shutdown())
|
|
236
|
+
break
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
default: {
|
|
240
|
+
send(socket, { type: 'error', message: `Unknown request: ${req.type}` })
|
|
241
|
+
socket.end()
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// Start
|
|
247
|
+
await fs.mkdir(config.socketDir, { recursive: true })
|
|
248
|
+
await fs.unlink(serverSockPath).catch(() => {})
|
|
249
|
+
await reconcile()
|
|
250
|
+
|
|
251
|
+
const server = net.createServer(handleConnection)
|
|
252
|
+
server.listen(serverSockPath, async () => {
|
|
253
|
+
await fs.chmod(serverSockPath, 0o660)
|
|
254
|
+
console.log(`[server] listening on ${serverSockPath}`)
|
|
255
|
+
})
|
|
256
|
+
|
|
257
|
+
server.on('error', err => {
|
|
258
|
+
console.error('[server] error:', err.message)
|
|
259
|
+
process.exit(1)
|
|
260
|
+
})
|
|
261
|
+
|
|
262
|
+
process.on('SIGTERM', () => shutdown())
|