unitup 0.4.0 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -7,6 +7,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.4.1] - 2026-10-18
11
+
12
+ ### Added
13
+ - **Supervisor Active Generation TCP Health Verification & Self-Healing**: Supervisor now validates TCP connectivity on the internal port (`active.internalPort`) of persisted active generations at startup. If the target generation is dead or refusing connections, supervisor cleans up stale state and automatically triggers a fresh deployment, eliminating stale `502 Bad Gateway (ECONNREFUSED)` loops.
14
+ - **Systemd Crash Loop Circuit Breaker (`RestartPreventExitStatus=78`)**: Added `RestartPreventExitStatus=78` (POSIX `EX_CONFIG`) to generated systemd unit files, paired with `StartLimitIntervalSec=30s` and `StartLimitBurst=5`. When supervisor encounters unrecoverable configuration errors (such as public port collision / `EADDRINUSE`), it exits with code 78, preventing systemd from endlessly restarting and spamming journald logs.
15
+ - **Enhanced Connection Diagnostics in Router**: Detailed internal port and error context (`connect ECONNREFUSED 127.0.0.1:<port>`) reported on proxy failures to clarify application startup crashes.
16
+ - **Exported `IPCClient` Type Definitions**: Exported `IPCClient` class and interface in `index.d.ts` for clean downstream programmatic integration by orchestrators.
17
+
18
+ ### Fixed
19
+ - **Systemd Unit Parser & Directives**: Added extraction and preservation of `RestartPreventExitStatus` in `parseUnitContent` to maintain parity across read/write cycles.
20
+ - **Router In-Flight Request Timing**: Resolved timing race condition in `test/router.test.js` in-flight request tracking test.
21
+
10
22
  ## [0.4.0] - 2026-10-18
11
23
 
12
24
  ### Added
package/index.d.ts CHANGED
@@ -688,6 +688,12 @@ export class RollbackManager {
688
688
  ): Promise<RollbackResult>;
689
689
  }
690
690
 
691
+ export class IPCClient {
692
+ constructor(serviceName: string, ipcDir?: string);
693
+ isAlive(): Promise<boolean>;
694
+ send(message: Record<string, unknown>, onProgress?: (msg: unknown) => void, timeoutMs?: number): Promise<unknown>;
695
+ }
696
+
691
697
  export class Supervisor {
692
698
  constructor(serviceName: string, serviceConfig?: Record<string, unknown>, options?: Record<string, unknown>);
693
699
  start(): Promise<Supervisor>;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "unitup",
3
- "version": "0.4.0",
3
+ "version": "0.4.1",
4
4
  "description": "Minimal, zero-dependency cross-platform native service manager (systemd, launchd, Windows Services) for any runtime, script, or executable",
5
5
  "main": "src/index.js",
6
6
  "types": "./index.d.ts",
@@ -385,7 +385,11 @@ export class Router {
385
385
  cleanupInFlight();
386
386
  if (!res.headersSent) {
387
387
  res.writeHead(502, { 'Content-Type': 'text/plain; charset=utf-8' });
388
- res.end(`502 Bad Gateway: Error communicating with generation #${genId} (${err.message}).`);
388
+ const errorDetail =
389
+ err.code === 'ECONNREFUSED'
390
+ ? `Connection refused at 127.0.0.1:${backend.internalPort}. The application process may have crashed or not bound to process.env.PORT.`
391
+ : err.message;
392
+ res.end(`502 Bad Gateway: Error communicating with generation #${genId} (${errorDetail}).`);
389
393
  } else {
390
394
  res.destroy();
391
395
  }
@@ -146,12 +146,25 @@ export class Supervisor {
146
146
 
147
147
  // 3. Check if there is an active running generation from persisted state
148
148
  const active = this.generationManager.getActive(this.serviceName);
149
+ let activeHealthy = false;
149
150
  if (active && this.processManager.isAlive(active.pid)) {
150
- if (publicPort) {
151
- await this.router.listen(publicPort, '0.0.0.0', this.serviceName);
151
+ // Verify internal port is actually listening to prevent 502 Bad Gateway
152
+ activeHealthy = await this.readiness.checkTcp(active.internalPort, '127.0.0.1', 1000);
153
+ if (activeHealthy) {
154
+ if (publicPort) {
155
+ await this.router.listen(publicPort, '0.0.0.0', this.serviceName);
156
+ }
157
+ this.router.switchBackend(this.serviceName, active, publicPort);
158
+ }
159
+ }
160
+
161
+ if (!activeHealthy) {
162
+ if (active) {
163
+ process.stderr.write(
164
+ `[unitup supervisor] Persisted active generation #${active.id} (PID ${active.pid}) is not listening on internal port :${active.internalPort}. Deploying fresh instance...\n`
165
+ );
166
+ this.generationManager.remove(active, false);
152
167
  }
153
- this.router.switchBackend(this.serviceName, active, publicPort);
154
- } else {
155
168
  // Start initial generation
156
169
  await this.deploymentManager.deploy(this.serviceName, this.config, {
157
170
  publicPort,
@@ -216,7 +229,11 @@ export class Supervisor {
216
229
  await supervisor.start();
217
230
  } catch (err) {
218
231
  process.stderr.write(`[unitup supervisor] Failed to start: ${err.message}\n`);
219
- process.exit(1);
232
+ const isUnrecoverableConfigError =
233
+ err.message?.includes('already occupied') ||
234
+ err.message?.includes('EADDRINUSE') ||
235
+ err.name === 'PortMismatchError';
236
+ process.exit(isUnrecoverableConfigError ? 78 : 1);
220
237
  }
221
238
  }
222
239
  }
package/src/unit.js CHANGED
@@ -107,6 +107,8 @@ export function generateUnitContent(opts) {
107
107
  '[Unit]',
108
108
  `Description=unitup service: ${safeName}`,
109
109
  'After=network.target',
110
+ 'StartLimitIntervalSec=30s',
111
+ 'StartLimitBurst=5',
110
112
  '',
111
113
  '[Service]',
112
114
  'Type=simple',
@@ -115,6 +117,7 @@ export function generateUnitContent(opts) {
115
117
  `ExecStart=${execStartLine}`,
116
118
  `Restart=${restartPolicy}`,
117
119
  'RestartSec=3',
120
+ 'RestartPreventExitStatus=78',
118
121
  'StandardOutput=journal',
119
122
  'StandardError=journal',
120
123
  `LogLevelMax=${logLevelMax}`,
@@ -266,6 +269,8 @@ export function parseUnitContent(content) {
266
269
  }
267
270
  } else if (trimmed.startsWith('Restart=')) {
268
271
  result.restart = trimmed.slice(8).trim();
272
+ } else if (trimmed.startsWith('RestartPreventExitStatus=')) {
273
+ result.restartPreventExitStatus = trimmed.slice(25).trim();
269
274
  } else if (trimmed.startsWith('MemoryHigh=')) {
270
275
  result.memoryHigh = trimmed.slice(11).trim();
271
276
  } else if (trimmed.startsWith('MemoryMax=')) {