create-loopengine 0.1.42 → 0.1.43

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-loopengine",
3
- "version": "0.1.42",
3
+ "version": "0.1.43",
4
4
  "description": "Scaffold a new loopengine agent project — a runnable HTTP server with one example agent, in your own repo.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -2511,8 +2511,62 @@ const server = createServer(async (req, res) => {
2511
2511
  const port = Number(process.env.PORT ?? 8787)
2512
2512
  server.listen(port, () => console.log(`agent API listening on :${port}`))
2513
2513
 
2514
+ // server.close() alone only stops accepting *new* connections — it
2515
+ // doesn't wait for existing ones, and the old code called process.exit
2516
+ // right after it regardless, killing every still-open connection
2517
+ // immediately. A long-lived SSE stream (the Playground's own
2518
+ // /messages/stream, open for a whole turn — longer still if the model
2519
+ // polls a background job within that same turn) dies mid-response, and
2520
+ // whatever's in front of this process (a load balancer, a reverse
2521
+ // proxy) sees the backend vanish mid-stream and reports a 502 to
2522
+ // whoever was watching it — confirmed live: a `pm2 restart` while an
2523
+ // image-generation batch was in progress produced exactly that. Now
2524
+ // actually waits for server.close()'s own callback (fires once every
2525
+ // in-flight connection has closed on its own) before tearing anything
2526
+ // else down — bounded by LOOPENGINE_SHUTDOWN_GRACE_MS so a genuinely
2527
+ // stuck connection can't hang a deploy forever. Whatever's orchestrating
2528
+ // the restart (pm2's own kill_timeout, a container platform's own grace
2529
+ // period) needs to be at least this long too, or it'll SIGKILL out from
2530
+ // under this before the wait finishes — SIGKILL can't be caught or
2531
+ // delayed by anything here.
2532
+ //
2533
+ // This only protects the *connection* a request/stream is running
2534
+ // over. A tool's own background work that isn't awaited by its
2535
+ // execute() call (e.g. a batch job that returns a job_id immediately
2536
+ // and keeps working after) isn't tied to any connection at all, and
2537
+ // still dies the instant process.exit runs regardless of how long this
2538
+ // grace period is — there's no persistent job queue here surviving a
2539
+ // process restart, just this one process's own event loop.
2514
2540
  async function shutdown() {
2515
- server.close()
2541
+ console.log('[loopengine] shutting down — draining in-flight requests...')
2542
+ const graceMs = Number(process.env.LOOPENGINE_SHUTDOWN_GRACE_MS ?? 30000)
2543
+ const closed = new Promise<void>((resolve) => server.close(() => resolve()))
2544
+ // HTTP keep-alive means a connection whose last request already
2545
+ // finished doesn't close itself — it sits open, idle, waiting for a
2546
+ // *next* request that (mid-restart) is never coming, which would
2547
+ // otherwise hold server.close()'s own callback back for the entire
2548
+ // grace period even though nothing real is still in flight on it —
2549
+ // confirmed live: an idle keep-alive socket alone reproduced the full
2550
+ // wait. closeIdleConnections() (Node >= 18.2) drops exactly those
2551
+ // (never an actively-streaming request/response, which is the one
2552
+ // thing this whole function exists to protect) right away — but only
2553
+ // the ones idle *at the instant it's called*. A connection that's
2554
+ // still active right now (the actual case this whole function exists
2555
+ // to protect) goes idle *later*, once its own response finishes, so a
2556
+ // single call up front misses it — confirmed live: without the
2557
+ // repeated sweep below, that connection's own idle socket still held
2558
+ // the grace period open right up to the timeout, same as if this call
2559
+ // were never made at all. Repeating it on an interval catches that as
2560
+ // soon as it happens, instead of only ever catching sockets already
2561
+ // idle before shutdown even began.
2562
+ server.closeIdleConnections()
2563
+ const sweep = setInterval(() => server.closeIdleConnections(), 1000)
2564
+ const timedOut = new Promise<'timeout'>((resolve) => setTimeout(() => resolve('timeout'), graceMs))
2565
+ const outcome = await Promise.race([closed.then(() => 'closed' as const), timedOut])
2566
+ clearInterval(sweep)
2567
+ if (outcome === 'timeout') {
2568
+ console.log(`[loopengine] shutdown grace period (${graceMs}ms) elapsed with connections still open — exiting anyway`)
2569
+ }
2516
2570
  await sessions.close()
2517
2571
  process.exit(0)
2518
2572
  }