rollbridge 0.1.8 → 0.1.11

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.8",
3
+ "version": "0.1.11",
4
4
  "description": "Zero-downtime process supervisor and local traffic switcher for deploy-managed apps.",
5
5
  "keywords": [
6
6
  "deploy",
@@ -27,10 +27,11 @@
27
27
  },
28
28
  "scripts": {
29
29
  "all-checks": "npm run typecheck && npm run lint && npm test",
30
- "lint": "eslint",
30
+ "lint": "npm run eslint && npm run typecheck",
31
31
  "release:patch": "release-patch",
32
32
  "test": "node --test test/*.test.js",
33
- "typecheck": "tsc --noEmit"
33
+ "typecheck": "tsc --noEmit",
34
+ "eslint": "eslint"
34
35
  },
35
36
  "engines": {
36
37
  "node": ">=20"
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})
@@ -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
  }
@@ -89,8 +91,9 @@ export default class ReleaseGroup extends EventEmitter {
89
91
  }
90
92
  } catch (error) {
91
93
  this.state = "failed"
92
- this.logStartupFailure(error instanceof Error ? error : String(error))
94
+ this.logStartupFailure(error instanceof Error ? error : String(error), {phase: "before cleanup"})
93
95
  await this.stop()
96
+ this.logStartupFailure(error instanceof Error ? error : String(error), {phase: "after cleanup"})
94
97
  throw error
95
98
  }
96
99
  }
@@ -123,13 +126,15 @@ export default class ReleaseGroup extends EventEmitter {
123
126
  }
124
127
 
125
128
  /**
126
- * Logs process diagnostics before failed startup cleanup stops and removes the release processes.
129
+ * Logs process diagnostics around failed startup cleanup.
127
130
  * @param {Error | string} error - Startup failure.
131
+ * @param {{phase: string}} options - Diagnostic phase.
128
132
  * @returns {void}
129
133
  */
