@teamscala/service-boot 0.23.304 → 0.23.305

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/package.json CHANGED
@@ -6,7 +6,7 @@
6
6
  },
7
7
  "name": "@teamscala/service-boot",
8
8
  "type": "module",
9
- "version": "0.23.304",
9
+ "version": "0.23.305",
10
10
  "sideEffects": false,
11
11
  "files": [
12
12
  "src"
@@ -16,11 +16,17 @@
16
16
  * The rule, and why it is not "env always wins":
17
17
  *
18
18
  * Railway injects PORT and healthchecks that exact port, reporting the service
19
- * unavailable if it listens elsewhere — so where there is NO routing row (a
20
- * Railway-only backend), the env is the only truth and must win.
19
+ * unavailable if it listens elsewhere. So on Railway the injected PORT is
20
+ * authoritative period — a `service_install`/`service_routing` row carries the
21
+ * service's LOCAL dev port (e.g. portal 5200), and on Railway binding that
22
+ * instead of PORT means Railway probes a port nothing listens on and the deploy
23
+ * fails healthcheck. The registry-wins rule below exists to stop LOCAL port
24
+ * collisions (one host, many services); inside a Railway container there are no
25
+ * sibling services to collide with, so PORT must win. `isRailway` is the caller's
26
+ * assertion that it is booting inside Railway (from `RAILWAY_ENVIRONMENT`).
21
27
  *
22
- * But a `service_routing` row means this service has a DECLARED port on this
23
- * host. There, a PORT that disagrees is a polluted launcher environment, never
28
+ * Off Railway, a `service_routing`/`service_install` row means this service has
29
+ * a DECLARED port on this host. There, a PORT that disagrees is a polluted launcher environment, never
24
30
  * an intention, and obeying it binds someone else's port. That is not a
25
31
  * hypothetical: mcp-ai-chat-system booted onto 3021 — session-picker's port —
26
32
  * while its own MCP_SERVER_URL still pointed at 8080. Both processes bound 3021,
@@ -60,7 +66,10 @@ export interface PortResolution {
60
66
  * @param envPort parsed PORT env, or undefined when unset/unparseable
61
67
  * @param declaredPort `service_routing.local_port`, or undefined when the
62
68
  * service has no routing row (the Railway-only case)
69
+ * @param isRailway true when booting inside Railway (caller derives from
70
+ * `RAILWAY_ENVIRONMENT`); there the injected PORT is authoritative
71
+ * regardless of the row, because Railway healthchecks it
63
72
  */
64
- export declare function resolveServicePort(envPort: number | undefined, declaredPort: number | undefined): PortResolution | {
73
+ export declare function resolveServicePort(envPort: number | undefined, declaredPort: number | undefined, isRailway?: boolean): PortResolution | {
65
74
  error: "no-port-source";
66
75
  };
@@ -95,4 +95,31 @@ describe("resolveServicePort", () => {
95
95
  });
96
96
  });
97
97
  });
98
+
99
+ describe("Railway: injected PORT is authoritative over the row's dev port", () => {
100
+ // The 2026-07-31 portal incident: portal declares its dev port 5200 in
101
+ // service_install, Railway injects PORT=8080 and healthchecks 8080. The
102
+ // registry-wins rule (correct on the shared dev host) made portal bind 5200
103
+ // on Railway, so Railway's healthcheck probed a dead port and the deploy
104
+ // failed — despite the service booting healthy. On Railway PORT must win.
105
+ test("a conflicting declared port does NOT override Railway's injected PORT", () => {
106
+ expect(resolveServicePort(8080, 5200, true)).toEqual({ port: 8080, source: "env" });
107
+ });
108
+
109
+ test("an agreeing declared port still resolves to env on Railway", () => {
110
+ expect(resolveServicePort(8080, 8080, true)).toEqual({ port: 8080, source: "env" });
111
+ });
112
+
113
+ test("Railway with no injected PORT falls back to the declared port", () => {
114
+ expect(resolveServicePort(undefined, 5200, true)).toEqual({ port: 5200, source: "service_routing" });
115
+ });
116
+
117
+ test("isRailway defaults to false: off Railway the registry still wins a conflict", () => {
118
+ expect(resolveServicePort(8080, 5200)).toEqual({
119
+ port: 5200,
120
+ source: "service_routing",
121
+ conflict: { envPort: 8080, declaredPort: 5200 },
122
+ });
123
+ });
124
+ });
98
125
  });
@@ -16,11 +16,17 @@
16
16
  * The rule, and why it is not "env always wins":
17
17
  *
18
18
  * Railway injects PORT and healthchecks that exact port, reporting the service
