@stigmer/cli 3.15.0 → 3.15.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/commands/internal-daemon.d.ts.map +1 -1
- package/commands/internal-daemon.js +2 -9
- package/commands/internal-daemon.js.map +1 -1
- package/commands/up.d.ts.map +1 -1
- package/commands/up.js +37 -8
- package/commands/up.js.map +1 -1
- package/local/artifact.d.ts +13 -3
- package/local/artifact.d.ts.map +1 -1
- package/local/artifact.js +31 -10
- package/local/artifact.js.map +1 -1
- package/local/daemon/components.d.ts.map +1 -1
- package/local/daemon/components.js +2 -0
- package/local/daemon/components.js.map +1 -1
- package/local/daemon/host.d.ts +8 -0
- package/local/daemon/host.d.ts.map +1 -1
- package/local/daemon/host.js +42 -1
- package/local/daemon/host.js.map +1 -1
- package/local/daemon/index.d.ts +3 -3
- package/local/daemon/index.d.ts.map +1 -1
- package/local/daemon/index.js +2 -2
- package/local/daemon/index.js.map +1 -1
- package/local/daemon/launch.d.ts +29 -1
- package/local/daemon/launch.d.ts.map +1 -1
- package/local/daemon/launch.js +73 -23
- package/local/daemon/launch.js.map +1 -1
- package/local/daemon/process.d.ts +16 -2
- package/local/daemon/process.d.ts.map +1 -1
- package/local/daemon/process.js +16 -2
- package/local/daemon/process.js.map +1 -1
- package/local/daemon/types.d.ts +2 -0
- package/local/daemon/types.d.ts.map +1 -1
- package/local/paths.d.ts +14 -4
- package/local/paths.d.ts.map +1 -1
- package/local/paths.js +17 -4
- package/local/paths.js.map +1 -1
- package/local/runtime/runner.d.ts +4 -0
- package/local/runtime/runner.d.ts.map +1 -1
- package/local/runtime/runner.js +10 -6
- package/local/runtime/runner.js.map +1 -1
- package/local/runtime/server.d.ts +5 -0
- package/local/runtime/server.d.ts.map +1 -1
- package/local/runtime/server.js +13 -8
- package/local/runtime/server.js.map +1 -1
- package/local/seedpack/content.d.ts +4 -0
- package/local/seedpack/content.d.ts.map +1 -1
- package/local/seedpack/content.js +11 -11
- package/local/seedpack/content.js.map +1 -1
- package/local/state/lock.d.ts +3 -2
- package/local/state/lock.d.ts.map +1 -1
- package/local/state/lock.js +23 -9
- package/local/state/lock.js.map +1 -1
- package/local/state/proc.d.ts +13 -0
- package/local/state/proc.d.ts.map +1 -1
- package/local/state/proc.js +15 -0
- package/local/state/proc.js.map +1 -1
- package/local/temporal/download.d.ts +7 -1
- package/local/temporal/download.d.ts.map +1 -1
- package/local/temporal/download.js +21 -11
- package/local/temporal/download.js.map +1 -1
- package/local/temporal/index.d.ts +3 -3
- package/local/temporal/index.d.ts.map +1 -1
- package/local/temporal/index.js +2 -2
- package/local/temporal/index.js.map +1 -1
- package/local/temporal/inspect.d.ts +10 -3
- package/local/temporal/inspect.d.ts.map +1 -1
- package/local/temporal/inspect.js +52 -13
- package/local/temporal/inspect.js.map +1 -1
- package/local/temporal/manager.d.ts +16 -4
- package/local/temporal/manager.d.ts.map +1 -1
- package/local/temporal/manager.js +19 -6
- package/local/temporal/manager.js.map +1 -1
- package/package.json +5 -5
- package/src/commands/internal-daemon.ts +2 -10
- package/src/commands/up.ts +52 -8
- package/src/local/artifact.test.ts +27 -1
- package/src/local/artifact.ts +35 -13
- package/src/local/daemon/components.ts +2 -0
- package/src/local/daemon/daemon.integration.test.ts +33 -0
- package/src/local/daemon/host.ts +50 -1
- package/src/local/daemon/index.ts +3 -3
- package/src/local/daemon/launch.test.ts +136 -0
- package/src/local/daemon/launch.ts +108 -27
- package/src/local/daemon/process.ts +31 -4
- package/src/local/daemon/supervisor.test.ts +1 -1
- package/src/local/daemon/types.ts +2 -0
- package/src/local/paths.ts +17 -4
- package/src/local/runtime/runner.ts +10 -6
- package/src/local/runtime/runtime.test.ts +15 -0
- package/src/local/runtime/server.test.ts +37 -0
- package/src/local/runtime/server.ts +17 -12
- package/src/local/seedpack/content.test.ts +21 -0
- package/src/local/seedpack/content.ts +15 -16
- package/src/local/state/lock.ts +24 -9
- package/src/local/state/proc.ts +16 -0
- package/src/local/state/state.test.ts +25 -1
- package/src/local/temporal/download.test.ts +79 -11
- package/src/local/temporal/download.ts +24 -11
- package/src/local/temporal/index.ts +11 -3
- package/src/local/temporal/inspect.test.ts +83 -0
- package/src/local/temporal/inspect.ts +60 -12
- package/src/local/temporal/manager.test.ts +34 -1
- package/src/local/temporal/manager.ts +22 -8
|
@@ -6,7 +6,7 @@ import { type HealthState, loadHealthState, writeHealthState } from "./health-st
|
|
|
6
6
|
import { acquireLock } from "./lock.js";
|
|
7
7
|
import { cleanupOldLogs, rotateLogs } from "./log-rotation.js";
|
|
8
8
|
import { readPidFile, removePidFile, writePidFile } from "./pidfile.js";
|
|
9
|
-
import { isProcessAlive } from "./proc.js";
|
|
9
|
+
import { isOtherLiveProcess, isProcessAlive } from "./proc.js";
|
|
10
10
|
import { loadStartupConfig, removeStartupConfig, saveStartupConfig, type StartupConfig } from "./startup-config.js";
|
|
11
11
|
|
|
12
12
|
function tempDir(prefix: string): string {
|
|
@@ -46,6 +46,17 @@ describe("isProcessAlive", () => {
|
|
|
46
46
|
it("is true for the current process and false for a dead PID", () => {
|
|
47
47
|
expect(isProcessAlive(process.pid)).toBe(true);
|
|
48
48
|
expect(isProcessAlive(DEAD_PID)).toBe(false);
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
describe("isOtherLiveProcess", () => {
|
|
53
|
+
// A recorded PID equal to our own is a leftover from a previous life of this
|
|
54
|
+
// PID (a restarted container's fresh PID namespace, a laptop after reboot),
|
|
55
|
+
// never a live peer — the distinction every stale-state reader needs.
|
|
56
|
+
it("is false for the current process, true for its parent, false for a dead PID", () => {
|
|
57
|
+
expect(isOtherLiveProcess(process.pid)).toBe(false);
|
|
58
|
+
expect(isOtherLiveProcess(process.ppid)).toBe(true);
|
|
59
|
+
expect(isOtherLiveProcess(DEAD_PID)).toBe(false);
|
|
49
60
|
expect(isProcessAlive(0)).toBe(false);
|
|
50
61
|
expect(isProcessAlive(-1)).toBe(false);
|
|
51
62
|
});
|
|
@@ -176,6 +187,19 @@ describe("lock", () => {
|
|
|
176
187
|
expect(readFileSync(path, "utf8").trim()).toBe(String(process.pid));
|
|
177
188
|
});
|
|
178
189
|
|
|
190
|
+
// The container case: after an unclean stop the lock file on the volume names
|
|
191
|
+
// the PID the daemon had last time — and a fresh PID namespace hands the
|
|
192
|
+
// restarted daemon that same PID. We are not holding a lock we are trying to
|
|
193
|
+
// acquire, so an owner equal to ourselves is stale by construction.
|
|
194
|
+
it("reclaims a lock whose recorded owner is this very process (a previous life of the PID)", () => {
|
|
195
|
+
const path = join(tempDir("stigmer-lock-"), "temporal.lock");
|
|
196
|
+
writeFileSync(path, `${process.pid}\n`);
|
|
197
|
+
const lock = acquireLock(path);
|
|
198
|
+
expect(lock).not.toBeNull();
|
|
199
|
+
lock?.release();
|
|
200
|
+
expect(acquireLock(path)).not.toBeNull();
|
|
201
|
+
});
|
|
202
|
+
|
|
179
203
|
it("release is idempotent and only removes our own lock", () => {
|
|
180
204
|
const path = join(tempDir("stigmer-lock-"), "temporal.lock");
|
|
181
205
|
const lock = acquireLock(path);
|
|
@@ -1,9 +1,16 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { existsSync, mkdtempSync, readFileSync } from "node:fs";
|
|
2
3
|
import { tmpdir } from "node:os";
|
|
3
4
|
import { join } from "node:path";
|
|
4
5
|
import { gzipSync } from "fflate";
|
|
5
6
|
import { describe, expect, it } from "vitest";
|
|
6
|
-
import {
|
|
7
|
+
import {
|
|
8
|
+
TEMPORAL_CHECKSUMS_FILE,
|
|
9
|
+
downloadTemporalCli,
|
|
10
|
+
extractTarEntry,
|
|
11
|
+
temporalArchiveName,
|
|
12
|
+
temporalReleaseAssetUrl,
|
|
13
|
+
} from "./download.js";
|
|
7
14
|
|
|
8
15
|
// Build a minimal ustar archive containing a single regular-file entry. Only
|
|
9
16
|
// the fields the reader uses (name, octal size, type flag) are populated.
|
|
@@ -40,24 +47,85 @@ describe("extractTarEntry", () => {
|
|
|
40
47
|
});
|
|
41
48
|
});
|
|
42
49
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
50
|
+
// A release as GitHub serves it: the archive for the requested platform plus a
|
|
51
|
+
// release-wide checksums.txt listing every asset. `fetchImpl` routes by URL so
|
|
52
|
+
// the checksum arm is exercised against a realistic multi-line file.
|
|
53
|
+
function release(version: string, archives: Record<string, Buffer>, checksums: string): typeof fetch {
|
|
54
|
+
return (async (input: string | URL | Request) => {
|
|
55
|
+
const url = String(input);
|
|
56
|
+
const name = url.slice(url.lastIndexOf("/") + 1);
|
|
57
|
+
if (name === TEMPORAL_CHECKSUMS_FILE) {
|
|
58
|
+
return { ok: true, status: 200, text: async () => checksums } as unknown as Response;
|
|
59
|
+
}
|
|
60
|
+
const gz = archives[name];
|
|
61
|
+
if (gz === undefined) return { ok: false, status: 404, arrayBuffer: async () => new ArrayBuffer(0) } as unknown as Response;
|
|
62
|
+
return {
|
|
48
63
|
ok: true,
|
|
49
64
|
status: 200,
|
|
50
65
|
arrayBuffer: async () => gz.buffer.slice(gz.byteOffset, gz.byteOffset + gz.byteLength),
|
|
51
|
-
}
|
|
66
|
+
} as unknown as Response;
|
|
67
|
+
}) as unknown as typeof fetch;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function archiveFor(payload: Buffer): Buffer {
|
|
71
|
+
return Buffer.from(gzipSync(new Uint8Array(makeTar("temporal", payload))));
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function sha256(bytes: Buffer): string {
|
|
75
|
+
return createHash("sha256").update(bytes).digest("hex");
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
describe("downloadTemporalCli", () => {
|
|
79
|
+
const linuxArm = temporalArchiveName("1.5.1", "linux", "arm64");
|
|
80
|
+
const linuxAmd = temporalArchiveName("1.5.1", "linux", "x64");
|
|
81
|
+
|
|
82
|
+
it("names the release assets the way Temporal publishes them", () => {
|
|
83
|
+
expect(linuxArm).toBe("temporal_cli_1.5.1_linux_arm64.tar.gz");
|
|
84
|
+
expect(linuxAmd).toBe("temporal_cli_1.5.1_linux_amd64.tar.gz");
|
|
85
|
+
expect(temporalArchiveName("1.5.1", "darwin", "arm64")).toBe("temporal_cli_1.5.1_darwin_arm64.tar.gz");
|
|
86
|
+
expect(temporalReleaseAssetUrl("1.5.1", TEMPORAL_CHECKSUMS_FILE)).toBe(
|
|
87
|
+
"https://github.com/temporalio/cli/releases/download/v1.5.1/checksums.txt",
|
|
88
|
+
);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("downloads, verifies against the matching checksums.txt line, gunzips, untars, and writes the binary", async () => {
|
|
92
|
+
const arm = archiveFor(Buffer.from("the-arm64-binary"));
|
|
93
|
+
const amd = archiveFor(Buffer.from("the-amd64-binary"));
|
|
94
|
+
// amd64 listed FIRST: a first-token parser would verify arm64 against it.
|
|
95
|
+
const checksums = `${sha256(amd)} ${linuxAmd}\n${sha256(arm)} ${linuxArm}\n`;
|
|
96
|
+
const fetchImpl = release("1.5.1", { [linuxArm]: arm, [linuxAmd]: amd }, checksums);
|
|
52
97
|
|
|
53
98
|
const binPath = join(mkdtempSync(join(tmpdir(), "stigmer-temporal-")), "bin", "temporal");
|
|
54
|
-
await downloadTemporalCli({ version: "1.5.1", binPath, fetchImpl });
|
|
99
|
+
await downloadTemporalCli({ version: "1.5.1", binPath, platform: "linux", arch: "arm64", fetchImpl });
|
|
100
|
+
|
|
101
|
+
expect(readFileSync(binPath, "utf8")).toBe("the-arm64-binary");
|
|
102
|
+
});
|
|
55
103
|
|
|
56
|
-
|
|
104
|
+
it("refuses an archive whose digest does not match its checksums.txt line", async () => {
|
|
105
|
+
const arm = archiveFor(Buffer.from("tampered"));
|
|
106
|
+
const checksums = `${"0".repeat(64)} ${linuxArm}\n`;
|
|
107
|
+
const fetchImpl = release("1.5.1", { [linuxArm]: arm }, checksums);
|
|
108
|
+
|
|
109
|
+
const binPath = join(mkdtempSync(join(tmpdir(), "stigmer-temporal-")), "temporal");
|
|
110
|
+
await expect(
|
|
111
|
+
downloadTemporalCli({ version: "1.5.1", binPath, platform: "linux", arch: "arm64", fetchImpl }),
|
|
112
|
+
).rejects.toThrow(/checksum mismatch/);
|
|
113
|
+
expect(existsSync(binPath)).toBe(false);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it("refuses an archive the checksums.txt has no entry for", async () => {
|
|
117
|
+
const arm = archiveFor(Buffer.from("unlisted"));
|
|
118
|
+
const checksums = `${sha256(arm)} some_other_asset.tar.gz\n`;
|
|
119
|
+
const fetchImpl = release("1.5.1", { [linuxArm]: arm }, checksums);
|
|
120
|
+
|
|
121
|
+
const binPath = join(mkdtempSync(join(tmpdir(), "stigmer-temporal-")), "temporal");
|
|
122
|
+
await expect(
|
|
123
|
+
downloadTemporalCli({ version: "1.5.1", binPath, platform: "linux", arch: "arm64", fetchImpl }),
|
|
124
|
+
).rejects.toThrow(/checksum mismatch/);
|
|
57
125
|
});
|
|
58
126
|
|
|
59
127
|
it("fails clearly on a non-OK response", async () => {
|
|
60
|
-
const fetchImpl = (
|
|
128
|
+
const fetchImpl = release("9.9.9", {}, "");
|
|
61
129
|
const binPath = join(mkdtempSync(join(tmpdir(), "stigmer-temporal-")), "temporal");
|
|
62
130
|
await expect(downloadTemporalCli({ version: "9.9.9", binPath, fetchImpl })).rejects.toThrow(/HTTP 404/);
|
|
63
131
|
});
|
|
@@ -2,12 +2,14 @@
|
|
|
2
2
|
//
|
|
3
3
|
// `up` needs a `temporal` binary and the upstream release is a small, public,
|
|
4
4
|
// stable artifact. The fetch/gunzip/untar/write mechanics are shared with the
|
|
5
|
-
//
|
|
6
|
-
// asset-naming and the default version.
|
|
5
|
+
// other release downloaders in `../artifact.ts`; this module only owns
|
|
6
|
+
// Temporal's asset-naming and the default version.
|
|
7
7
|
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
8
|
+
// Every download is verified against the release's `checksums.txt` (the
|
|
9
|
+
// `sha256sum` file Temporal publishes beside its archives) before the binary is
|
|
10
|
+
// written: the integrity guarantee is the published digest, not merely HTTPS
|
|
11
|
+
// plus GitHub's release immutability. The same code path stages the binary the
|
|
12
|
+
// all-in-one image bakes, so one pin and one verification serve every channel.
|
|
11
13
|
|
|
12
14
|
import { existsSync } from "node:fs";
|
|
13
15
|
import { fetchTarballBinary, mapReleaseArch, mapReleaseOs } from "../artifact.js";
|
|
@@ -19,6 +21,9 @@ export { extractTarEntry } from "../artifact.js";
|
|
|
19
21
|
/** Default Temporal CLI version (matches the Go CLI's `DefaultTemporalVersion`). */
|
|
20
22
|
export const DEFAULT_TEMPORAL_VERSION = "1.5.1";
|
|
21
23
|
|
|
24
|
+
/** The release's `sha256sum`-format digest file, one line per published asset. */
|
|
25
|
+
export const TEMPORAL_CHECKSUMS_FILE = "checksums.txt";
|
|
26
|
+
|
|
22
27
|
export interface TemporalDownloadTarget {
|
|
23
28
|
/** Temporal CLI version, e.g. "1.5.1". */
|
|
24
29
|
version: string;
|
|
@@ -32,15 +37,23 @@ export interface TemporalDownloadTarget {
|
|
|
32
37
|
fetchImpl?: typeof fetch;
|
|
33
38
|
}
|
|
34
39
|
|
|
35
|
-
/**
|
|
40
|
+
/** The release asset name for a version on a platform, as Temporal publishes it. */
|
|
41
|
+
export function temporalArchiveName(version: string, platform: NodeJS.Platform, arch: string): string {
|
|
42
|
+
return `temporal_cli_${version}_${mapReleaseOs(platform)}_${mapReleaseArch(arch)}.tar.gz`;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** The GitHub release download URL for one asset of a version. */
|
|
46
|
+
export function temporalReleaseAssetUrl(version: string, asset: string): string {
|
|
47
|
+
return `https://github.com/temporalio/cli/releases/download/v${version}/${asset}`;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Download, verify and install the Temporal CLI binary to `binPath`. */
|
|
36
51
|
export async function downloadTemporalCli(target: TemporalDownloadTarget): Promise<void> {
|
|
37
|
-
const
|
|
38
|
-
const goarch = mapReleaseArch(target.arch ?? process.arch);
|
|
39
|
-
const archive = `temporal_cli_${target.version}_${goos}_${goarch}.tar.gz`;
|
|
40
|
-
const url = `https://github.com/temporalio/cli/releases/download/v${target.version}/${archive}`;
|
|
52
|
+
const archive = temporalArchiveName(target.version, target.platform ?? process.platform, target.arch ?? process.arch);
|
|
41
53
|
|
|
42
54
|
await fetchTarballBinary({
|
|
43
|
-
url,
|
|
55
|
+
url: temporalReleaseAssetUrl(target.version, archive),
|
|
56
|
+
checksumUrl: temporalReleaseAssetUrl(target.version, TEMPORAL_CHECKSUMS_FILE),
|
|
44
57
|
entryName: "temporal",
|
|
45
58
|
binPath: target.binPath,
|
|
46
59
|
label: "Temporal CLI",
|
|
@@ -1,6 +1,14 @@
|
|
|
1
1
|
// Public surface of the Temporal subsystem.
|
|
2
2
|
|
|
3
|
-
export {
|
|
4
|
-
|
|
5
|
-
|
|
3
|
+
export {
|
|
4
|
+
DEFAULT_TEMPORAL_VERSION,
|
|
5
|
+
TEMPORAL_CHECKSUMS_FILE,
|
|
6
|
+
downloadTemporalCli,
|
|
7
|
+
extractTarEntry,
|
|
8
|
+
isTemporalInstalled,
|
|
9
|
+
temporalArchiveName,
|
|
10
|
+
temporalReleaseAssetUrl,
|
|
11
|
+
} from "./download.js";
|
|
12
|
+
export { isLikelyTemporal, processCommandLine, type ProcessInspectOptions } from "./inspect.js";
|
|
13
|
+
export { TEMPORAL_BIN_ENV, TemporalManager, type TemporalManagerOptions } from "./manager.js";
|
|
6
14
|
export { type SupervisedTarget, type SupervisorOptions, TemporalSupervisor } from "./supervisor.js";
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
// Pins the process-identity reader: the kernel's /proc answer comes first, `ps`
|
|
2
|
+
// is the fallback, and neither's absence is mistaken for "not Temporal" when the
|
|
3
|
+
// other can answer. The /proc arm is what keeps a minimal container (no `ps`)
|
|
4
|
+
// from restarting a healthy Temporal every supervisor tick.
|
|
5
|
+
|
|
6
|
+
import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs";
|
|
7
|
+
import { tmpdir } from "node:os";
|
|
8
|
+
import { join } from "node:path";
|
|
9
|
+
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
10
|
+
import { isLikelyTemporal, processCommandLine } from "./inspect.js";
|
|
11
|
+
|
|
12
|
+
function procFixture(entries: Record<number, string[] | "">): string {
|
|
13
|
+
const root = mkdtempSync(join(tmpdir(), "stigmer-proc-"));
|
|
14
|
+
for (const [pid, argv] of Object.entries(entries)) {
|
|
15
|
+
mkdirSync(join(root, pid));
|
|
16
|
+
// /proc/<pid>/cmdline: argv joined by NUL, NUL-terminated; empty for a zombie.
|
|
17
|
+
writeFileSync(join(root, pid, "cmdline"), argv === "" ? "" : `${argv.join("\0")}\0`);
|
|
18
|
+
}
|
|
19
|
+
return root;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// Hide `ps` so the fallback cannot answer; tests that need it restore PATH.
|
|
23
|
+
let savedPath: string | undefined;
|
|
24
|
+
beforeEach(() => {
|
|
25
|
+
savedPath = process.env.PATH;
|
|
26
|
+
});
|
|
27
|
+
afterEach(() => {
|
|
28
|
+
if (savedPath === undefined) delete process.env.PATH;
|
|
29
|
+
else process.env.PATH = savedPath;
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
describe("processCommandLine", () => {
|
|
33
|
+
it("reads argv from <procRoot>/<pid>/cmdline, joining the NUL-separated arguments", () => {
|
|
34
|
+
const root = procFixture({ 42: ["/opt/stigmer/bin/temporal", "server", "start-dev", "--port", "7233"] });
|
|
35
|
+
process.env.PATH = "";
|
|
36
|
+
expect(processCommandLine(42, { procRoot: root })).toBe("/opt/stigmer/bin/temporal server start-dev --port 7233");
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("falls back to ps when the pid has no cmdline entry", () => {
|
|
40
|
+
const root = procFixture({});
|
|
41
|
+
// Our own process is the one PID whose command line `ps` can always report.
|
|
42
|
+
const line = processCommandLine(process.pid, { procRoot: root });
|
|
43
|
+
expect(line).not.toBeNull();
|
|
44
|
+
expect(line).toMatch(/node|vitest/);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("treats an empty cmdline (a zombie) as unreadable and falls back to ps", () => {
|
|
48
|
+
const root = procFixture({ [process.pid]: "" });
|
|
49
|
+
expect(processCommandLine(process.pid, { procRoot: root })).toMatch(/node|vitest/);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("returns null when neither /proc nor ps can answer", () => {
|
|
53
|
+
process.env.PATH = "";
|
|
54
|
+
expect(processCommandLine(process.pid, { procRoot: null })).toBeNull();
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
describe("isLikelyTemporal", () => {
|
|
59
|
+
it("recognises the managed dev server by its binary path", () => {
|
|
60
|
+
const root = procFixture({ 7: ["/home/u/.stigmer/bin/temporal", "server", "start-dev"] });
|
|
61
|
+
process.env.PATH = "";
|
|
62
|
+
expect(isLikelyTemporal(7, "/home/u/.stigmer/bin/temporal", { procRoot: root })).toBe(true);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("recognises a temporal server started from another path by its subcommand", () => {
|
|
66
|
+
const root = procFixture({ 8: ["/usr/local/bin/temporal", "server", "start-dev"] });
|
|
67
|
+
process.env.PATH = "";
|
|
68
|
+
expect(isLikelyTemporal(8, "/elsewhere/temporal", { procRoot: root })).toBe(true);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("rejects an unrelated process that reused the pid", () => {
|
|
72
|
+
const root = procFixture({ 9: ["/usr/bin/node", "/app/main.js"] });
|
|
73
|
+
process.env.PATH = "";
|
|
74
|
+
expect(isLikelyTemporal(9, "/home/u/.stigmer/bin/temporal", { procRoot: root })).toBe(false);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
// The defect the /proc arm fixes: with `ps` gone and no /proc reader, a live
|
|
78
|
+
// Temporal read as "not Temporal" and the supervisor restarted it forever.
|
|
79
|
+
it("is false, not an exception, when no reader can answer", () => {
|
|
80
|
+
process.env.PATH = "";
|
|
81
|
+
expect(isLikelyTemporal(process.pid, "/x/temporal", { procRoot: null })).toBe(false);
|
|
82
|
+
});
|
|
83
|
+
});
|
|
@@ -3,30 +3,78 @@
|
|
|
3
3
|
// A PID file can outlive the process it named; the OS may then assign that PID
|
|
4
4
|
// to something unrelated. Before trusting a PID as "our Temporal", we confirm
|
|
5
5
|
// the live process's command line actually looks like the Temporal dev server.
|
|
6
|
-
//
|
|
6
|
+
//
|
|
7
|
+
// Two readers, in order. On Linux the kernel publishes every process's command
|
|
8
|
+
// line at /proc/<pid>/cmdline (NUL-separated argv), which needs no external
|
|
9
|
+
// program — the reader that works inside a minimal container. Everywhere else
|
|
10
|
+
// (and on a Linux where /proc is unreadable) we shell out to `ps`, the
|
|
11
|
+
// POSIX-portable answer. Returns null on any uncertainty; callers treat null
|
|
12
|
+
// as "not Temporal", which is the safe direction for a PID-reuse guard.
|
|
13
|
+
//
|
|
14
|
+
// Why the /proc reader exists: `node:22-slim` and its kin ship no `ps`. With
|
|
15
|
+
// `ps` as the only reader, every liveness probe answered "not Temporal", the
|
|
16
|
+
// supervisor restarted a healthy dev server every tick, and each restart spawned
|
|
17
|
+
// a second process that failed to bind while the first kept the port. A
|
|
18
|
+
// dependency on a userland tool is the wrong shape for a liveness check that
|
|
19
|
+
// the kernel can answer directly.
|
|
7
20
|
|
|
8
21
|
import { execFileSync } from "node:child_process";
|
|
22
|
+
import { readFileSync } from "node:fs";
|
|
23
|
+
import { join } from "node:path";
|
|
9
24
|
|
|
10
|
-
/**
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
25
|
+
/** Where the kernel publishes process metadata on Linux; null where it does not. */
|
|
26
|
+
const DEFAULT_PROC_ROOT: string | null = process.platform === "linux" ? "/proc" : null;
|
|
27
|
+
|
|
28
|
+
export interface ProcessInspectOptions {
|
|
29
|
+
/**
|
|
30
|
+
* Root of a procfs tree to read `<pid>/cmdline` from. Defaults to `/proc` on
|
|
31
|
+
* Linux and to none elsewhere; tests point it at a fixture directory.
|
|
32
|
+
*/
|
|
33
|
+
procRoot?: string | null;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Full command line of a process, or null if it cannot be read. */
|
|
37
|
+
export function processCommandLine(pid: number, options: ProcessInspectOptions = {}): string | null {
|
|
38
|
+
const procRoot = options.procRoot === undefined ? DEFAULT_PROC_ROOT : options.procRoot;
|
|
39
|
+
if (procRoot !== null) {
|
|
40
|
+
const fromProc = readProcCmdline(procRoot, pid);
|
|
41
|
+
if (fromProc !== null) return fromProc;
|
|
19
42
|
}
|
|
43
|
+
return psCommandLine(pid);
|
|
20
44
|
}
|
|
21
45
|
|
|
22
46
|
/**
|
|
23
47
|
* Heuristically confirm that `pid` is the Temporal server we manage: its command
|
|
24
48
|
* line mentions "temporal" and either our binary path or a "server" subcommand.
|
|
25
49
|
*/
|
|
26
|
-
export function isLikelyTemporal(pid: number, binPath: string): boolean {
|
|
27
|
-
const cmd = processCommandLine(pid);
|
|
50
|
+
export function isLikelyTemporal(pid: number, binPath: string, options: ProcessInspectOptions = {}): boolean {
|
|
51
|
+
const cmd = processCommandLine(pid, options);
|
|
28
52
|
if (cmd === null) return false;
|
|
29
53
|
const lower = cmd.toLowerCase();
|
|
30
54
|
if (!lower.includes("temporal")) return false;
|
|
31
55
|
return cmd.includes(binPath) || lower.includes("server");
|
|
32
56
|
}
|
|
57
|
+
|
|
58
|
+
// /proc/<pid>/cmdline is argv joined by NUL bytes (with a trailing NUL). An
|
|
59
|
+
// empty file means a zombie or a kernel thread — nothing we can name, so null
|
|
60
|
+
// lets the caller fall through to `ps`.
|
|
61
|
+
function readProcCmdline(procRoot: string, pid: number): string | null {
|
|
62
|
+
try {
|
|
63
|
+
const raw = readFileSync(join(procRoot, String(pid), "cmdline"), "utf8");
|
|
64
|
+
const argv = raw.split("\0").filter((arg) => arg !== "");
|
|
65
|
+
return argv.length === 0 ? null : argv.join(" ");
|
|
66
|
+
} catch {
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function psCommandLine(pid: number): string | null {
|
|
72
|
+
try {
|
|
73
|
+
return execFileSync("ps", ["-p", String(pid), "-o", "command="], {
|
|
74
|
+
encoding: "utf8",
|
|
75
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
76
|
+
}).trim();
|
|
77
|
+
} catch {
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { mkdtempSync } from "node:fs";
|
|
1
|
+
import { existsSync, mkdirSync, mkdtempSync, writeFileSync } from "node:fs";
|
|
2
2
|
import { tmpdir } from "node:os";
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
import { describe, expect, it } from "vitest";
|
|
@@ -28,4 +28,37 @@ describe("TemporalManager", () => {
|
|
|
28
28
|
const manager = TemporalManager.forHome(tempHome());
|
|
29
29
|
expect(await manager.isRunning()).toBe(false);
|
|
30
30
|
});
|
|
31
|
+
|
|
32
|
+
// A stale PID file naming THIS process (the restarted container's fresh PID
|
|
33
|
+
// namespace reused it) can never be a running Temporal.
|
|
34
|
+
it("reports not-running when the PID file names the current process", async () => {
|
|
35
|
+
const home = tempHome();
|
|
36
|
+
writePidFile(temporalPidFile(home), process.pid);
|
|
37
|
+
expect(await TemporalManager.forHome(home).isRunning()).toBe(false);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
describe("STIGMER_TEMPORAL_BIN", () => {
|
|
41
|
+
it("relocates only the binary; data, log, PID and lock stay under the home", async () => {
|
|
42
|
+
const home = tempHome();
|
|
43
|
+
const binPath = join(tempHome(), "opt", "temporal");
|
|
44
|
+
mkdirSync(join(binPath, ".."), { recursive: true });
|
|
45
|
+
writeFileSync(binPath, "#!/bin/sh\n");
|
|
46
|
+
|
|
47
|
+
const manager = TemporalManager.forHome(home, { STIGMER_TEMPORAL_BIN: binPath });
|
|
48
|
+
expect(manager.binPath).toBe(binPath);
|
|
49
|
+
|
|
50
|
+
// Nothing to download: the pre-installed binary is what ensureInstalled checks for.
|
|
51
|
+
await manager.ensureInstalled();
|
|
52
|
+
expect(existsSync(join(home, ".stigmer", "bin", "temporal"))).toBe(false);
|
|
53
|
+
// State still lives in the home layout.
|
|
54
|
+
writePidFile(temporalPidFile(home), 5151);
|
|
55
|
+
expect(manager.getPid()).toBe(5151);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("is ignored when empty, keeping the ~/.stigmer/bin default", () => {
|
|
59
|
+
const home = tempHome();
|
|
60
|
+
const manager = TemporalManager.forHome(home, { STIGMER_TEMPORAL_BIN: "" });
|
|
61
|
+
expect(manager.binPath).toBe(join(home, ".stigmer", "bin", "temporal"));
|
|
62
|
+
});
|
|
63
|
+
});
|
|
31
64
|
});
|
|
@@ -19,7 +19,7 @@ import { binDir, logDir, temporalDataDir, temporalLockFile, temporalPidFile } fr
|
|
|
19
19
|
import { TEMPORAL_PORT, TEMPORAL_UI_PORT } from "../constants.js";
|
|
20
20
|
import { type FileLock, acquireLock } from "../state/lock.js";
|
|
21
21
|
import { readPidFile, removePidFile, writePidFile } from "../state/pidfile.js";
|
|
22
|
-
import { findProcessByPort,
|
|
22
|
+
import { findProcessByPort, isOtherLiveProcess, killProcessGroup } from "../state/proc.js";
|
|
23
23
|
import { DEFAULT_TEMPORAL_VERSION, downloadTemporalCli, isTemporalInstalled } from "./download.js";
|
|
24
24
|
import { isLikelyTemporal } from "./inspect.js";
|
|
25
25
|
|
|
@@ -27,8 +27,16 @@ const READY_TIMEOUT_MS = 10_000;
|
|
|
27
27
|
const STOP_POLL_MS = 500;
|
|
28
28
|
const STOP_TIMEOUT_MS = 10_000;
|
|
29
29
|
|
|
30
|
+
/**
|
|
31
|
+
* Env var naming a pre-installed `temporal` binary to use instead of the one
|
|
32
|
+
* the CLI downloads into ~/.stigmer/bin — the STIGMER_NODE_BIN precedent. The
|
|
33
|
+
* all-in-one image bakes the binary read-only off its data volume and points
|
|
34
|
+
* here; `ensureInstalled` then has nothing to download.
|
|
35
|
+
*/
|
|
36
|
+
export const TEMPORAL_BIN_ENV = "STIGMER_TEMPORAL_BIN";
|
|
37
|
+
|
|
30
38
|
export interface TemporalManagerOptions {
|
|
31
|
-
/** Path to the temporal binary (~/.stigmer/bin/temporal). */
|
|
39
|
+
/** Path to the temporal binary (~/.stigmer/bin/temporal, or STIGMER_TEMPORAL_BIN). */
|
|
32
40
|
binPath: string;
|
|
33
41
|
/** Temporal dev-server data directory (~/.stigmer/temporal-data). */
|
|
34
42
|
dataDir: string;
|
|
@@ -44,7 +52,8 @@ export interface TemporalManagerOptions {
|
|
|
44
52
|
}
|
|
45
53
|
|
|
46
54
|
export class TemporalManager {
|
|
47
|
-
|
|
55
|
+
/** The `temporal` binary this manager runs (downloaded, or pre-installed via STIGMER_TEMPORAL_BIN). */
|
|
56
|
+
readonly binPath: string;
|
|
48
57
|
private readonly dataDir: string;
|
|
49
58
|
private readonly logFile: string;
|
|
50
59
|
private readonly pidFile: string;
|
|
@@ -65,10 +74,15 @@ export class TemporalManager {
|
|
|
65
74
|
this.uiPort = options.uiPort ?? TEMPORAL_UI_PORT;
|
|
66
75
|
}
|
|
67
76
|
|
|
68
|
-
/**
|
|
69
|
-
|
|
77
|
+
/**
|
|
78
|
+
* Build a manager from the standard ~/.stigmer layout. The binary alone may
|
|
79
|
+
* be relocated through STIGMER_TEMPORAL_BIN; the dev server's data, log,
|
|
80
|
+
* PID and lock stay under the home, because they are state, not software.
|
|
81
|
+
*/
|
|
82
|
+
static forHome(home: string = homedir(), env: NodeJS.ProcessEnv = process.env): TemporalManager {
|
|
83
|
+
const binOverride = env[TEMPORAL_BIN_ENV];
|
|
70
84
|
return new TemporalManager({
|
|
71
|
-
binPath: join(binDir(home), "temporal"),
|
|
85
|
+
binPath: binOverride !== undefined && binOverride !== "" ? binOverride : join(binDir(home), "temporal"),
|
|
72
86
|
dataDir: temporalDataDir(home),
|
|
73
87
|
logFile: join(logDir(home), "temporal.log"),
|
|
74
88
|
pidFile: temporalPidFile(home),
|
|
@@ -183,7 +197,7 @@ export class TemporalManager {
|
|
|
183
197
|
async isRunning(): Promise<boolean> {
|
|
184
198
|
const pid = this.getPid();
|
|
185
199
|
if (pid === null) return false;
|
|
186
|
-
if (!
|
|
200
|
+
if (!isOtherLiveProcess(pid)) return false;
|
|
187
201
|
if (!isLikelyTemporal(pid, this.binPath)) return false;
|
|
188
202
|
return this.isPortInUse();
|
|
189
203
|
}
|
|
@@ -229,7 +243,7 @@ export class TemporalManager {
|
|
|
229
243
|
private cleanupStaleProcesses(): void {
|
|
230
244
|
const pid = this.getPid();
|
|
231
245
|
if (pid !== null) {
|
|
232
|
-
if (!
|
|
246
|
+
if (!isOtherLiveProcess(pid) || !isLikelyTemporal(pid, this.binPath)) {
|
|
233
247
|
removePidFile(this.pidFile);
|
|
234
248
|
}
|
|
235
249
|
return;
|