@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
|
@@ -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
|
|
20
|
-
*
|
|
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
|
-
*
|
|
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
|
|
20
|
-
*
|
|
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
|
-
*
|
|
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).
|
|
78
|
-
//
|
|
79
|
-
//
|
|
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",
|