copilot-relay 0.2.5 → 0.2.6

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/README.md CHANGED
@@ -135,8 +135,10 @@ check that proves the relay can actually serve Claude Code — a relay whose
135
135
  Copilot token expired an hour ago still answers `/healthz` and `/v1/models`,
136
136
  because neither contacts upstream. It is opt-in because it spends a few tokens.
137
137
 
138
- `--json` emits machine-readable output. Exit codes: `0` running, `1` not
139
- running, `2` running but the deep check failed.
138
+ `--json` emits machine-readable output. Exit codes: `0` running and reachable,
139
+ `1` not running, `2` running but not usable — the health probe failed, or
140
+ `--deep` was requested and failed. `0` requires a live process *and* a passing
141
+ health probe, so a relay that cannot answer `/healthz` never reports success.
140
142
 
141
143
  ## Logging
142
144
 
package/dist/main.js CHANGED
@@ -7,11 +7,11 @@ import os from "node:os";
7
7
  import path, { dirname, resolve } from "node:path";
8
8
  import { fileURLToPath } from "node:url";
9
9
  import { execFile } from "node:child_process";
10
+ import { createAdaptorServer } from "@hono/node-server";
10
11
  import { createHash, randomUUID } from "node:crypto";
11
12
  import { Agent, fetch as fetch$1 } from "undici";
12
13
  import { events } from "fetch-event-stream";
13
14
  import { readFileSync } from "node:fs";
14
- import { createAdaptorServer } from "@hono/node-server";
15
15
  import { Hono } from "hono";
16
16
  import { cors } from "hono/cors";
17
17
  import { streamSSE } from "hono/streaming";
@@ -864,6 +864,27 @@ const isRelayPid = async (pid) => {
864
864
  if (!command) return false;
865
865
  return isRelayStartProcess(command, await getProcessCwd(pid));
866
866
  };
