rollbridge 0.1.7 → 0.1.10

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/docs/config.md CHANGED
@@ -127,6 +127,7 @@ legacyTakeover: {
127
127
  | --- | --- | --- | --- |
128
128
  | `id` | string | **required** | Unique identifier. Appears in `status`, logs, and `ROLLBRIDGE_*` env vars. |
129
129
  | `policy` | `"proxied"` \| `"companion"` \| `"singleton"` \| `"service"` | `"companion"` | Lifecycle policy (see [README → Process Policies](../README.md#process-policies)). Exactly one process must be `proxied`. |
130
+ | `deployStrategy` | `"persistent"` \| `"handoff"` | `"persistent"` | Service deploy behavior. Only valid on `policy: "service"`; see below. |
130
131
  | `command` | string | **required** | Shell command to run (templated). |
131
132
  | `cwd` | string | the release path | Working directory (templated). |
132
133
  | `env` | object of string → string | `{}` | Extra environment variables (values templated). Merged over the injected `ROLLBRIDGE_*` vars. |
@@ -135,7 +136,7 @@ legacyTakeover: {
135
136
  | `stopSignal` | signal name (e.g. `"SIGTERM"`, `"SIGINT"`, `"SIGQUIT"`) | `"SIGTERM"` | Signal sent to gracefully stop the process; after `gracefulStopMs` it is `SIGKILL`ed. Use a worker's quit signal so it finishes in-flight work before exiting. |
136
137
  | `nonBlockingDrain` | boolean | `false` | When a release is retired, drain this process **immediately** (in parallel with the proxied connection drain) instead of after it. Companion processes only — typically background workers (see below). |
137
138
  | `lifecycle` | object | no hooks | Command hooks run when gracefully stopping the process (see below). |
138
- | `gracefulStopMs` | number | `proxy.forceStopTimeoutMs` | Graceful-stop window: time between `stopSignal`/`stopCommand` and `SIGKILL` for this process. |
139
+ | `gracefulStopMs` | number or `"indefinite"` | `proxy.forceStopTimeoutMs` | Graceful-stop window: time between `stopSignal`/`stopCommand` and `SIGKILL` for this process. Use `"indefinite"` to wait for process exit without sending `SIGKILL`. |
139
140
  | `restartDelayMs` | number | `1000` | Base delay before restarting this process after a crash (the backoff base; see `restart`). |
140
141
  | `restart` | object | unlimited restarts, constant delay | Automatic-restart policy: cap, rolling window, and backoff (see below). |
141
142
  | `memory` | object | unset (no monitoring) | Memory supervision: restart the process when its RSS exceeds a limit (see below). |
@@ -161,6 +162,32 @@ restart every replica, or `worker#0` for one). Replicas get `replicaIndex`/
161
162
  environment, so each instance can pick a distinct shard, queue, or lock. A single
162
163
  process (`replicas: 1`) keeps its plain id and is replica `0` of `1`.
163
164
 
165
+ ### `processes[].deployStrategy`
166
+
167
+ `deployStrategy` controls how `policy: "service"` processes behave across deploys:
168
+
169
+ | Value | Behavior |
170
+ | --- | --- |
171
+ | `"persistent"` | The default. Rollbridge runs one daemon-wide service instance on a stable port. Deploys update the stored template for future restarts but do not replace a healthy service. |
172
+ | `"handoff"` | Rollbridge starts a new release-scoped service instance before the new release's companions and proxied process start. The old release keeps its old service port while it drains, then that old service stops with the old release. |
173
+
174
+ Use `"handoff"` when release-scoped processes must talk to a same-release service
175
+ while old and new releases overlap. A handoff service must define a **multi-port
176
+ range** so old and new instances can run at the same time:
177
+
178
+ ```js
179
+ {
180
+ id: "background-jobs-main",
181
+ policy: "service",
182
+ deployStrategy: "handoff",
183
+ command: "npx velocious background-jobs-main",
184
+ port: {from: 7331, to: 7399}
185
+ }
186
+ ```
187
+
188
+ Reference it from same-release processes with `{{ports.background-jobs-main}}`.
189
+ During a deploy, old workers keep the old port and new workers get the new port.
190
+
164
191
  ### `processes[].lifecycle`
165
192
 
166
193
  Command hooks run when Rollbridge **gracefully stops** the process — during a
@@ -181,12 +208,13 @@ ignored. Use one or the other.
181
208
 
182
209
  The full stop sequence is: run `quietCommand` → drain (`drainCommand`, or wait
183
210
  `drainTimeoutMs` for the process to exit) → if still running, run `stopCommand`
184
- or send `stopSignal` → `SIGKILL` after `gracefulStopMs`. Each hook command is run
185
- through a shell with the process's environment plus `ROLLBRIDGE_PID` (the
186
- process-group leader's pid, so a hook can `kill -TSTP -$ROLLBRIDGE_PID`). Every
187
- hook is **bounded by a timeout** (its drain timeout, or `gracefulStopMs`) and its
188
- failure is non-fatal the sequence proceeds and `SIGKILL` is always the final
189
- fallback, so a slow or broken hook can't wedge a stop.
211
+ or send `stopSignal` → `SIGKILL` after `gracefulStopMs` unless
212
+ `gracefulStopMs: "indefinite"` is set. Each hook command is run through a shell
213
+ with the process's environment plus `ROLLBRIDGE_PID` (the process-group leader's
214
+ pid, so a hook can `kill -TSTP -$ROLLBRIDGE_PID`). Every hook is **bounded by a
215
+ timeout** (its drain timeout, `gracefulStopMs`, or 30 seconds when the graceful
216
+ stop window is indefinite) and its failure is non-fatal the sequence proceeds
217
+ to the stop signal/command so a slow or broken hook can't wedge a stop.
190
218
 
191
219
  ```js
192
220
  {id: "worker", policy: "companion", command: "…", lifecycle: {quietCommand: "kill -TSTP -$ROLLBRIDGE_PID", drainTimeoutMs: 60000}}
package/docs/velocious.md CHANGED
@@ -14,7 +14,7 @@ A production version of this config lives at
14
14
  | Velocious process | Policy | Why |
15
15
  | --- | --- | --- |
16
16
  | `beacon` | `service` | A shared broker the other processes connect to. It should survive deploys and keep a **stable port**, so workers and the web process always reach the same Beacon. |
17
- | `background-jobs-main` | `service` (or `singleton`) | The job coordinator. Run it as a `service` when it should outlive releases on a stable port; run it as a `singleton` when it must run the latest release's code after every deploy (see [Choosing the jobs-main policy](#choosing-the-jobs-main-policy)). |
17
+ | `background-jobs-main` | `service` with `deployStrategy: "handoff"` | The job coordinator. Run it as a handoff service so each release's workers and web process use a same-release coordinator while old releases drain (see [Choosing the jobs-main policy](#choosing-the-jobs-main-policy)). |
18
18
  | `background-jobs-worker` | `companion` | Release-scoped: one set of workers per active release, started before the web process and running that release's code. |
19
19
  | `web` | `proxied` | Receives external HTTP/WebSocket traffic, is health-checked before traffic switches, and is drained on the next deploy. Exactly one process is `proxied`. |
20
20
 
@@ -49,10 +49,11 @@ export default {
49
49
  port: 7330
50
50
  },
51
51
 
52
- // Job coordinator — waits for Beacon, stable port other jobs processes use.
52
+ // Job coordinator — one release-scoped service instance per deploy.
53
53
  {
54
54
  id: "background-jobs-main",
55
55
  policy: "service",
56
+ deployStrategy: "handoff",
56
57
  cwd: "{{releasePath}}/backend",
57
58
  env: {
58
59
  NODE_ENV: "production",
@@ -60,7 +61,7 @@ export default {
60
61
  VELOCIOUS_BACKGROUND_JOBS_PORT: "{{port}}"
61
62
  },
62
63
  command: "wait-for-it 127.0.0.1:{{ports.beacon}} --strict -- npx velocious background-jobs-main",
63
- port: 7331
64
+ port: {from: 7331, to: 7399}
64
65
  },
65
66
 
66
67
  // Workers — one set per release; raise gracefulStopMs to let in-flight
@@ -75,7 +76,7 @@ export default {
75
76
  VELOCIOUS_BACKGROUND_JOBS_PORT: "{{ports.background-jobs-main}}"
76
77
  },
77
78
  command: "wait-for-it 127.0.0.1:{{ports.beacon}} --strict -- wait-for-it 127.0.0.1:{{ports.background-jobs-main}} --strict -- npx velocious background-jobs-worker",
78
- gracefulStopMs: 60000
79
+ gracefulStopMs: "indefinite"
79
80
  },
80
81
 
81
82
  // Web/API — the one proxied process.
@@ -98,11 +99,13 @@ export default {
98
99
 
99
100
  ## Wiring processes together
100
101
 
101
- Beacon and `background-jobs-main` get **fixed** ports (`7330`, `7331`) because
102
- they are `service`s — a stable port lets every release's workers and web process
103
- find them. The proxied `web` process gets a **range** (`{from: 14500, to:
104
- 14599}`); Rollbridge allocates a free port per release so the old and new web
105
- releases can run side by side during the drain.
102
+ Beacon gets a **fixed** port (`7330`) because it is a persistent `service` — a
103
+ stable port lets every release's processes find the shared broker.
104
+ `background-jobs-main` gets a **range** (`{from: 7331, to: 7399}`) because it is a
105
+ handoff service: Rollbridge allocates a new port per release so old workers keep
106
+ talking to the old coordinator while new workers and web use the new one. The
107
+ proxied `web` process also gets a **range** (`{from: 14500, to: 14599}`) so old
108
+ and new web releases can run side by side during the drain.
106
109
 
107
110
  Cross-reference ports with `{{ports.<id>}}` and pass them to Velocious through
108
111
  `env`. Rollbridge also injects `ROLLBRIDGE_<ID>_PORT` for every process (e.g.
@@ -115,8 +118,8 @@ environment instead of templating if you prefer — see
115
118
  Only the `proxied` process is health-checked, so dependent processes must wait
116
119
  for their dependencies themselves. Two mechanisms combine:
117
120
 
118
- 1. **Policy ordering.** On each deploy Rollbridge starts `service`s first, then
119
- the release's `companion`s, then the `proxied` process (see
121
+ 1. **Policy ordering.** On each deploy Rollbridge starts handoff `service`s
122
+ first, then the release's `companion`s, then the `proxied` process (see
120
123
  [README → Deploy ordering](../README.md#deploy-ordering)).
121
124
  2. **Readiness gating.** `wait-for-it 127.0.0.1:{{ports.beacon}} --strict -- …`
122
125
  blocks the command until Beacon's port accepts connections, so
@@ -159,8 +162,9 @@ The worker is a `companion`, so each release runs its own workers:
159
162
  is drained and retired — the worker's `stopSignal`, then `SIGKILL` after
160
163
  `gracefulStopMs`.
161
164
  - Set `stopSignal` to the signal your worker drains on and `gracefulStopMs` to at
162
- least your longest in-flight job, so a job gets time to finish before the
163
- forced kill. Set `replicas` to run a pool of workers.
165
+ least your longest in-flight job. Use `gracefulStopMs: "indefinite"` when the
166
+ worker can safely drain until it exits on its own. Set `replicas` to run a pool
167
+ of workers.
164
168
 
165
169
  See [`docs/workers.md`](workers.md) for the full safe background-job deployment
166
170
  pattern (companion + `replicas` + `stopSignal`/`lifecycle` hooks +
@@ -185,7 +189,7 @@ jobs across a deploy:
185
189
  },
186
190
  command: "wait-for-it 127.0.0.1:{{ports.beacon}} --strict -- wait-for-it 127.0.0.1:{{ports.background-jobs-main}} --strict -- npx velocious background-jobs-worker",
187
191
  replicas: 4,
188
- gracefulStopMs: 60000
192
+ gracefulStopMs: "indefinite"
189
193
  }
190
194
  ```
191
195
 
@@ -193,9 +197,8 @@ jobs across a deploy:
193
197
  each with `ROLLBRIDGE_REPLICA_INDEX`/`ROLLBRIDGE_REPLICA_COUNT` if you shard work.
194
198
  - On deploy the new release's workers start before traffic switches; the old
195
199
  release's workers receive `SIGTERM` (the default `stopSignal`) when the old
196
- release is retired, then `SIGKILL` after `gracefulStopMs` — so size
197
- `gracefulStopMs` to your longest job. Both releases' workers briefly consume the
198
- shared queue, so keep job code backwards-compatible and jobs idempotent.
200
+ release is retired, then wait to exit. With `gracefulStopMs: "indefinite"`,
201
+ Rollbridge does not send a `SIGKILL` fallback.
199
202
 
200
203
  If your worker quiesces on a command or a non-default signal, add a `lifecycle`
201
204
  block — Rollbridge runs `quietCommand`, drains for up to `drainTimeoutMs`, then
@@ -208,27 +211,28 @@ lifecycle: {quietCommand: "kill -TSTP -$ROLLBRIDGE_PID", drainTimeoutMs: 60000}
208
211
 
209
212
  ### Choosing the jobs-main policy
210
213
 
211
- `background-jobs-main` is duplicate-unsafe (you never want two coordinators), so
212
- it is either a `service` or a `singleton` — never a `companion`:
214
+ `background-jobs-main` coordinates workers, so choose its lifecycle deliberately:
213
215
 
214
- - **`service`** keeps running across deploys on its stable port. Workers from
215
- every release talk to the same coordinator, so there's no coordination gap on
216
- deploy. The trade-off: a `service` keeps running the **release it was started
217
- from** and only adopts the latest release's template if it crashes and
218
- restarts (or the daemon restarts). If `background-jobs-main` itself needs the
219
- newest code immediately after every deploy, this is the wrong policy.
220
- - **`singleton`** Rollbridge stops the old instance and then starts the new
221
- one on each deploy, so it always runs the latest release's code and two copies
222
- never overlap. The trade-off: a brief coordination gap while it restarts.
216
+ - **`service` with `deployStrategy: "handoff"`** starts one coordinator per
217
+ release on a port from a range. New workers and web get the new release's port;
218
+ old workers keep the old release's port while they drain. This is the safest
219
+ default when the coordinator should run the same code version as its workers.
220
+ - **`service` with the default `deployStrategy: "persistent"`** keeps one
221
+ daemon-wide coordinator on a stable port. Workers from every release talk to the
222
+ same coordinator, but it keeps running the release it was started from and only
223
+ adopts the latest template if it restarts later.
224
+ - **`singleton`** stops the old instance and then starts the new one on each
225
+ deploy, so it always runs the latest release's code and two copies never
226
+ overlap. The trade-off: a brief coordination gap while it restarts.
223
227
 
224
228
  Beacon is a broker rather than code that changes per release, so `service` is
225
229
  almost always right for it.
226
230
 
227
231
  ## Verifying
228
232
 
229
- After a deploy, `rollbridge status` should show `beacon` and
230
- `background-jobs-main` as long-lived `service`s with unchanged ports across
231
- deploys, one `background-jobs-worker` for the active release, and the `web`
233
+ After a deploy, `rollbridge status` should show `beacon` as a long-lived service
234
+ with an unchanged port, `background-jobs-main` as the active release's handoff
235
+ service, one `background-jobs-worker` for the active release, and the `web`
232
236
  process `proxied` with its connection counts. Use
233
237
  [`rollbridge logs --process <id>`](cli.md) to read recent output from any
234
238
  process, and [`docs/troubleshooting.md`](troubleshooting.md) for health-check,
package/docs/workers.md CHANGED
@@ -47,7 +47,8 @@ the worker's chance to finish its current job and exit cleanly.
47
47
  finish the current job and exit on `SIGTERM` (the default); some use `SIGINT`
48
48
  or `SIGQUIT`. Use the one your worker treats as "drain and exit".
49
49
  - Set `gracefulStopMs` to at least your longest job's duration, so a job in
50
- progress is not cut off by the `SIGKILL` fallback.
50
+ progress is not cut off by the `SIGKILL` fallback. Use `"indefinite"` only for
51
+ workers that are safe to leave draining until they exit on their own.
51
52
 
52
53
  ```js
53
54
  {
@@ -56,7 +57,7 @@ the worker's chance to finish its current job and exit cleanly.
56
57
  command: "npx velocious background-jobs-worker",
57
58
  replicas: 4,
58
59
  stopSignal: "SIGTERM",
59
- gracefulStopMs: 60000
60
+ gracefulStopMs: "indefinite"
60
61
  }
61
62
  ```
62
63
 
@@ -73,8 +74,9 @@ the worker's chance to finish its current job and exit cleanly.
73
74
 
74
75
  Because old workers are retired on the release's **connection** drain (not on
75
76
  their own job queue draining), a job still running when the release is retired
76
- gets only the `gracefulStopMs` window to finish. Keep jobs **idempotent and
77
- safe to retry** so a job interrupted at the `SIGKILL` fallback can run again.
77
+ gets only the `gracefulStopMs` window to finish, unless `gracefulStopMs` is
78
+ `"indefinite"`. Keep jobs **idempotent and safe to retry** so a job interrupted
79
+ at a finite `SIGKILL` fallback can run again.
78
80
 
79
81
  ## Command-based lifecycle hooks
80
82
 
@@ -107,7 +109,7 @@ in parallel with the connection drain. The new release's workers handle new work
107
109
  while the old workers finish their in-flight jobs:
108
110
 
109
111
  ```js
110
- {id: "worker", policy: "companion", command: "…", nonBlockingDrain: true, gracefulStopMs: 60000}
112
+ {id: "worker", policy: "companion", command: "…", nonBlockingDrain: true, gracefulStopMs: "indefinite"}
111
113
  ```
112
114
 
113
115
  See [`docs/config.md`](config.md) for `stopSignal`, `replicas`, and
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rollbridge",
3
- "version": "0.1.7",
3
+ "version": "0.1.10",
4
4
  "description": "Zero-downtime process supervisor and local traffic switcher for deploy-managed apps.",
5
5
  "keywords": [
6
6
  "deploy",
package/src/config.js CHANGED
@@ -13,7 +13,9 @@ import {pathToFileURL} from "node:url"
13
13
  * @typedef {{backoffFactor: number, maxDelayMs: number, maxRestarts: number | undefined, windowMs: number}} RestartConfig
14
14
  * @typedef {{checkIntervalMs: number, limitBytes: number, warnBytes: number}} MemoryConfig
15
15
  * @typedef {{drainCommand?: string, drainTimeoutMs: number, quietCommand?: string, stopCommand?: string}} LifecycleConfig
16
- * @typedef {{cwd?: string, env: Record<string, string>, gracefulStopMs: number, health?: HealthConfig, id: string, lifecycle: LifecycleConfig, memory?: MemoryConfig, nonBlockingDrain: boolean, outputLines: number, policy: ProcessPolicy, port?: PortRange, replicas: number, restart: RestartConfig, restartDelayMs: number, stopSignal: string, command: string}} ProcessConfig
16
+ * @typedef {number | "indefinite"} StopTimeoutMs
17
+ * @typedef {"persistent" | "handoff"} ServiceDeployStrategy
18
+ * @typedef {{cwd?: string, deployStrategy: ServiceDeployStrategy, env: Record<string, string>, gracefulStopMs: StopTimeoutMs, health?: HealthConfig, id: string, lifecycle: LifecycleConfig, memory?: MemoryConfig, nonBlockingDrain: boolean, outputLines: number, policy: ProcessPolicy, port?: PortRange, replicas: number, restart: RestartConfig, restartDelayMs: number, stopSignal: string, command: string}} ProcessConfig
17
19
  * @typedef {{group?: number | string, mode?: number, owner?: number | string, path: string}} ControlConfig
18
20
  * @typedef {{drainTimeoutMs: number, forceStopTimeoutMs: number, healthPath: string, healthTimeoutMs: number, host: string, port: number, upstreamHost: string}} ProxyConfig
19
21
  * @typedef {{includes: string[], name: string}} LegacyTakeoverProcessConfig
@@ -185,7 +187,7 @@ function normalizeProcess(value, index, proxy, issues) {
185
187
  if (!isPlainObject(value)) {
186
188
  issues.push({fix: `Define processes[${index}] as a mapping with id, policy, and command.`, message: `processes[${index}] must be an object`})
187
189
 
188
- return {command: "", cwd: undefined, env: {}, gracefulStopMs: proxy.forceStopTimeoutMs, health: undefined, id: "", lifecycle: {drainTimeoutMs: 0}, memory: undefined, nonBlockingDrain: false, outputLines: 50, policy: "companion", port: undefined, replicas: 1, restart: defaultRestartConfig(), restartDelayMs: 1000, stopSignal: "SIGTERM"}
190
+ return {command: "", cwd: undefined, deployStrategy: "persistent", env: {}, gracefulStopMs: proxy.forceStopTimeoutMs, health: undefined, id: "", lifecycle: {drainTimeoutMs: 0}, memory: undefined, nonBlockingDrain: false, outputLines: 50, policy: "companion", port: undefined, replicas: 1, restart: defaultRestartConfig(), restartDelayMs: 1000, stopSignal: "SIGTERM"}
189
191
  }
190
192
 
191
193
  const source = value
@@ -193,8 +195,9 @@ function normalizeProcess(value, index, proxy, issues) {
193
195
  return {
194
196
  command: normalizeString(source.command, `processes[${index}].command`, issues),
195
197
  cwd: source.cwd === undefined ? undefined : normalizeString(source.cwd, `processes[${index}].cwd`, issues),
198
+ deployStrategy: normalizeDeployStrategy(source.deployStrategy, `processes[${index}].deployStrategy`, issues),
196
199
  env: normalizeEnv(source.env, `processes[${index}].env`, issues),
197
- gracefulStopMs: normalizeNumber(source.gracefulStopMs, `processes[${index}].gracefulStopMs`, issues, {default: proxy.forceStopTimeoutMs}),
200
+ gracefulStopMs: normalizeStopTimeout(source.gracefulStopMs, `processes[${index}].gracefulStopMs`, proxy.forceStopTimeoutMs, issues),
198
201
  health: normalizeHealth(source.health, `processes[${index}].health`, proxy, issues),
199
202
  id: normalizeString(source.id, `processes[${index}].id`, issues),
200
203
  lifecycle: normalizeLifecycle(source.lifecycle, `processes[${index}].lifecycle`, issues),
@@ -457,6 +460,36 @@ function normalizeStopSignal(value, key, issues) {
457
460
  return "SIGTERM"
458
461
  }
459
462
 
463
+ /**
464
+ * @param {JsonValue} value - Raw graceful stop timeout.
465
+ * @param {string} key - Config key.
466
+ * @param {number} fallback - Default timeout in milliseconds.
467
+ * @param {ConfigIssue[]} issues - Issue collector.
468
+ * @returns {StopTimeoutMs} Normalized stop timeout.
469
+ */
470
+ function normalizeStopTimeout(value, key, fallback, issues) {
471
+ if (value === "indefinite") return "indefinite"
472
+
473
+ const timeoutMs = normalizeNumber(value, key, issues, {default: fallback})
474
+
475
+ return nonNegativeOrDefault(timeoutMs, key, issues, fallback, false)
476
+ }
477
+
478
+ /**
479
+ * @param {JsonValue} value - Raw service deploy strategy.
480
+ * @param {string} key - Config key.
481
+ * @param {ConfigIssue[]} issues - Issue collector.
482
+ * @returns {ServiceDeployStrategy} Normalized service deploy strategy.
483
+ */
484
+ function normalizeDeployStrategy(value, key, issues) {
485
+ if (value === undefined || value === null) return "persistent"
486
+ if (value === "persistent" || value === "handoff") return value
487
+
488
+ issues.push({fix: `Set ${key} to "persistent" or "handoff".`, message: `${key} must be one of: persistent, handoff`})
489
+
490
+ return "persistent"
491
+ }
492
+
460
493
  /**
461
494
  * @param {JsonValue} value - Raw output retention value.
462
495
  * @param {string} key - Config key.
@@ -615,6 +648,16 @@ function validateProcessSet(processes, issues) {
615
648
  issues.push({fix: `Set nonBlockingDrain only on a companion process; "${processConfig.id}" is ${processConfig.policy}.`, message: `Process "${processConfig.id}" can only set nonBlockingDrain on a companion process`})
616
649
  }
617
650
 
651
+ if (processConfig.deployStrategy === "handoff") {
652
+ if (processConfig.policy !== "service") {
653
+ issues.push({fix: `Set deployStrategy: "handoff" only on service processes; "${processConfig.id}" is ${processConfig.policy}.`, message: `Process "${processConfig.id}" can only set deployStrategy: "handoff" on a service process`})
654
+ } else if (!processConfig.port) {
655
+ issues.push({fix: `Add a port range to hand off service "${processConfig.id}" so old and new instances can overlap.`, message: `Handoff service "${processConfig.id}" must define a port range`})
656
+ } else if (processConfig.port.from === processConfig.port.to) {
657
+ issues.push({fix: `Set service "${processConfig.id}" to a multi-port range, e.g. port: {from: ${processConfig.port.from}, to: ${processConfig.port.from + 99}}.`, message: `Handoff service "${processConfig.id}" must use a multi-port range`})
658
+ }
659
+ }
660
+
618
661
  if (processConfig.lifecycle.stopCommand && processConfig.stopSignal !== "SIGTERM") {
619
662
  issues.push({fix: `Drop the custom stopSignal "${processConfig.stopSignal}" or lifecycle.stopCommand from "${processConfig.id}": with a stopCommand set, Rollbridge runs it to stop the process instead of sending stopSignal, so the signal is never used.`, message: `Process "${processConfig.id}" sets both lifecycle.stopCommand and a custom stopSignal, but stopCommand replaces stopSignal`})
620
663
  }
package/src/daemon.js CHANGED
@@ -419,7 +419,7 @@ export default class RollbridgeDaemon {
419
419
  await release.allocatePorts()
420
420
 
421
421
  for (const processConfig of this.config.processes) {
422
- if (processConfig.policy !== "service") continue
422
+ if (processConfig.policy !== "service" || processConfig.deployStrategy === "handoff") continue
423
423
  if (this.services.has(processConfig.id)) continue
424
424
 
425
425
  const service = release.buildProcess(processConfig, {shouldRestart: () => !this.stopping})
@@ -821,7 +821,7 @@ export function releasesToPrune(releases, policy, now) {
821
821
  }
822
822
 
823
823
  /**
824
- * @typedef {{alive: boolean, application?: string, activeReleaseId?: string | null}} ControlSocketInspection
824
+ * @typedef {{alive: boolean, application?: string, activeReleaseId?: string | null, proxy?: {host: string, port: number}}} ControlSocketInspection
825
825
  */
826
826
 
827
827
  /**
@@ -874,7 +874,7 @@ export async function inspectControlSocket(socketPath, timeoutMs = 1000) {
874
874
 
875
875
  const status = parseControlStatus(buffer.slice(0, newlineIndex))
876
876
 
877
- finish(status ? {activeReleaseId: status.activeReleaseId, alive: true, application: status.application} : {alive: true})
877
+ finish(status ? {activeReleaseId: status.activeReleaseId, alive: true, application: status.application, proxy: status.proxy} : {alive: true})
878
878
  })
879
879
  socket.once("error", (error) => {
880
880
  if (settled) return
@@ -896,7 +896,7 @@ export async function inspectControlSocket(socketPath, timeoutMs = 1000) {
896
896
  /**
897
897
  * Parses a control status response line into a Rollbridge identity, if it is one.
898
898
  * @param {string} line - JSON response line.
899
- * @returns {{application: string, activeReleaseId: string | null} | undefined} Identity, or undefined when unrecognized.
899
+ * @returns {{application: string, activeReleaseId: string | null, proxy: {host: string, port: number} | undefined} | undefined} Identity, or undefined when unrecognized.
900
900
  */
901
901
  function parseControlStatus(line) {
902
902
  /** @type {JsonValue} */
@@ -911,5 +911,7 @@ function parseControlStatus(line) {
911
911
  if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return undefined
912
912
  if (typeof parsed.application !== "string") return undefined
913
913
 
914
- return {activeReleaseId: typeof parsed.activeReleaseId === "string" ? parsed.activeReleaseId : null, application: parsed.application}
914
+ const proxy = "proxy" in parsed && parsed.proxy && typeof parsed.proxy === "object" && !Array.isArray(parsed.proxy) && typeof parsed.proxy.host === "string" && typeof parsed.proxy.port === "number" ? {host: parsed.proxy.host, port: parsed.proxy.port} : undefined
915
+
916
+ return {activeReleaseId: typeof parsed.activeReleaseId === "string" ? parsed.activeReleaseId : null, application: parsed.application, proxy}
915
917
  }
package/src/doctor.js CHANGED
@@ -26,7 +26,7 @@ export async function runEnvironmentChecks(config) {
26
26
 
27
27
  checks.push(controlSocketCheck(config, socketInspection))
28
28
  checks.push(await controlSocketDirectoryCheck(config))
29
- checks.push(await proxyPortCheck(config))
29
+ checks.push(await proxyPortCheck(config, socketInspection))
30
30
 
31
31
  if (config.statePath !== undefined) {
32
32
  // A live daemon persists its own (live) pids into the state file, so they are not orphans.
@@ -79,7 +79,7 @@ async function orphanCheck(statePath, daemonRunning) {
79
79
 
80
80
  /**
81
81
  * @param {string} socketPath - Control socket path.
82
- * @returns {Promise<{alive: boolean, application?: string} | {error: string}>} Probe result, or the probe error.
82
+ * @returns {Promise<import("./daemon.js").ControlSocketInspection | {error: string}>} Probe result, or the probe error.
83
83
  */
84
84
  async function inspectControlSocketSafely(socketPath) {
85
85
  try {
@@ -91,7 +91,7 @@ async function inspectControlSocketSafely(socketPath) {
91
91
 
92
92
  /**
93
93
  * @param {import("./config.js").RollbridgeConfig} config - Normalized config.
94
- * @param {{alive: boolean, application?: string} | {error: string}} inspection - Control socket probe result.
94
+ * @param {import("./daemon.js").ControlSocketInspection | {error: string}} inspection - Control socket probe result.
95
95
  * @returns {DoctorCheck} Control socket reachability check.
96
96
  */
97
97
  function controlSocketCheck(config, inspection) {
@@ -109,6 +109,10 @@ function controlSocketCheck(config, inspection) {
109
109
  return {detail: `another process is already listening on ${socketPath}; the daemon would fail to bind it`, name: "control socket", ok: false}
110
110
  }
111
111
 
112
+ if (inspection.application === config.application) {
113
+ return {detail: `Rollbridge daemon for "${inspection.application}" is running on ${socketPath}`, name: "control socket", ok: true}
114
+ }
115
+
112
116
  return {detail: `a Rollbridge daemon for "${inspection.application}" is already running on ${socketPath}; stop it before starting another`, name: "control socket", ok: false}
113
117
  }
114
118
 
@@ -131,9 +135,10 @@ async function controlSocketDirectoryCheck(config) {
131
135
 
132
136
  /**
133
137
  * @param {import("./config.js").RollbridgeConfig} config - Normalized config.
134
- * @returns {Promise<DoctorCheck>} Whether the proxy port can be bound.
138
+ * @param {import("./daemon.js").ControlSocketInspection | {error: string}} inspection - Control socket probe result.
139
+ * @returns {Promise<DoctorCheck>} Whether the proxy port can be bound or is owned by the running daemon.
135
140
  */
136
- async function proxyPortCheck(config) {
141
+ async function proxyPortCheck(config, inspection) {
137
142
  const address = `${config.proxy.host}:${config.proxy.port}`
138
143
  const bind = await canBindPort(config.proxy.host, config.proxy.port)
139
144
 
@@ -141,6 +146,10 @@ async function proxyPortCheck(config) {
141
146
  return {detail: `${address} is available`, name: "proxy port", ok: true}
142
147
  }
143
148
 
149
+ if (!("error" in inspection) && inspection.application === config.application && inspection.proxy?.host === config.proxy.host && inspection.proxy.port === config.proxy.port) {
150
+ return {detail: `${address} is already held by the running Rollbridge daemon`, name: "proxy port", ok: true}
151
+ }
152
+
144
153
  return {detail: `${address} is unavailable (${bind.code})`, name: "proxy port", ok: false}
145
154
  }
146
155
 
@@ -10,7 +10,8 @@ import {processGroupMembers} from "./process-memory.js"
10
10
  * @typedef {"deploy" | "crash" | "manual" | "memory"} ManagedProcessStartReason
11
11
  * @typedef {import("node:child_process").ChildProcess["signalCode"]} ProcessExitSignal
12
12
  * @typedef {{at: string, line: string, stream: "stdout" | "stderr"}} ManagedProcessLog
13
- * @typedef {{command: string, cwd: string | undefined, env: Record<string, string | undefined>, lifecycle: import("./config.js").LifecycleConfig, logger: (message: string, data?: Record<string, import("./json.js").JsonValue>) => void, memory: import("./config.js").MemoryConfig | undefined, outputLines: number, restart: import("./config.js").RestartConfig, restartDelayMs: number, shouldRestart: () => boolean, stopSignal: string, stopTimeoutMs: number}} ManagedProcessDefinition
13
+ * @typedef {import("./config.js").StopTimeoutMs} StopTimeoutMs
14
+ * @typedef {{command: string, cwd: string | undefined, env: Record<string, string | undefined>, lifecycle: import("./config.js").LifecycleConfig, logger: (message: string, data?: Record<string, import("./json.js").JsonValue>) => void, memory: import("./config.js").MemoryConfig | undefined, outputLines: number, restart: import("./config.js").RestartConfig, restartDelayMs: number, shouldRestart: () => boolean, stopSignal: string, stopTimeoutMs: StopTimeoutMs}} ManagedProcessDefinition
14
15
  * @typedef {{children: import("./process-memory.js").ProcessGroupMember[], command: string, cwd: string | undefined, exitCode: number | null | undefined, exitSignal: ProcessExitSignal | undefined, id: string, lastMemoryRestartAt: string | undefined, lastStartReason: ManagedProcessStartReason | undefined, logs: ManagedProcessLog[], memoryRestarts: number, pid: number | undefined, restarts: number, rssBytes: number | undefined, startedAt: string | undefined, state: ManagedProcessState, uptimeMs: number | undefined}} ManagedProcessStatus
15
16
  */
16
17
 
@@ -29,7 +30,7 @@ export default class ManagedProcess extends EventEmitter {
29
30
  * @param {number} args.restartDelayMs - Restart delay.
30
31
  * @param {() => boolean} args.shouldRestart - Restart policy callback.
31
32
  * @param {string} [args.stopSignal] - Signal sent to gracefully stop the process (default "SIGTERM").
32
- * @param {number} args.stopTimeoutMs - Stop timeout.
33
+ * @param {StopTimeoutMs} args.stopTimeoutMs - Stop timeout.
33
34
  */
34
35
  constructor({command, cwd, env, id, lifecycle = {drainTimeoutMs: 0}, logger, memory, outputLines, restart = {backoffFactor: 1, maxDelayMs: 0, maxRestarts: undefined, windowMs: 0}, restartDelayMs, shouldRestart, stopSignal = "SIGTERM", stopTimeoutMs}) {
35
36
  super()
@@ -356,8 +357,10 @@ export default class ManagedProcess extends EventEmitter {
356
357
 
357
358
  const {drainCommand, drainTimeoutMs, quietCommand, stopCommand} = this.lifecycle
358
359
 
360
+ const hookTimeoutMs = this.hookTimeoutMs()
361
+
359
362
  // 1. Quiesce: tell the process to stop accepting new work.
360
- if (quietCommand) await this.runHook(quietCommand, this.stopTimeoutMs, "quiet command")
363
+ if (quietCommand) await this.runHook(quietCommand, hookTimeoutMs, "quiet command")
361
364
 
362
365
  // 2. Drain: let in-flight work finish, bounded by drainTimeoutMs (0 skips the step). A
363
366
  // drainCommand blocks until drained; otherwise wait for the process to exit on its own.
@@ -368,7 +371,7 @@ export default class ManagedProcess extends EventEmitter {
368
371
 
369
372
  // 3. Stop whatever is still running, then SIGKILL if it outlasts the graceful window.
370
373
  if (this.child) {
371
- if (stopCommand) await this.runHook(stopCommand, this.stopTimeoutMs, "stop command")
374
+ if (stopCommand) await this.runHook(stopCommand, hookTimeoutMs, "stop command")
372
375
  else this.killProcessGroup(this.stopSignal)
373
376
 
374
377
  const timeoutMs = options.timeoutMs ?? this.stopTimeoutMs
@@ -383,6 +386,13 @@ export default class ManagedProcess extends EventEmitter {
383
386
  this.state = "stopped"
384
387
  }
385
388
 
389
+ /** @returns {number} Timeout used for lifecycle hooks. */
390
+ hookTimeoutMs() {
391
+ if (this.stopTimeoutMs === "indefinite") return 30000
392
+
393
+ return this.stopTimeoutMs
394
+ }
395
+
386
396
  /**
387
397
  * Runs a lifecycle hook command, bounded by a timeout so a hung hook can never block stop().
388
398
  * Failures are logged and swallowed — the graceful-stop sequence proceeds (and SIGKILL is the
@@ -465,11 +475,15 @@ export default class ManagedProcess extends EventEmitter {
465
475
  }
466
476
 
467
477
  /**
468
- * @param {number} timeoutMs - Timeout.
478
+ * @param {StopTimeoutMs} timeoutMs - Timeout.
469
479
  * @returns {Promise<boolean>} True when the process exited before timeout.
470
480
  */
471
481
  async waitForExit(timeoutMs) {
472
482
  if (!this.exitPromise) return true
483
+ if (timeoutMs === "indefinite") {
484
+ await this.exitPromise
485
+ return true
486
+ }
473
487
 
474
488
  let timer = /** @type {ReturnType<typeof setTimeout> | undefined} */ (undefined)
475
489
  const timeoutPromise = new Promise((resolve) => {
@@ -53,6 +53,7 @@ export default class ReleaseGroup extends EventEmitter {
53
53
  this.connectionCount = 0
54
54
  this.connections = /** @type {ReleaseConnections} */ ({http: 0, websocket: 0})
55
55
  this.processes = /** @type {Map<string, ManagedProcess>} */ (new Map())
56
+ this.handoffServiceIds = /** @type {Set<string>} */ (new Set())
56
57
  this.nonBlockingDrainIds = /** @type {Set<string>} */ (new Set())
57
58
  this.ports = /** @type {Record<string, number>} */ ({})
58
59
  this.servicePorts = servicePorts
@@ -75,6 +76,7 @@ export default class ReleaseGroup extends EventEmitter {
75
76
  const processInstance = this.buildProcess(processConfig, {count: processConfig.replicas, index, instanceId})
76
77
 
77
78
  this.processes.set(instanceId, processInstance)
79
+ if (processConfig.policy === "service" && processConfig.deployStrategy === "handoff") this.handoffServiceIds.add(instanceId)
78
80
  if (processConfig.nonBlockingDrain) this.nonBlockingDrainIds.add(instanceId)
79
81
  await processInstance.start("deploy")
80
82
  }
@@ -154,11 +156,12 @@ export default class ReleaseGroup extends EventEmitter {
154
156
  * @returns {import("./config.js").ProcessConfig[]} Ordered process configs.
155
157
  */
156
158
  releaseProcessStartOrder() {
157
- const releaseProcesses = this.config.processes.filter((processConfig) => !["singleton", "service"].includes(processConfig.policy))
159
+ const releaseProcesses = this.config.processes.filter((processConfig) => processConfig.policy !== "singleton" && (processConfig.policy !== "service" || processConfig.deployStrategy === "handoff"))
160
+ const serviceProcesses = releaseProcesses.filter((processConfig) => processConfig.policy === "service")
158
161
  const companionProcesses = releaseProcesses.filter((processConfig) => processConfig.policy === "companion")
159
162
  const proxiedProcesses = releaseProcesses.filter((processConfig) => processConfig.policy === "proxied")
160
163
 
161
- return [...companionProcesses, ...proxiedProcesses]
164
+ return [...serviceProcesses, ...companionProcesses, ...proxiedProcesses]
162
165
  }
163
166
 
164
167
  /** @returns {void} Marks this release active. */
@@ -175,7 +178,7 @@ export default class ReleaseGroup extends EventEmitter {
175
178
 
176
179
  for (const processConfig of this.config.processes) {
177
180
  if (!processConfig.port) continue
178
- if (processConfig.policy === "service" && this.servicePorts[processConfig.id] !== undefined) {
181
+ if (processConfig.policy === "service" && processConfig.deployStrategy !== "handoff" && this.servicePorts[processConfig.id] !== undefined) {
179
182
  this.ports[processConfig.id] = this.servicePorts[processConfig.id]
180
183
  usedPorts.add(this.servicePorts[processConfig.id])
181
184
  continue
@@ -331,7 +334,8 @@ export default class ReleaseGroup extends EventEmitter {
331
334
  // connection drain, not held until after it. The rest stop once connections have closed.
332
335
  const entries = [...this.processes.entries()]
333
336
  const nonBlockingStops = entries.filter(([id]) => this.nonBlockingDrainIds.has(id)).map(([, processInstance]) => processInstance.stop())
334
- const connectionDependent = entries.filter(([id]) => !this.nonBlockingDrainIds.has(id)).map(([, processInstance]) => processInstance)
337
+ const handoffServices = entries.filter(([id]) => this.handoffServiceIds.has(id)).map(([, processInstance]) => processInstance)
338
+ const connectionDependent = entries.filter(([id]) => !this.nonBlockingDrainIds.has(id) && !this.handoffServiceIds.has(id)).map(([, processInstance]) => processInstance)
335
339
 
336
340
  if (this.connectionCount > 0) {
337
341
  await new Promise((resolve) => {
@@ -345,6 +349,7 @@ export default class ReleaseGroup extends EventEmitter {
345
349
 
346
350
  await Promise.allSettled(connectionDependent.map((processInstance) => processInstance.stop()))
347
351
  await Promise.allSettled(nonBlockingStops)
352
+ await Promise.allSettled(handoffServices.map((processInstance) => processInstance.stop()))
348
353
  this.state = "stopped"
349
354
  this.stoppedAt = new Date().toISOString()
350
355
  }
@@ -204,6 +204,81 @@ test("validateConfig defaults lifecycle, accepts hooks, and rejects bad values",
204
204
  assert.deepEqual(validateLifecycle({stopCommand: "kill -TERM $ROLLBRIDGE_PID"}).issues, [])
205
205
  })
206
206
 
207
+ test("validateConfig accepts indefinite graceful stop windows", () => {
208
+ const {config, issues} = validateConfig({
209
+ application: "demo",
210
+ control: {path: "/tmp/demo.sock"},
211
+ processes: [
212
+ {command: "run web", id: "web", policy: "proxied", port: {from: 18000, to: 18099}},
213
+ {command: "run worker", gracefulStopMs: "indefinite", id: "worker", policy: "companion"}
214
+ ],
215
+ proxy: {host: "127.0.0.1", port: 8182}
216
+ })
217
+
218
+ assert.deepEqual(issues, [])
219
+ assert.equal(config.processes[1].gracefulStopMs, "indefinite")
220
+ })
221
+
222
+ test("validateConfig accepts handoff services only with a multi-port service range", () => {
223
+ const valid = validateConfig({
224
+ application: "demo",
225
+ control: {path: "/tmp/demo.sock"},
226
+ processes: [
227
+ {command: "run web", id: "web", policy: "proxied", port: {from: 18000, to: 18099}},
228
+ {command: "run service", deployStrategy: "handoff", id: "beacon", policy: "service", port: {from: 18100, to: 18199}}
229
+ ],
230
+ proxy: {host: "127.0.0.1", port: 8182}
231
+ })
232
+
233
+ assert.deepEqual(valid.issues, [])
234
+ assert.equal(valid.config.processes[1].deployStrategy, "handoff")
235
+
236
+ const defaulted = validateConfig({
237
+ application: "demo",
238
+ control: {path: "/tmp/demo.sock"},
239
+ processes: [{command: "run web", id: "web", policy: "proxied", port: {from: 18000, to: 18099}}],
240
+ proxy: {host: "127.0.0.1", port: 8182}
241
+ })
242
+
243
+ assert.equal(defaulted.config.processes[0].deployStrategy, "persistent")
244
+
245
+ const invalidProcess = validateConfig({
246
+ application: "demo",
247
+ control: {path: "/tmp/demo.sock"},
248
+ processes: [
249
+ {command: "run web", id: "web", policy: "proxied", port: {from: 18000, to: 18099}},
250
+ {command: "run worker", deployStrategy: "handoff", id: "worker", policy: "companion"}
251
+ ],
252
+ proxy: {host: "127.0.0.1", port: 8182}
253
+ })
254
+
255
+ assert.ok(invalidProcess.issues.some((issue) => issue.message === "Process \"worker\" can only set deployStrategy: \"handoff\" on a service process"), JSON.stringify(invalidProcess.issues))
256
+
257
+ const missingPort = validateConfig({
258
+ application: "demo",
259
+ control: {path: "/tmp/demo.sock"},
260
+ processes: [
261
+ {command: "run web", id: "web", policy: "proxied", port: {from: 18000, to: 18099}},
262
+ {command: "run service", deployStrategy: "handoff", id: "beacon", policy: "service"}
263
+ ],
264
+ proxy: {host: "127.0.0.1", port: 8182}
265
+ })
266
+
267
+ assert.ok(missingPort.issues.some((issue) => issue.message === "Handoff service \"beacon\" must define a port range"), JSON.stringify(missingPort.issues))
268
+
269
+ const fixedPort = validateConfig({
270
+ application: "demo",
271
+ control: {path: "/tmp/demo.sock"},
272
+ processes: [
273
+ {command: "run web", id: "web", policy: "proxied", port: {from: 18000, to: 18099}},
274
+ {command: "run service", deployStrategy: "handoff", id: "beacon", policy: "service", port: 18100}
275
+ ],
276
+ proxy: {host: "127.0.0.1", port: 8182}
277
+ })
278
+
279
+ assert.ok(fixedPort.issues.some((issue) => issue.message === "Handoff service \"beacon\" must use a multi-port range"), JSON.stringify(fixedPort.issues))
280
+ })
281
+
207
282
  test("validateConfig rejects a custom stopSignal alongside a stopCommand that would ignore it", () => {
208
283
  /**
209
284
  * @param {Record<string, import("../src/json.js").JsonValue>} overrides - Extra fields merged onto the worker process.
@@ -188,7 +188,7 @@ test("runEnvironmentChecks reports a missing control socket directory", async ()
188
188
  assert.match(directoryCheck.detail, /missing or not writable/)
189
189
  })
190
190
 
191
- test("runEnvironmentChecks fails when a Rollbridge daemon already holds the socket and port", async () => {
191
+ test("runEnvironmentChecks passes when the running Rollbridge daemon holds the socket and port", async () => {
192
192
  const root = await fs.mkdtemp(path.join(os.tmpdir(), "rollbridge-doctor-"))
193
193
  const config = buildConfig({controlPath: path.join(root, "rollbridge.sock"), proxyPort: await freePort()})
194
194
  const daemon = new RollbridgeDaemon({config, logger: () => {}})
@@ -199,11 +199,33 @@ test("runEnvironmentChecks fails when a Rollbridge daemon already holds the sock
199
199
  const checks = await runEnvironmentChecks(config)
200
200
  const socketCheck = checkNamed(checks, "control socket")
201
201
 
202
- // A daemon already running means `rollbridge daemon` would fail to bind, so doctor must fail too.
203
- assert.equal(socketCheck.ok, false)
204
- assert.match(socketCheck.detail, /a Rollbridge daemon for "doctor-test" is already running/)
202
+ assert.equal(socketCheck.ok, true)
203
+ assert.match(socketCheck.detail, /Rollbridge daemon for "doctor-test" is running/)
204
+ assert.equal(checkNamed(checks, "proxy port").ok, true)
205
+ } finally {
206
+ await daemon.shutdown()
207
+ await fs.rm(root, {force: true, recursive: true})
208
+ }
209
+ })
210
+
211
+ test("runEnvironmentChecks fails when the running daemon does not own the configured proxy port", async () => {
212
+ const root = await fs.mkdtemp(path.join(os.tmpdir(), "rollbridge-doctor-"))
213
+ const config = buildConfig({controlPath: path.join(root, "rollbridge.sock"), proxyPort: await freePort()})
214
+ const changedConfigPort = await freePort()
215
+ const changedConfig = buildConfig({controlPath: config.control.path, proxyPort: changedConfigPort})
216
+ const server = net.createServer()
217
+ const daemon = new RollbridgeDaemon({config, logger: () => {}})
218
+
219
+ await daemon.start()
220
+ await new Promise((resolve) => server.listen(changedConfigPort, "127.0.0.1", () => resolve(undefined)))
221
+
222
+ try {
223
+ const checks = await runEnvironmentChecks(changedConfig)
224
+
225
+ assert.equal(checkNamed(checks, "control socket").ok, true)
205
226
  assert.equal(checkNamed(checks, "proxy port").ok, false)
206
227
  } finally {
228
+ await new Promise((resolve) => server.close(() => resolve(undefined)))
207
229
  await daemon.shutdown()
208
230
  await fs.rm(root, {force: true, recursive: true})
209
231
  }
@@ -1,10 +1,12 @@
1
1
  // @ts-check
2
2
 
3
3
  import http from "node:http"
4
+ import crypto from "node:crypto"
4
5
 
5
6
  const port = Number(requiredEnv("ROLLBRIDGE_PORT"))
6
7
  const servicePort = Number(requiredEnv("ROLLBRIDGE_BEACON_PORT"))
7
8
  const releaseId = process.env.ROLLBRIDGE_RELEASE_ID || "unknown"
9
+ const sockets = new Set()
8
10
 
9
11
  await waitForService()
10
12
 
@@ -21,6 +23,37 @@ const server = http.createServer((request, response) => {
21
23
 
22
24
  process.on("SIGTERM", () => {
23
25
  server.close(() => process.exit(0))
26
+
27
+ if (sockets.size === 0) {
28
+ setTimeout(() => process.exit(0), 10)
29
+ }
30
+ })
31
+
32
+ server.on("upgrade", (request, socket) => {
33
+ const key = request.headers["sec-websocket-key"]
34
+
35
+ if (typeof key !== "string") {
36
+ socket.end("HTTP/1.1 400 Bad Request\r\n\r\n")
37
+ return
38
+ }
39
+
40
+ const accept = crypto
41
+ .createHash("sha1")
42
+ .update(`${key}258EAFA5-E914-47DA-95CA-C5AB0DC85B11`)
43
+ .digest("base64")
44
+
45
+ sockets.add(socket)
46
+ socket.once("close", () => sockets.delete(socket))
47
+ socket.on("data", () => {
48
+ socket.end()
49
+ })
50
+ socket.write([
51
+ "HTTP/1.1 101 Switching Protocols",
52
+ "Upgrade: websocket",
53
+ "Connection: Upgrade",
54
+ `Sec-WebSocket-Accept: ${accept}`,
55
+ "\r\n"
56
+ ].join("\r\n"))
24
57
  })
25
58
 
26
59
  server.listen(port, "127.0.0.1")
@@ -381,6 +381,39 @@ test("sends the configured stopSignal as the graceful stop signal", async () =>
381
381
  assert.equal(managed.status().state, "stopped")
382
382
  })
383
383
 
384
+ test("indefinite stop waits for the process to exit without SIGKILL", async () => {
385
+ const managed = new ManagedProcess({
386
+ command: `${JSON.stringify(process.execPath)} -e ${JSON.stringify("process.on('SIGTERM', () => setTimeout(() => process.exit(0), 150)); setInterval(() => {}, 1000)")}`,
387
+ cwd: undefined,
388
+ env: {},
389
+ id: "worker",
390
+ logger: () => {},
391
+ outputLines: 50,
392
+ restartDelayMs: 10,
393
+ shouldRestart: () => false,
394
+ stopSignal: "SIGTERM",
395
+ stopTimeoutMs: "indefinite"
396
+ })
397
+ /** @type {string[]} */
398
+ const signals = []
399
+ const killProcessGroup = managed.killProcessGroup.bind(managed)
400
+
401
+ managed.killProcessGroup = (signal) => {
402
+ signals.push(signal)
403
+ killProcessGroup(signal)
404
+ }
405
+
406
+ try {
407
+ await managed.start()
408
+ await managed.stop()
409
+
410
+ assert.equal(managed.status().state, "stopped")
411
+ assert.deepEqual(signals, ["SIGTERM"])
412
+ } finally {
413
+ await managed.stop()
414
+ }
415
+ })
416
+
384
417
  test("a memory restart respawns and is counted when the supervisor still wants the process", async () => {
385
418
  const managed = buildLongLived(() => true)
386
419
 
@@ -243,6 +243,80 @@ test("service processes start before releases and restart with the latest deploy
243
243
  }
244
244
  })
245
245
 
246
+ test("handoff services start per release and drain with their release", async () => {
247
+ const fixture = await createFixture({handoffService: true, webDependsOnService: true})
248
+ const daemon = await startDaemon(fixture.config)
249
+ /** @type {WebSocket | undefined} */
250
+ let socket
251
+
252
+ try {
253
+ await daemon.deploy({releaseId: "v1", releasePath: fixture.root, revision: "v1"})
254
+ socket = await openWebSocket(daemon)
255
+ const v1 = statusRelease(daemon, "v1")
256
+ const v1Service = v1.processes.find((processStatus) => processStatus.id === "beacon")
257
+
258
+ assert.ok(v1Service?.pid, "v1 service should be running")
259
+ assert.equal(v1.ports.beacon > 0, true)
260
+
261
+ await daemon.deploy({releaseId: "v2", releasePath: fixture.root, revision: "v2"})
262
+ const v2 = statusRelease(daemon, "v2")
263
+ const v2Service = v2.processes.find((processStatus) => processStatus.id === "beacon")
264
+
265
+ assert.ok(v2Service?.pid, "v2 service should be running")
266
+ assert.notEqual(v2.ports.beacon, v1.ports.beacon)
267
+ assert.equal(statusRelease(daemon, "v1").processes.find((processStatus) => processStatus.id === "beacon")?.state, "running")
268
+
269
+ socket.close()
270
+ socket = undefined
271
+
272
+ await waitFor(() => statusRelease(daemon, "v1").state === "stopped")
273
+ assert.equal(statusRelease(daemon, "v1").processes.find((processStatus) => processStatus.id === "beacon")?.state, "stopped")
274
+
275
+ const events = await processEvents(fixture.serviceLogPath)
276
+
277
+ assert.ok(events.some((event) => event.event === "start" && event.releaseId === "v1"), "v1 service should start")
278
+ assert.ok(events.some((event) => event.event === "start" && event.releaseId === "v2"), "v2 service should start")
279
+ assert.ok(events.some((event) => event.event === "stop" && event.releaseId === "v1"), "v1 service should stop after drain")
280
+ } finally {
281
+ if (socket) socket.close()
282
+ await daemon.shutdown()
283
+ await fs.rm(fixture.root, {force: true, recursive: true})
284
+ }
285
+ })
286
+
287
+ test("handoff services stop after release-local dependents finish draining", async () => {
288
+ const fixture = await createFixture({handoffService: true, nonBlockingDrainWorker: true, webDependsOnService: true, workerStopDelayMs: 200})
289
+ const daemon = await startDaemon(fixture.config)
290
+ /** @type {WebSocket | undefined} */
291
+ let socket
292
+
293
+ try {
294
+ await daemon.deploy({releaseId: "v1", releasePath: fixture.root, revision: "v1"})
295
+ socket = await openWebSocket(daemon)
296
+ await daemon.deploy({releaseId: "v2", releasePath: fixture.root, revision: "v2"})
297
+
298
+ await waitFor(() => statusRelease(daemon, "v1").processes.find((processStatus) => processStatus.id === "worker")?.state === "stopped")
299
+
300
+ const drainingRelease = statusRelease(daemon, "v1")
301
+
302
+ assert.equal(drainingRelease.state, "draining")
303
+ assert.equal(drainingRelease.processes.find((processStatus) => processStatus.id === "beacon")?.state, "running")
304
+
305
+ socket.close()
306
+ socket = undefined
307
+ await waitFor(() => statusRelease(daemon, "v1").state === "stopped")
308
+
309
+ const events = await processEvents(fixture.serviceLogPath)
310
+ const v1ServiceStop = events.find((event) => event.event === "stop" && event.releaseId === "v1")
311
+
312
+ assert.ok(v1ServiceStop, "v1 handoff service should stop after release drain")
313
+ } finally {
314
+ if (socket) socket.close()
315
+ await daemon.shutdown()
316
+ await fs.rm(fixture.root, {force: true, recursive: true})
317
+ }
318
+ })
319
+
246
320
  test("a replicated companion starts one instance per replica, and restart targets one or all", async () => {
247
321
  const fixture = await createFixture({companionReplicas: 3})
248
322
  const daemon = await startDaemon(fixture.config)
@@ -972,7 +1046,7 @@ test("deploy can ensure the daemon before sending the release command", async ()
972
1046
  })
973
1047
 
974
1048
  /**
975
- * @param {{companionReplicas?: number, includeCompanion?: boolean, includeService?: boolean, includeSingleton?: boolean, memoryLimitBytes?: number, nonBlockingDrainWorker?: boolean, persistState?: boolean, proxyHost?: string, singletonCwd?: string, webCommand?: string, webDependsOnService?: boolean, webHealthTimeoutMs?: number}} [options] - Fixture options.
1049
+ * @param {{companionReplicas?: number, handoffService?: boolean, includeCompanion?: boolean, includeService?: boolean, includeSingleton?: boolean, memoryLimitBytes?: number, nonBlockingDrainWorker?: boolean, persistState?: boolean, proxyHost?: string, singletonCwd?: string, webCommand?: string, webDependsOnService?: boolean, webHealthTimeoutMs?: number, workerStopDelayMs?: number}} [options] - Fixture options.
976
1050
  * @returns {Promise<{config: import("../src/config.js").RollbridgeConfig, root: string, serviceLogPath: string, singletonLogPath: string, statePath: string}>} Fixture data.
977
1051
  */
978
1052
  async function createFixture(options = {}) {
@@ -983,15 +1057,16 @@ async function createFixture(options = {}) {
983
1057
  /** @type {Array<Record<string, import("../src/json.js").JsonValue>>} */
984
1058
  const processes = []
985
1059
 
986
- if (options.includeService) {
1060
+ if (options.includeService || options.handoffService) {
987
1061
  processes.push({
988
1062
  command: `${JSON.stringify(process.execPath)} ${JSON.stringify(serviceAppPath)} --release={{releaseId}}`,
1063
+ ...(options.handoffService ? {deployStrategy: "handoff"} : {}),
989
1064
  env: {
990
1065
  ROLLBRIDGE_SERVICE_LOG: serviceLogPath
991
1066
  },
992
1067
  id: "beacon",
993
1068
  policy: "service",
994
- port: {from: 0, to: 0},
1069
+ port: options.handoffService ? {from: 15000, to: 15099} : {from: 0, to: 0},
995
1070
  restartDelayMs: 50
996
1071
  })
997
1072
  }
@@ -1015,7 +1090,7 @@ async function createFixture(options = {}) {
1015
1090
 
1016
1091
  if (options.nonBlockingDrainWorker) {
1017
1092
  processes.push({
1018
- command: `${JSON.stringify(process.execPath)} -e ${JSON.stringify("setInterval(() => {}, 1000)")}`,
1093
+ command: `${JSON.stringify(process.execPath)} -e ${JSON.stringify(`process.on('SIGTERM', () => setTimeout(() => process.exit(0), ${options.workerStopDelayMs || 0})); setInterval(() => {}, 1000)`)}`,
1019
1094
  id: "worker",
1020
1095
  nonBlockingDrain: true,
1021
1096
  policy: "companion"