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/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())