867
+ /**
868
+ * The relay serving `config.port`, or undefined if nothing is.
869
+ *
870
+ * Deliberately narrower than findRelayProcessIds: it never consults the global
871
+ * process list. `stop` wants "any relay anywhere" so it can clean up strays;
872
+ * `status` is asked about one configured relay, and answering with a different
873
+ * one is worse than answering "not running" because it looks authoritative.
874
+ *
875
+ * Returns pid and address as a single coherent record, so a pid discovered one
876
+ * way can never be printed next to an address taken from another.
877
+ */
878
+ const findRelayOnPort = async (config) => {
879
+ const entry = await readRelayPidFileEntry();
880
+ if (entry && entry.port === config.port && isProcessAlive(entry.pid) && await isRelayPid(entry.pid)) return entry;
881
+ for (const pid of await getPortListenerPids(config.port)) if (await isRelayPid(pid)) return {
882
+ host: config.host,
883
+ pid,
884
+ port: config.port,
885
+ startedAt: ""
886
+ };
887
+ };
867
888
  const findRelayProcessIds = async (config) => {
868
889
  const candidates = /* @__PURE__ */ new Set();
869
890
  const storedPid = await readRelayPidFile();
@@ -2939,6 +2960,7 @@ const startServer = (config) => new Promise((resolve$1, reject) => {
2939
2960
 
2940
2961
  //#endregion
2941
2962
  //#region src/start.ts
2963
+ const canCloseConnections = (server) => typeof server.closeIdleConnections === "function" && typeof server.closeAllConnections === "function";
2942
2964
  async function startRelay(appConfig) {
2943
2965
  appConfig ??= await readAppConfig();
2944
2966
  setLogLevel(appConfig.logLevel);
@@ -2997,6 +3019,7 @@ async function startRelay(appConfig) {
2997
3019
  applyRuntimeConfig(nextConfig);
2998
3020
  log.info(`Config reloaded: logLevel=${nextConfig.logLevel} thinkEffort=${nextConfig.thinkEffort} upstreamTimeoutSeconds=${nextConfig.upstreamTimeoutSeconds}`);
2999
3021
  });
3022
+ const shutdownGraceMs = 2e3;
3000
3023
  const shutdown = (signal) => {
3001
3024
  log.info(`Received ${signal}; shutting down copilot-relay`);
3002
3025
  server.close((error) => {
@@ -3005,6 +3028,13 @@ async function startRelay(appConfig) {
3005
3028
  process.exitCode = 1;
3006
3029
  }
3007
3030
  });
3031
+ if (canCloseConnections(server)) {
3032
+ server.closeIdleConnections();
3033
+ setTimeout(() => {
3034
+ log.info("Grace period elapsed; closing remaining connections");
3035
+ server.closeAllConnections();
3036
+ }, shutdownGraceMs).unref();
3037
+ }
3008
3038
  };
3009
3039
  process.once("SIGINT", shutdown);
3010
3040
  process.once("SIGTERM", shutdown);
@@ -3054,14 +3084,26 @@ const restart = defineCommand({
3054
3084
  *
3055
3085
  * 2 is distinct from 1 on purpose: "running but broken" and "not running" call
3056
3086
  * for different responses - restart the service versus re-authenticate.
3087
+ *
3088
+ * A failed health probe is a 2, not a 0. Printing FAILED while exiting 0 would
3089
+ * make every scripted caller treat an unreachable relay as fine. See #34.
3057
3090
  */
3058
3091
  const exitCodes = {
3059
- deepCheckFailed: 2,
3060
3092
  notRunning: 1,
3093
+ notUsable: 2,
3061
3094
  running: 0
3062
3095
  };
3063
3096
  const localProbeTimeoutMs = 5e3;
3064
3097
  const deepProbeTimeoutMs = 6e4;
3098
+ /**
3099
+ * Status to exit code. Pure, because this mapping is the contract every
3100
+ * scripted caller depends on and deserves direct coverage.
3101
+ *
3102
+ * 0 requires a live process *and* a passing health probe. Printing FAILED while
3103
+ * exiting 0 would make every scripted caller treat an unreachable relay as
3104
+ * fine. See #34.
3105
+ */
3106
+ const resolveExitCode = (status$1) => !status$1.running ? exitCodes.notRunning : !status$1.health?.ok ? exitCodes.notUsable : status$1.deep && !status$1.deep.ok ? exitCodes.notUsable : exitCodes.running;
3065
3107
  const formatUptime = (startedAt) => {
3066
3108
  if (!startedAt) return "unknown";
3067
3109
  const started = Date.parse(startedAt);
@@ -3189,15 +3231,12 @@ const collectStatus = async (options) => {
3189
3231
  thinkEffort: appConfig.thinkEffort,
3190
3232
  version: appVersion
3191
3233
  };
3192
- const pids = await findRelayProcessIds(readProxyConfig(appConfig));
3193
- if (pids.length === 0) return {
3234
+ const relay = await findRelayOnPort(readProxyConfig(appConfig));
3235
+ if (!relay) return {
3194
3236
  ...base,
3195
3237
  running: false
3196
3238
  };
3197
- const entry = await readRelayPidFileEntry();
3198
- const host = entry?.host ?? appConfig.host;
3199
- const port = entry?.port ?? appConfig.port;
3200
- const baseUrl = `http://${host}:${port}`;
3239
+ const baseUrl = `http://${relay.host}:${relay.port}`;
3201
3240
  const health = await checkHealth(baseUrl);
3202
3241
  const models = health.ok ? await readModels(baseUrl) : [];
3203
3242
  const deep = options.deep && health.ok ? await checkDeep(baseUrl, models[0] ?? appConfig.gptModel) : void 0;
@@ -3207,10 +3246,10 @@ const collectStatus = async (options) => {
3207
3246
  ...deep ? { deep } : {},
3208
3247
  health,
3209
3248
  models,
3210
- pid: entry?.pid ?? pids[0],
3211
- port,
3249
+ pid: relay.pid,
3250
+ port: relay.port,
3212
3251
  running: true,
3213
- ...entry?.startedAt ? { startedAt: entry.startedAt } : {}
3252
+ ...relay.startedAt ? { startedAt: relay.startedAt } : {}
3214
3253
  };
3215
3254
  };
3216
3255
  const status = defineCommand({
@@ -3232,7 +3271,7 @@ const status = defineCommand({
3232
3271
  const result = await collectStatus({ deep: Boolean(args.deep) });
3233
3272
  if (args.json) console.log(JSON.stringify(result, null, 2));
3234
3273
  else console.log(renderStatus(result).join("\n"));
3235
- process.exitCode = !result.running ? exitCodes.notRunning : result.deep && !result.deep.ok ? exitCodes.deepCheckFailed : exitCodes.running;
3274
+ process.exitCode = resolveExitCode(result);
3236
3275
  }
3237
3276
  });
3238
3277