cuttlely 1.3.0 → 1.3.1

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.
Files changed (3) hide show
  1. package/README.md +1 -1
  2. package/lib/cli.js +92 -13
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -15,7 +15,7 @@ npx cuttlely start
15
15
  That pulls `ghcr.io/cuttlely/cuttlely`, tagged with the same version as the `cuttlely` package you ran, keeps data in the Docker volume `cuttlely-data`, publishes port **43117**, waits until `http://127.0.0.1:43117/api/v1/ping` answers `pong`, prints `http://localhost:43117`, and opens it.
16
16
 
17
17
  ```bash
18
- npx cuttlely start --port 8080 --tag 1.3.0 --env-file .env --no-open
18
+ npx cuttlely start --port 8080 --tag 1.3.1 --env-file .env --no-open
19
19
  ```
20
20
 
21
21
  | Flag | Meaning |
package/lib/cli.js CHANGED
@@ -2,6 +2,7 @@
2
2
  // This package only talks to the local Docker daemon.
3
3
  import { readFileSync, statSync } from 'node:fs'
4
4
  import { spawn } from 'node:child_process'
5
+ import { get as httpGet } from 'node:http'
5
6
  import { dirname, join } from 'node:path'
6
7
  import { fileURLToPath } from 'node:url'
7
8
  import { RESOURCE_ID_RE, runCheck } from './check.js'
@@ -17,6 +18,21 @@ export const VOLUME_NAME = 'cuttlely-data'
17
18
  export const DATA_DIR = '/var/cuttlely'
18
19
  export const INSTALL_DOCS = 'https://github.com/cuttlely/cuttlely/blob/main/docs/how-to/docker.md'
19
20
  export const HEALTH_TIMEOUT_MS = 6 * 60 * 1000
21
+ // Each health try gets its own limit, so one stuck request cannot use up the whole wait.
22
+ export const HEALTH_TRY_TIMEOUT_MS = 5000
23
+ // Error codes for a connection that the port accepts and then drops while the container boots.
24
+ export const RETRYABLE_HEALTH_ERRORS = new Set([
25
+ 'ECONNRESET',
26
+ 'ECONNREFUSED',
27
+ 'ECONNABORTED',
28
+ 'EPIPE',
29
+ 'ETIMEDOUT',
30
+ 'EHOSTUNREACH',
31
+ 'UND_ERR_SOCKET',
32
+ 'UND_ERR_CONNECT_TIMEOUT',
33
+ 'UND_ERR_HEADERS_TIMEOUT',
34
+ 'HEALTH_TRY_TIMEOUT'
35
+ ])
20
36
 
21
37
  const TAG_RE = /^[A-Za-z0-9_][A-Za-z0-9_.-]{0,127}$/
22
38
  const INSPECT_FORMAT = '{{.State.Status}}|{{.Config.Image}}|{{.Image}}|{{range $p, $b := .HostConfig.PortBindings}}{{$p}} {{end}}'
@@ -457,26 +473,86 @@ async function readImageId(docker, image) {
457
473
  return result.stdout.trim()
458
474
  }
459
475
 