19
- * unavailable if it listens elsewhere — so where there is NO routing row (a
20
- * Railway-only backend), the env is the only truth and must win.
19
+ * unavailable if it listens elsewhere. So on Railway the injected PORT is
20
+ * authoritative period — a `service_install`/`service_routing` row carries the
21
+ * service's LOCAL dev port (e.g. portal 5200), and on Railway binding that
22
+ * instead of PORT means Railway probes a port nothing listens on and the deploy
23
+ * fails healthcheck. The registry-wins rule below exists to stop LOCAL port
24
+ * collisions (one host, many services); inside a Railway container there are no
25
+ * sibling services to collide with, so PORT must win. `isRailway` is the caller's
26
+ * assertion that it is booting inside Railway (from `RAILWAY_ENVIRONMENT`).
21
27
  *
22
- * But a `service_routing` row means this service has a DECLARED port on this
23
- * host. There, a PORT that disagrees is a polluted launcher environment, never
28
+ * Off Railway, a `service_routing`/`service_install` row means this service has
29
+ * a DECLARED port on this host. There, a PORT that disagrees is a polluted launcher environment, never
24
30
  * an intention, and obeying it binds someone else's port. That is not a
25
31
  * hypothetical: mcp-ai-chat-system booted onto 3021 — session-picker's port —
26
32
  * while its own MCP_SERVER_URL still pointed at 8080. Both processes bound 3021,
@@ -63,14 +69,26 @@ function isBindablePort(value: number | undefined): value is number {
63
69
  * @param envPort parsed PORT env, or undefined when unset/unparseable
64
70
  * @param declaredPort `service_routing.local_port`, or undefined when the
65
71
  * service has no routing row (the Railway-only case)
72
+ * @param isRailway true when booting inside Railway (caller derives from
73
+ * `RAILWAY_ENVIRONMENT`); there the injected PORT is authoritative
74
+ * regardless of the row, because Railway healthchecks it
66
75
  */
67
76
  export function resolveServicePort(
68
77
  envPort: number | undefined,
69
78
  declaredPort: number | undefined,
79
+ isRailway = false,
70
80
  ): PortResolution | { error: "no-port-source" } {
71
81
  const envUsable = isBindablePort(envPort);
72
82
  const declaredUsable = isBindablePort(declaredPort);
73
83
 
84
+ // On Railway the injected PORT is authoritative: Railway healthchecks that
85
+ // exact port, and the declared port is the service's LOCAL dev port — binding
86
+ // it makes the healthcheck probe a port nothing listens on. There are no
87
+ // sibling services to collide with inside a container, so PORT wins.
88
+ if (isRailway && envUsable) {
89
+ return { port: envPort, source: "env" };
90
+ }
91
+
74
92
  // No routing row: Railway's injected PORT is the only truth available.
75
93
  if (!declaredUsable) {
76
94
  return envUsable ? { port: envPort, source: "env" } : { error: "no-port-source" };
package/src/ui-service.ts CHANGED
@@ -74,9 +74,13 @@ export async function bootUiService(
74
74
  //
75
75
  // This branch previously took `process.env.PORT` unconditionally, which is
76
76
  // how scala-quiz (declared 5203) booted onto session-picker's 3021 from a
77
- // leaked env and silently split that daemon's traffic (2026-07-31). Railway
78
- // is unaffected by construction: it injects PORT and a Railway-only service
79
- // has no declared port, so the env still wins there.
77
+ // leaked env and silently split that daemon's traffic (2026-07-31). On the
78
+ // dev host the registry wins a conflict (below). On Railway the injected PORT
79
+ // wins instead — Railway healthchecks exactly that port, and the row's port
80
+ // is the service's LOCAL dev port (e.g. portal 5200), so binding it on Railway
81
+ // makes the healthcheck probe a dead port and the deploy fails (`isRailway`
82
+ // path in the resolver).
83
+ const isRailway = Boolean(process.env.RAILWAY_ENVIRONMENT);
80
84
  const envPort = process.env.PORT ? Number(process.env.PORT) : undefined;
81
85
  // The APP binds its PRIVATE port (app_port) when present — for scale-to-zero
82
86
  // services `port` is the ACTIVATOR's ingress (what callers resolve), not the
@@ -85,7 +89,7 @@ export async function bootUiService(
85
89
  // bind the activator's port → SO_REUSEPORT silent load-balancing (the
86
90
  // service-ports-from-registry trap: clean boot, two owners in ss -ltnp).
87
91
  const declaredAppPort = config.app_port ?? config.port;
88
- const resolution = resolveServicePort(envPort, declaredAppPort);
92
+ const resolution = resolveServicePort(envPort, declaredAppPort, isRailway);
89
93
  if ("error" in resolution) {
90
94
  throw new BootFatalError(
91
95
  "ui-service.port-resolution",