130
- logStartupFailure(error) {
134
+ logStartupFailure(error, {phase}) {
131
135
  this.logger("release startup failed", {
132
136
  error: error instanceof Error ? error.message : error,
137
+ phase,
133
138
  releaseId: this.releaseId
134
139
  })
135
140
 
@@ -141,6 +146,7 @@ export default class ReleaseGroup extends EventEmitter {
141
146
  exitCode: status.exitCode ?? null,
142
147
  exitSignal: status.exitSignal ?? null,
143
148
  logs: status.logs,
149
+ phase,
144
150
  pid: status.pid ?? null,
145
151
  processId: status.id,
146
152
  releaseId: this.releaseId,
@@ -154,11 +160,12 @@ export default class ReleaseGroup extends EventEmitter {
154
160
  * @returns {import("./config.js").ProcessConfig[]} Ordered process configs.
155
161
  */
156
162
  releaseProcessStartOrder() {
157
- const releaseProcesses = this.config.processes.filter((processConfig) => !["singleton", "service"].includes(processConfig.policy))
163
+ const releaseProcesses = this.config.processes.filter((processConfig) => processConfig.policy !== "singleton" && (processConfig.policy !== "service" || processConfig.deployStrategy === "handoff"))
164
+ const serviceProcesses = releaseProcesses.filter((processConfig) => processConfig.policy === "service")
158
165
  const companionProcesses = releaseProcesses.filter((processConfig) => processConfig.policy === "companion")
159
166
  const proxiedProcesses = releaseProcesses.filter((processConfig) => processConfig.policy === "proxied")
160
167
 
161
- return [...companionProcesses, ...proxiedProcesses]
168
+ return [...serviceProcesses, ...companionProcesses, ...proxiedProcesses]
162
169
  }
163
170
 
164
171
  /** @returns {void} Marks this release active. */
@@ -175,7 +182,7 @@ export default class ReleaseGroup extends EventEmitter {
175
182
 
176
183
  for (const processConfig of this.config.processes) {
177
184
  if (!processConfig.port) continue
178
- if (processConfig.policy === "service" && this.servicePorts[processConfig.id] !== undefined) {
185
+ if (processConfig.policy === "service" && processConfig.deployStrategy !== "handoff" && this.servicePorts[processConfig.id] !== undefined) {
179
186
  this.ports[processConfig.id] = this.servicePorts[processConfig.id]
180
187
  usedPorts.add(this.servicePorts[processConfig.id])
181
188
  continue
@@ -331,7 +338,8 @@ export default class ReleaseGroup extends EventEmitter {
331
338
  // connection drain, not held until after it. The rest stop once connections have closed.
332
339
  const entries = [...this.processes.entries()]
333
340
  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)
341
+ const handoffServices = entries.filter(([id]) => this.handoffServiceIds.has(id)).map(([, processInstance]) => processInstance)
342
+ const connectionDependent = entries.filter(([id]) => !this.nonBlockingDrainIds.has(id) && !this.handoffServiceIds.has(id)).map(([, processInstance]) => processInstance)
335
343
 
336
344
  if (this.connectionCount > 0) {
337
345
  await new Promise((resolve) => {
@@ -345,6 +353,7 @@ export default class ReleaseGroup extends EventEmitter {
345
353
 
346
354
  await Promise.allSettled(connectionDependent.map((processInstance) => processInstance.stop()))
347
355
  await Promise.allSettled(nonBlockingStops)
356
+ await Promise.allSettled(handoffServices.map((processInstance) => processInstance.stop()))
348
357
  this.state = "stopped"
349
358
  this.stoppedAt = new Date().toISOString()
350
359
  }
@@ -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.
@@ -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
 
@@ -123,8 +123,8 @@ test("wildcard proxy bind host targets release processes through loopback", asyn
123
123
  }
124
124
  })
125
125
 
126
- test("failed release startup logs process output before cleanup", async () => {
127
- const fixture = await createFixture({webCommand: `${JSON.stringify(process.execPath)} -e "console.log('startup stdout'); console.error('startup stderr'); const http = require('node:http'); http.createServer((_request, response) => { response.writeHead(500); response.end('bad') }).listen(Number(process.env.ROLLBRIDGE_PORT), '127.0.0.1')"`, webHealthTimeoutMs: 500})
126
+ test("failed release startup logs process output and cleanup status", async () => {
127
+ const fixture = await createFixture({handoffService: true, webCommand: `${JSON.stringify(process.execPath)} -e "console.log('startup stdout'); console.error('startup stderr'); const http = require('node:http'); http.createServer((_request, response) => { response.writeHead(500); response.end('bad') }).listen(Number(process.env.ROLLBRIDGE_PORT), '127.0.0.1')"`, webHealthTimeoutMs: 500})
128
128
  /** @type {Array<{data?: Record<string, import("../src/json.js").JsonValue>, message: string}>} */
129
129
  const logs = []
130
130
  const daemon = new RollbridgeDaemon({
@@ -140,13 +140,22 @@ test("failed release startup logs process output before cleanup", async () => {
140
140
  /Health check failed/
141
141
  )
142
142
 
143
- const processStatusLog = logs.find((entry) => entry.message === "release startup process status" && entry.data?.processId === "web")
143
+ const processStatusLog = logs.find((entry) => entry.message === "release startup process status" && entry.data?.phase === "before cleanup" && entry.data?.processId === "web")
144
+ const cleanupProcessStatusLog = logs.find((entry) => entry.message === "release startup process status" && entry.data?.phase === "after cleanup" && entry.data?.processId === "web")
145
+ const handoffServiceStatusLog = logs.find((entry) => entry.message === "release startup process status" && entry.data?.phase === "after cleanup" && entry.data?.processId === "beacon")
144
146
 
145
147
  assert.ok(processStatusLog, "expected failed web process diagnostics to be logged")
146
148
  assert.ok(processStatusLog.data, "expected diagnostic data")
147
149
  assert.ok(Array.isArray(processStatusLog.data.logs), "expected retained process output in diagnostics")
148
150
  assert.ok(processStatusLog.data.logs.some((entry) => typeof entry === "object" && entry && "line" in entry && entry.line === "startup stdout"))
149
151
  assert.ok(processStatusLog.data.logs.some((entry) => typeof entry === "object" && entry && "line" in entry && entry.line === "startup stderr"))
152
+ assert.equal(processStatusLog.data.state, "running")
153
+ assert.ok(cleanupProcessStatusLog, "expected failed web cleanup diagnostics to be logged")
154
+ assert.equal(cleanupProcessStatusLog.data?.state, "stopped")
155
+ assert.equal(cleanupProcessStatusLog.data?.exitSignal, "SIGTERM")
156
+ assert.ok(handoffServiceStatusLog, "expected handoff service cleanup diagnostics to be logged")
157
+ assert.equal(handoffServiceStatusLog.data?.state, "stopped")
158
+ assert.equal(handoffServiceStatusLog.data?.exitSignal, "SIGTERM")
150
159
  } finally {
151
160
  await daemon.shutdown()
152
161
  await fs.rm(fixture.root, {force: true, recursive: true})
@@ -243,6 +252,80 @@ test("service processes start before releases and restart with the latest deploy
243
252
  }
244
253
  })
245
254
 
255
+ test("handoff services start per release and drain with their release", async () => {
256
+ const fixture = await createFixture({handoffService: true, webDependsOnService: true})
257
+ const daemon = await startDaemon(fixture.config)
258
+ /** @type {WebSocket | undefined} */
259
+ let socket
260
+
261
+ try {
262
+ await daemon.deploy({releaseId: "v1", releasePath: fixture.root, revision: "v1"})
263
+ socket = await openWebSocket(daemon)
264
+ const v1 = statusRelease(daemon, "v1")
265
+ const v1Service = v1.processes.find((processStatus) => processStatus.id === "beacon")
266
+
267
+ assert.ok(v1Service?.pid, "v1 service should be running")
268
+ assert.equal(v1.ports.beacon > 0, true)
269
+
270
+ await daemon.deploy({releaseId: "v2", releasePath: fixture.root, revision: "v2"})
271
+ const v2 = statusRelease(daemon, "v2")
272
+ const v2Service = v2.processes.find((processStatus) => processStatus.id === "beacon")
273
+
274
+ assert.ok(v2Service?.pid, "v2 service should be running")
275
+ assert.notEqual(v2.ports.beacon, v1.ports.beacon)
276
+ assert.equal(statusRelease(daemon, "v1").processes.find((processStatus) => processStatus.id === "beacon")?.state, "running")
277
+
278
+ socket.close()
279
+ socket = undefined
280
+
281
+ await waitFor(() => statusRelease(daemon, "v1").state === "stopped")
282
+ assert.equal(statusRelease(daemon, "v1").processes.find((processStatus) => processStatus.id === "beacon")?.state, "stopped")
283
+
284
+ const events = await processEvents(fixture.serviceLogPath)
285
+
286
+ assert.ok(events.some((event) => event.event === "start" && event.releaseId === "v1"), "v1 service should start")
287
+ assert.ok(events.some((event) => event.event === "start" && event.releaseId === "v2"), "v2 service should start")
288
+ assert.ok(events.some((event) => event.event === "stop" && event.releaseId === "v1"), "v1 service should stop after drain")
289
+ } finally {
290
+ if (socket) socket.close()
291
+ await daemon.shutdown()
292
+ await fs.rm(fixture.root, {force: true, recursive: true})
293
+ }
294
+ })
295
+
296
+ test("handoff services stop after release-local dependents finish draining", async () => {
297
+ const fixture = await createFixture({handoffService: true, nonBlockingDrainWorker: true, webDependsOnService: true, workerStopDelayMs: 200})
298
+ const daemon = await startDaemon(fixture.config)
299
+ /** @type {WebSocket | undefined} */
300
+ let socket
301
+
302
+ try {
303
+ await daemon.deploy({releaseId: "v1", releasePath: fixture.root, revision: "v1"})
304
+ socket = await openWebSocket(daemon)
305
+ await daemon.deploy({releaseId: "v2", releasePath: fixture.root, revision: "v2"})
306
+
307
+ await waitFor(() => statusRelease(daemon, "v1").processes.find((processStatus) => processStatus.id === "worker")?.state === "stopped")
308
+
309
+ const drainingRelease = statusRelease(daemon, "v1")
310
+
311
+ assert.equal(drainingRelease.state, "draining")
312
+ assert.equal(drainingRelease.processes.find((processStatus) => processStatus.id === "beacon")?.state, "running")
313
+
314
+ socket.close()
315
+ socket = undefined
316
+ await waitFor(() => statusRelease(daemon, "v1").state === "stopped")
317
+
318
+ const events = await processEvents(fixture.serviceLogPath)
319
+ const v1ServiceStop = events.find((event) => event.event === "stop" && event.releaseId === "v1")
320
+
321
+ assert.ok(v1ServiceStop, "v1 handoff service should stop after release drain")
322
+ } finally {
323
+ if (socket) socket.close()
324
+ await daemon.shutdown()
325
+ await fs.rm(fixture.root, {force: true, recursive: true})
326
+ }
327
+ })
328
+
246
329
  test("a replicated companion starts one instance per replica, and restart targets one or all", async () => {
247
330
  const fixture = await createFixture({companionReplicas: 3})
248
331
  const daemon = await startDaemon(fixture.config)
@@ -972,7 +1055,7 @@ test("deploy can ensure the daemon before sending the release command", async ()
972
1055
  })
973
1056
 
974
1057
  /**
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.
1058
+ * @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
1059
  * @returns {Promise<{config: import("../src/config.js").RollbridgeConfig, root: string, serviceLogPath: string, singletonLogPath: string, statePath: string}>} Fixture data.
977
1060
  */
978
1061
  async function createFixture(options = {}) {
@@ -983,15 +1066,16 @@ async function createFixture(options = {}) {
983
1066
  /** @type {Array<Record<string, import("../src/json.js").JsonValue>>} */
984
1067
  const processes = []
985
1068
 
986
- if (options.includeService) {
1069
+ if (options.includeService || options.handoffService) {
987
1070
  processes.push({
988
1071
  command: `${JSON.stringify(process.execPath)} ${JSON.stringify(serviceAppPath)} --release={{releaseId}}`,
1072
+ ...(options.handoffService ? {deployStrategy: "handoff"} : {}),
989
1073
  env: {
990
1074
  ROLLBRIDGE_SERVICE_LOG: serviceLogPath
991
1075
  },
992
1076
  id: "beacon",
993
1077
  policy: "service",
994
- port: {from: 0, to: 0},
1078
+ port: options.handoffService ? {from: 15000, to: 15099} : {from: 0, to: 0},
995
1079
  restartDelayMs: 50
996
1080
  })
997
1081
  }
@@ -1015,7 +1099,7 @@ async function createFixture(options = {}) {
1015
1099
 
1016
1100
  if (options.nonBlockingDrainWorker) {
1017
1101
  processes.push({
1018
- command: `${JSON.stringify(process.execPath)} -e ${JSON.stringify("setInterval(() => {}, 1000)")}`,
1102
+ command: `${JSON.stringify(process.execPath)} -e ${JSON.stringify(`process.on('SIGTERM', () => setTimeout(() => process.exit(0), ${options.workerStopDelayMs || 0})); setInterval(() => {}, 1000)`)}`,
1019
1103
  id: "worker",
1020
1104
  nonBlockingDrain: true,
1021
1105
  policy: "companion"