460
- async function waitForHealth({ url, fetchImpl, sleep, timeoutMs, now }) {
476
+ function healthErrorCode(error) {
477
+ return error?.code || error?.cause?.code || error?.name || 'UNKNOWN'
478
+ }
479
+
480
+ // One health try with its own timeout. The timer is a normal (ref'd) timer, so a request that never settles still
481
+ // keeps the event loop alive until it fires; AbortSignal.timeout alone is unref'd and does not.
482
+ function healthTry(fetchImpl, url, tryTimeoutMs) {
483
+ const controller = new AbortController()
484
+ let timer
485
+ const timeout = new Promise((_, reject) => {
486
+ timer = setTimeout(() => {
487
+ const error = new Error(`No answer from ${url} within ${tryTimeoutMs} ms`)
488
+ error.code = 'HEALTH_TRY_TIMEOUT'
489
+ controller.abort(error)
490
+ reject(error)
491
+ }, tryTimeoutMs)
492
+ })
493
+ const attempt = (async () => {
494
+ const res = await fetchImpl(url, { redirect: 'manual', signal: controller.signal })
495
+ const body = typeof res.text === 'function' ? await res.text() : String(res.body ?? '')
496
+ return res.ok && body.includes('pong')
497
+ })()
498
+ attempt.catch(() => {})
499
+ return Promise.race([attempt, timeout]).finally(() => clearTimeout(timer))
500
+ }
501
+
502
+ // Waits until the ping answers pong or the deadline passes. Returns { ready, lastError }.
503
+ // A keep-alive interval runs for the whole wait: on Node 20 a fetch whose socket is reset during boot can stay
504
+ // pending with nothing else holding the event loop, and Node then exits with code 13 before the ready message.
505
+ export async function waitForHealth({ url, fetchImpl, sleep, timeoutMs, now, tryTimeoutMs = HEALTH_TRY_TIMEOUT_MS }) {
461
506
  const deadline = now() + timeoutMs
462
- for (;;) {
463
- try {
464
- const res = await fetchImpl(url, { redirect: 'manual' })
465
- const body = typeof res.text === 'function' ? await res.text() : String(res.body ?? '')
466
- if (res.ok && body.includes('pong')) return true
467
- } catch {
468
- // The server is still booting, or the port is not open yet.
507
+ const keepAlive = setInterval(() => {}, 1000)
508
+ let lastError = null
509
+ try {
510
+ for (;;) {
511
+ try {
512
+ if (await healthTry(fetchImpl, url, tryTimeoutMs)) return { ready: true, lastError: null }
513
+ lastError = null
514
+ } catch (error) {
515
+ // Resets, refusals and per-try timeouts mean the server is still booting: try again.
516
+ // Anything else is also retried until the deadline, but is reported if it is the last error seen.
517
+ lastError = { code: healthErrorCode(error), retryable: RETRYABLE_HEALTH_ERRORS.has(healthErrorCode(error)) }
518
+ }
519
+ if (now() >= deadline) return { ready: false, lastError }
520
+ await sleep(1000)
469
521
  }
470
- if (now() >= deadline) return false
471
- await sleep(1000)
472
- }
522
+ } finally {
523
+ clearInterval(keepAlive)
524
+ }
525
+ }
526
+
527
+ // Default health fetch: node:http with a socket timeout, so the open request itself holds the event loop.
528
+ export function httpHealthFetch(url, { signal, timeoutMs = HEALTH_TRY_TIMEOUT_MS } = {}) {
529
+ return new Promise((resolve, reject) => {
530
+ const req = httpGet(url, { signal, timeout: timeoutMs }, (res) => {
531
+ let body = ''
532
+ res.setEncoding('utf8')
533
+ res.on('data', (chunk) => {
534
+ body += chunk
535
+ })
536
+ res.on('end', () => {
537
+ const status = res.statusCode || 0
538
+ resolve({ ok: status >= 200 && status < 300, status, text: async () => body })
539
+ })
540
+ res.on('error', reject)
541
+ })
542
+ req.on('timeout', () => {
543
+ const error = new Error(`No answer from ${url} within ${timeoutMs} ms`)
544
+ error.code = 'HEALTH_TRY_TIMEOUT'
545
+ req.destroy(error)
546
+ })
547
+ req.on('error', reject)
548
+ })
473
549
  }
474
550
 
475
551
  async function reportReady(ctx) {
476
552
  const url = publicUrl(ctx.opts.port)
477
553
  const ping = healthUrl(ctx.opts.port)
478
554
  ctx.out(`Waiting for Cuttlely at ${ping}`)
479
- const ready = await waitForHealth({
555
+ const { ready, lastError } = await waitForHealth({
480
556
  url: ping,
481
557
  fetchImpl: ctx.fetchImpl,
482
558
  sleep: ctx.sleep,
@@ -484,6 +560,9 @@ async function reportReady(ctx) {
484
560
  now: ctx.now
485
561
  })
486
562
  if (!ready) {
563
+ const minutes = Math.max(1, Math.round(ctx.timeoutMs / 60000))
564
+ const reason = lastError ? ` (last error: ${lastError.code})` : ''
565
+ ctx.err(`Cuttlely did not answer ${ping} within ${minutes} min${reason}.`)
487
566
  ctx.err(`Cuttlely is still starting. Open ${url} when it is ready.`)
488
567
  ctx.err('Logs: cuttlely logs')
489
568
  ctx.err(INSTALL_DOCS)
@@ -649,7 +728,7 @@ export async function main(argv, io = {}) {
649
728
  env: io.env || process.env,
650
729
  fetch: io.fetch || globalThis.fetch.bind(globalThis),
651
730
  writeFile: io.writeFile,
652
- fetchImpl: io.fetch || globalThis.fetch.bind(globalThis),
731
+ fetchImpl: io.fetch || httpHealthFetch,
653
732
  sleep: io.sleep || ((ms) => new Promise((resolve) => setTimeout(resolve, ms))),
654
733
  open: io.open || openBrowser,
655
734
  now: io.now || Date.now,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cuttlely",
3
- "version": "1.3.0",
3
+ "version": "1.3.1",
4
4
  "description": "Start Cuttlely in Docker. The open-source agent platform for applications.",
5
5
  "type": "module",
6
6
  "bin": {