@tsplc/build 0.0.0-stage → 0.1.0

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.
Files changed (82) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +6 -2
  3. package/dist/cli.js +1652 -0
  4. package/dist/cli.js.map +34 -0
  5. package/dist/index.js +1552 -0
  6. package/dist/index.js.map +31 -0
  7. package/dist/libraries.js +128 -0
  8. package/dist/libraries.js.map +10 -0
  9. package/docker/Dockerfile +173 -0
  10. package/docker/Dockerfile.dockerignore +17 -0
  11. package/docker/Dockerfile.source +15 -0
  12. package/docker/build-image +38 -0
  13. package/docker/codesys-build +5 -0
  14. package/docker/config.ts +8 -0
  15. package/docker/control-linux-sl/Dockerfile +38 -0
  16. package/docker/entrypoint.sh +10 -0
  17. package/docker/install/NOTICE.md +21 -0
  18. package/docker/install/core.sh +36 -0
  19. package/docker/install/decode.c +12 -0
  20. package/docker/install/device/Runtime.ico +0 -0
  21. package/docker/install/device/Runtime.png +0 -0
  22. package/docker/install/device/codesyscontrol.devdesc.xml +1502 -0
  23. package/docker/install/extract.py +43 -0
  24. package/docker/install/helper-launcher.c +46 -0
  25. package/docker/install/install.sh +94 -0
  26. package/docker/install/prerequisites.sh +15 -0
  27. package/docker/install/verify.py +133 -0
  28. package/docker/install/verify.sh +34 -0
  29. package/docker/install/wine-ncrypt/COPYING.LIB +502 -0
  30. package/docker/install/wine-ncrypt/README.md +28 -0
  31. package/docker/install/wine-ncrypt/enable-oaep.py +34 -0
  32. package/docker/install/wine-ncrypt/main.c +619 -0
  33. package/docker/install/wine-ncrypt/oaep.patch +11 -0
  34. package/docker/install/wine-signal/README.md +22 -0
  35. package/docker/install/wine-signal/build.sh +10 -0
  36. package/docker/install/wine-signal/check.sh +34 -0
  37. package/docker/install/wine-signal/noop-i386.c +2 -0
  38. package/docker/install/wine-signal/signal-state.c +105 -0
  39. package/docker/install/wine-signal/tests/avx-registers.c +53 -0
  40. package/docker/install/wine-signal/tests/frame-and-handler.c +192 -0
  41. package/docker/install/wine-signal/tests/loader-i386.c +8 -0
  42. package/docker/install/wine-signal/tests/signal-return.c +47 -0
  43. package/docker/install/wine-signal/wine +4 -0
  44. package/docker/runner.ts +131 -0
  45. package/package.json +53 -4
  46. package/python/build.py +655 -0
  47. package/python/codesys_online.py +300 -0
  48. package/python/test_build.py +444 -0
  49. package/python/test_codesys_online.py +170 -0
  50. package/python/test_installer_packages.py +216 -0
  51. package/python/test_installer_verification.py +307 -0
  52. package/python/test_worker.py +142 -0
  53. package/python/worker.py +89 -0
  54. package/runtime/install.sh +74 -0
  55. package/src/__tests__/fixtures/xml/fb-simple.xml +40 -0
  56. package/src/__tests__/fixtures/xml/fb-state-machine.xml +86 -0
  57. package/src/__tests__/fixtures/xml/prg-cyclic.xml +44 -0
  58. package/src/artifacts.ts +77 -0
  59. package/src/build.ts +87 -0
  60. package/src/cli.ts +116 -0
  61. package/src/config.ts +12 -0
  62. package/src/controller-online.ts +143 -0
  63. package/src/controller-session.ts +229 -0
  64. package/src/diagnostics.ts +66 -0
  65. package/src/docker-build.ts +148 -0
  66. package/src/docker-context.ts +35 -0
  67. package/src/docker-run.ts +104 -0
  68. package/src/host/codesys-command.ts +45 -0
  69. package/src/host/codesys-host.ts +145 -0
  70. package/src/host/codesys-worker.ts +345 -0
  71. package/src/host/windows-path.ts +73 -0
  72. package/src/index.ts +26 -0
  73. package/src/io.ts +127 -0
  74. package/src/libraries.ts +166 -0
  75. package/src/package-path.ts +5 -0
  76. package/src/run.ts +133 -0
  77. package/src/runtime-status.ts +21 -0
  78. package/src/sources.ts +248 -0
  79. package/src/st/declarations.ts +229 -0
  80. package/src/st/lexer.ts +125 -0
  81. package/src/st/parse.ts +202 -0
  82. package/src/st/reader.ts +50 -0
@@ -0,0 +1,104 @@
1
+ import { spawn } from "node:child_process";
2
+ import { createHash } from "node:crypto";
3
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
4
+ import { basename, posix, relative, resolve, sep } from "node:path";
5
+ import type { ResolvedConfig } from "./config.ts";
6
+
7
+ /** Run a Docker client command attached to this terminal and forward interruption to it. */
8
+ export const runDocker = (args: string[], failure: (status: string | number | null) => string) =>
9
+ new Promise<void>((accept, reject) => {
10
+ const child = spawn("docker", args, {
11
+ stdio: ["inherit", process.env["TSPLC_LOG_STDERR"] === "1" ? 2 : "inherit", "inherit"],
12
+ });
13
+ const interrupt = () => child.kill("SIGINT");
14
+ const terminate = () => child.kill("SIGTERM");
15
+ process.on("SIGINT", interrupt);
16
+ process.on("SIGTERM", terminate);
17
+ const cleanup = () => {
18
+ process.off("SIGINT", interrupt);
19
+ process.off("SIGTERM", terminate);
20
+ };
21
+ child.once("error", (cause) => {
22
+ cleanup();
23
+ reject(
24
+ new Error("Cannot start Docker. Install and start Docker Desktop or Docker Engine", {
25
+ cause,
26
+ }),
27
+ );
28
+ });
29
+ child.once("exit", (code, signal) => {
30
+ cleanup();
31
+ if (code === 0) {
32
+ accept();
33
+ } else {
34
+ reject(new Error(failure(signal ?? code)));
35
+ }
36
+ });
37
+ });
38
+
39
+ const bindMount = (source: string, target: string, readonly = false) =>
40
+ `type=bind,"source=${source.replaceAll('"', '""')}",target=${target}${readonly ? ",readonly" : ""}`;
41
+
42
+ async function mountLibraries(libraries: ResolvedConfig["libraries"], directory: string) {
43
+ if (!libraries?.length) return undefined;
44
+ await mkdir(resolve(directory, "libraries"), { recursive: true });
45
+ return Promise.all(
46
+ libraries.map(async (library) => {
47
+ const data = await readFile(library.path);
48
+ if (createHash("sha256").update(data).digest("hex") !== library.sha256)
49
+ throw new Error(`Library changed after installation: ${library.title}`);
50
+ const filename = `${library.sha256}-${basename(library.path)}`;
51
+ await writeFile(resolve(directory, "libraries", filename), data);
52
+ return { ...library, path: `/input/libraries/${filename}` };
53
+ }),
54
+ );
55
+ }
56
+
57
+ /**
58
+ * Write the builder container's request and return the `docker run` options that precede
59
+ * the image. The request names only container paths; host paths enter through these mounts,
60
+ * and host-only Docker, CODESYS and runtime settings stay outside.
61
+ */
62
+ export const prepareContainerRun = async (
63
+ config: ResolvedConfig,
64
+ requestDirectory: string,
65
+ options: { network: string; environment?: string[]; sources: boolean },
66
+ ) => {
67
+ await writeFile(
68
+ resolve(requestDirectory, "config.json"),
69
+ JSON.stringify({
70
+ ...config,
71
+ libraries: options.sources ? await mountLibraries(config.libraries, requestDirectory) : [],
72
+ configFile: undefined,
73
+ docker: undefined,
74
+ codesys: undefined,
75
+ sourceDir: "/sources",
76
+ entry: posix.resolve(
77
+ "/sources",
78
+ relative(config.sourceDir, config.entry).split(sep).join("/"),
79
+ ),
80
+ template: "/template.project",
81
+ outDir: "/output",
82
+ runtime: undefined,
83
+ }),
84
+ );
85
+ return [
86
+ "run",
87
+ "--rm",
88
+ "--init",
89
+ ...(config.docker?.cpuSet ? ["--cpuset-cpus", config.docker.cpuSet] : []),
90
+ "--network",
91
+ options.network,
92
+ ...(process.getuid && process.getgid
93
+ ? ["--user", `${process.getuid()}:${process.getgid()}`]
94
+ : []),
95
+ ...(options.environment ?? []).flatMap((name) => ["--env", name]),
96
+ "--mount",
97
+ bindMount(requestDirectory, "/input", true),
98
+ ...(options.sources ? ["--mount", bindMount(config.sourceDir, "/sources", true)] : []),
99
+ "--mount",
100
+ bindMount(config.template, "/template.project", true),
101
+ "--mount",
102
+ bindMount(config.outDir, "/output"),
103
+ ];
104
+ };
@@ -0,0 +1,45 @@
1
+ export { commandArgumentSchema } from "@tsplc/config/build";
2
+ import type { ResolvedConfig } from "../config";
3
+
4
+ export const codesysCommandFilename = "run.cmd";
5
+
6
+ // Keep arguments in the Windows command line: Wine's direct launcher loses the
7
+ // quoting CODESYS needs around profile names containing spaces.
8
+ export const codesysCommand = [
9
+ "@echo off",
10
+ "setlocal DisableDelayedExpansion",
11
+ '"%CODESYS_BUILD_EXE%" --profile="%CODESYS_BUILD_PROFILE%" --noUI --runscript="%CODESYS_BUILD_SCRIPT%"',
12
+ 'set "CODESYS_BUILD_STATUS=%errorlevel%"',
13
+ 'if not "%CODESYS_BUILD_STATUS%"=="0" echo CODESYS Windows exit status: %CODESYS_BUILD_STATUS%',
14
+ "exit /b %CODESYS_BUILD_STATUS%",
15
+ "",
16
+ ].join("\r\n");
17
+
18
+ export const getHostCommand = (
19
+ config: Pick<ResolvedConfig["codesys"], "commandProcessor" | "wine">,
20
+ platform: NodeJS.Platform = process.platform,
21
+ ) => {
22
+ if (platform === "win32") {
23
+ return {
24
+ args: ["/d", "/c", codesysCommandFilename],
25
+ executable: config.commandProcessor,
26
+ };
27
+ }
28
+ const wine = config.wine?.binary ?? "wine";
29
+ const args = ["cmd", "/d", "/c", codesysCommandFilename];
30
+ // --noUI suppresses the IDE, but CODESYS can still allocate a Wine console.
31
+ // A private PTY makes Wine use its Unix console instead of a desktop window.
32
+ return {
33
+ args:
34
+ platform === "darwin"
35
+ ? ["-q", "/dev/null", wine, ...args]
36
+ : [
37
+ "-q",
38
+ "-e",
39
+ "-c",
40
+ [wine, ...args].map((argument) => `'${argument.replaceAll("'", "'\\''")}'`).join(" "),
41
+ "/dev/null",
42
+ ],
43
+ executable: "script",
44
+ };
45
+ };
@@ -0,0 +1,145 @@
1
+ import { execFile, spawn } from "node:child_process";
2
+ import { appendFileSync, constants, renameSync, writeFileSync } from "node:fs";
3
+ import { access, open, readFile, rm, writeFile } from "node:fs/promises";
4
+ import { delimiter, resolve } from "node:path";
5
+ import { promisify } from "node:util";
6
+ import { z } from "zod";
7
+ import type { ResolvedConfig } from "../config";
8
+ import { codesysCommand, codesysCommandFilename, getHostCommand } from "./codesys-command";
9
+ import { toWindowsPath } from "./windows-path";
10
+
11
+ const execute = promisify(execFile);
12
+ const exitSchema = z.object({
13
+ pid: z.int().positive(),
14
+ code: z.int().nullable(),
15
+ signal: z.string().nullable(),
16
+ });
17
+
18
+ export const createCodesysHost = (config: ResolvedConfig) => {
19
+ const native = process.platform === "win32";
20
+ const { codesys, timeouts } = config;
21
+ const hostEnv = {
22
+ // Child processes inherit OS settings such as PATH and DISPLAY.
23
+ ...process.env,
24
+ WINEPREFIX: codesys.wine?.prefix,
25
+ WINEDEBUG: codesys.wine?.debug,
26
+ CODESYS_BUILD_NATIVE_EXIT: String(codesys.wine?.nativeExit ?? false),
27
+ };
28
+
29
+ /** Start the headless worker script; its launcher outlives this CLI process. */
30
+ const startWorker = async (directory: string) => {
31
+ if (!native) {
32
+ await execute(codesys.wine?.binary ?? "wine", ["--version"], {
33
+ env: hostEnv,
34
+ timeout: timeouts.hostCommand,
35
+ }).catch((cause: unknown) => {
36
+ throw new Error("Wine is missing. Install Wine; Winetricks alone cannot run CODESYS", {
37
+ cause,
38
+ });
39
+ });
40
+ const candidates = (process.env["PATH"] ?? "").split(delimiter);
41
+ const available = await Promise.all(
42
+ candidates.map((path) =>
43
+ access(resolve(path, "script"), constants.X_OK).then(
44
+ () => true,
45
+ () => false,
46
+ ),
47
+ ),
48
+ );
49
+ if (!available.some(Boolean)) {
50
+ throw new Error(
51
+ "Headless Wine builds require the script utility (install util-linux on Linux)",
52
+ );
53
+ }
54
+ }
55
+ await writeFile(resolve(directory, codesysCommandFilename), codesysCommand);
56
+ const env = {
57
+ ...hostEnv,
58
+ CODESYS_BUILD_SCRIPT: await toWindowsPath(resolve(directory, "worker.py"), codesys.wine),
59
+ CODESYS_BUILD_SESSION: await toWindowsPath(directory, codesys.wine),
60
+ CODESYS_BUILD_EXE: await toWindowsPath(codesys.executable, codesys.wine),
61
+ CODESYS_BUILD_PROFILE: codesys.profile,
62
+ };
63
+ const launcher = getHostCommand(codesys);
64
+ const logPath = resolve(directory, "codesys.log");
65
+ const exitPath = resolve(directory, "launcher-exit.json");
66
+ await rm(exitPath, { force: true });
67
+ const log = await open(logPath, "w");
68
+ try {
69
+ const child = spawn(launcher.executable, launcher.args, {
70
+ cwd: directory,
71
+ detached: true,
72
+ windowsHide: true,
73
+ env,
74
+ stdio: ["ignore", log.fd, log.fd],
75
+ });
76
+ child.once("exit", (code, signal) => {
77
+ try {
78
+ appendFileSync(logPath, `\nCODESYS launcher exit status: ${signal ?? code}\n`);
79
+ } catch {
80
+ // A failed diagnostic write must preserve the original process failure.
81
+ }
82
+ try {
83
+ const temporary = `${exitPath}.${child.pid}.tmp`;
84
+ writeFileSync(temporary, JSON.stringify({ pid: child.pid, code, signal }));
85
+ renameSync(temporary, exitPath);
86
+ } catch {
87
+ // The log still records the exit if the optional status file cannot be published.
88
+ }
89
+ });
90
+ await new Promise<void>((resolve, reject) => {
91
+ child.once("error", reject);
92
+ child.once("spawn", resolve);
93
+ });
94
+ if (!child.pid) {
95
+ throw new Error("CODESYS launcher has no process ID");
96
+ }
97
+ child.unref();
98
+ return child.pid;
99
+ } finally {
100
+ await log.close();
101
+ }
102
+ };
103
+
104
+ return {
105
+ exitStatus: async (directory: string, pid: number) => {
106
+ const path = resolve(directory, "launcher-exit.json");
107
+ const value = await readFile(path, "utf8").catch((error: unknown) => {
108
+ if (error instanceof Error && "code" in error && error.code === "ENOENT") return;
109
+ throw error;
110
+ });
111
+ if (value === undefined) return;
112
+ const status = exitSchema.parse(JSON.parse(value));
113
+ if (status.pid !== pid) return;
114
+ if (!native && status.code === 0 && status.signal === null) {
115
+ const logPath = resolve(directory, "codesys.log");
116
+ const log = (await readFile(logPath, "utf8"))
117
+ // Terminal color escapes must not hide a native assertion signature.
118
+ // eslint-disable-next-line no-control-regex
119
+ .replace(/\u001b\[[0-?]*[ -/]*[@-~]/g, "")
120
+ .replace(/\r/g, "");
121
+ // A Wine helper can abort under Rosetta while the main launcher returns 0.
122
+ // That command still failed; publishing its artifacts would hide the fault.
123
+ const assertion =
124
+ /^assertion failed \[[^\n]+\]:[^\n]*\n[ \t]*\(ThreadContext[^\n]*\)/m.exec(log);
125
+ if (assertion) {
126
+ throw new Error(
127
+ `Wine translation aborted: ${assertion[0].replace(/\s+/g, " ")}. See ${logPath}`,
128
+ );
129
+ }
130
+ }
131
+ return status;
132
+ },
133
+ startWorker,
134
+ terminate: async (pid: number) => {
135
+ if (native) {
136
+ await execute("taskkill.exe", ["/pid", String(pid), "/t", "/f"], {
137
+ env: hostEnv,
138
+ timeout: timeouts.hostCommand,
139
+ });
140
+ return;
141
+ }
142
+ process.kill(-pid, "SIGKILL");
143
+ },
144
+ };
145
+ };
@@ -0,0 +1,345 @@
1
+ // Engineering CLI lifecycle and diagnostics.
2
+ import { createHash, randomUUID } from "node:crypto";
3
+ import {
4
+ access,
5
+ copyFile,
6
+ mkdir,
7
+ open,
8
+ readFile,
9
+ rename,
10
+ rm,
11
+ unlink,
12
+ writeFile,
13
+ } from "node:fs/promises";
14
+ import { resolve } from "node:path";
15
+ import { setTimeout as delay } from "node:timers/promises";
16
+ import { z } from "zod";
17
+ import type { ResolvedConfig } from "../config";
18
+ import { CodesysCompilerError, compilerDiagnosticSchema } from "../diagnostics";
19
+ import { packageDirectory } from "../package-path";
20
+ import { codesysCommand, getHostCommand } from "./codesys-command";
21
+ import { createCodesysHost } from "./codesys-host";
22
+
23
+ const pythonDirectory = resolve(packageDirectory, "python");
24
+
25
+ const sessionSchema = z.object({
26
+ directory: z.string(),
27
+ fingerprint: z.string(),
28
+ pid: z.int().positive(),
29
+ });
30
+
31
+ type Session = z.infer<typeof sessionSchema>;
32
+
33
+ type WorkerCommand = { action: "build" | "online" | "ping" | "stop" };
34
+
35
+ const hasCode = (error: unknown, code: string) =>
36
+ error instanceof Error && "code" in error && error.code === code;
37
+
38
+ const isProcessAlive = (pid: number) => {
39
+ try {
40
+ // Signal 0 checks process existence and permissions without killing it.
41
+ // Only ESRCH means it is gone; permission errors must still propagate.
42
+ process.kill(pid, 0);
43
+ return true;
44
+ } catch (error) {
45
+ if (hasCode(error, "ESRCH")) {
46
+ return false;
47
+ }
48
+ throw error;
49
+ }
50
+ };
51
+
52
+ const readJson = async (path: string): Promise<unknown> => {
53
+ try {
54
+ return JSON.parse(await readFile(path, "utf8"));
55
+ } catch (error) {
56
+ if (hasCode(error, "ENOENT")) {
57
+ return undefined;
58
+ }
59
+ throw error;
60
+ }
61
+ };
62
+
63
+ export const writeJson = async (path: string, value: unknown) => {
64
+ // Readers may poll while we write. A temporary file in the same directory
65
+ // lets rename publish the complete JSON atomically, without exposing partial data.
66
+ const temporary = `${path}.${randomUUID()}.tmp`;
67
+ await writeFile(temporary, JSON.stringify(value, null, 2));
68
+ await rename(temporary, path);
69
+ };
70
+
71
+ /** Serializes startup and builds across CLI processes, including separate terminals. */
72
+ export const withWorkerLock = async <T>(
73
+ config: Pick<ResolvedConfig, "outDir" | "timeouts">,
74
+ run: () => Promise<T>,
75
+ ) => {
76
+ await mkdir(resolve(config.outDir, ".codesys"), { recursive: true });
77
+ const path = resolve(config.outDir, ".codesys/command.lock");
78
+ const deadline = Date.now() + config.timeouts.build + config.timeouts.startup;
79
+ while (true) {
80
+ const lock = await open(path, "wx").catch((error: unknown) => {
81
+ if (hasCode(error, "EEXIST")) {
82
+ return;
83
+ }
84
+ throw error;
85
+ });
86
+
87
+ if (lock) {
88
+ try {
89
+ await lock.writeFile(String(process.pid));
90
+ return await run();
91
+ } finally {
92
+ await lock.close();
93
+ await unlink(path);
94
+ }
95
+ }
96
+
97
+ const owner = Number(
98
+ await readFile(path, "utf8").catch((error: unknown) => {
99
+ if (hasCode(error, "ENOENT")) {
100
+ return "";
101
+ }
102
+ throw error;
103
+ }),
104
+ );
105
+ if (owner > 0 && !isProcessAlive(owner)) {
106
+ // Do not race another client by deleting a lock that it may have replaced.
107
+ throw new Error(`An interrupted command left ${path}. Remove that lock file and retry.`);
108
+ }
109
+ if (Date.now() >= deadline) {
110
+ throw new Error(`Timed out waiting for another CODESYS command (${path})`);
111
+ }
112
+ await delay(200);
113
+ }
114
+ };
115
+
116
+ /** The caller holds withWorkerLock throughout a command, so the mailbox has one writer. */
117
+ export const createCodesysWorker = (config: ResolvedConfig) => {
118
+ const host = createCodesysHost(config);
119
+ const statePath = resolve(config.outDir, ".codesys/session.json");
120
+ const verifiedProcesses = new Set<number>();
121
+
122
+ const readSession = async (): Promise<Session | undefined> => {
123
+ const value = await readJson(statePath);
124
+ if (value === undefined) {
125
+ return undefined;
126
+ }
127
+ const session = sessionSchema.safeParse(value);
128
+ if (!session.success) {
129
+ throw new Error(`Invalid CODESYS session file: ${statePath}`, {
130
+ cause: session.error,
131
+ });
132
+ }
133
+ return session.data;
134
+ };
135
+
136
+ const terminate = async (session: Session) => {
137
+ if (isProcessAlive(session.pid)) {
138
+ await host.terminate(session.pid).catch((error: unknown) => {
139
+ if (isProcessAlive(session.pid)) {
140
+ throw error;
141
+ }
142
+ });
143
+ }
144
+ // Let the launcher reap its child before another error handler or CLI
145
+ // checks this PID. Sending a second group kill can otherwise race exit.
146
+ const deadline = Date.now() + config.timeouts.terminate;
147
+ while (isProcessAlive(session.pid)) {
148
+ if (Date.now() >= deadline) {
149
+ throw new Error("CODESYS launcher did not exit after termination");
150
+ }
151
+ await delay(50);
152
+ }
153
+ };
154
+
155
+ const request = async (session: Session, command: WorkerCommand, timeoutMs: number) => {
156
+ const id = randomUUID();
157
+ const reply = resolve(session.directory, `${id}.json`);
158
+ await writeJson(resolve(session.directory, "request.json"), {
159
+ ...command,
160
+ id,
161
+ });
162
+ const deadline = Date.now() + timeoutMs;
163
+ while (true) {
164
+ const result = await readJson(reply);
165
+ if (result !== undefined) {
166
+ await unlink(reply);
167
+ const response = z
168
+ .object({
169
+ error: z.string().optional(),
170
+ diagnostics: z.array(compilerDiagnosticSchema).optional(),
171
+ id: z.literal(id),
172
+ ok: z.boolean(),
173
+ })
174
+ .safeParse(result);
175
+ if (!response.success) {
176
+ throw new Error("CODESYS returned an invalid worker response", {
177
+ cause: response.error,
178
+ });
179
+ }
180
+ verifiedProcesses.add(session.pid);
181
+ if (!response.data.ok) {
182
+ const message = response.data.error ?? "CODESYS build failed";
183
+ if (response.data.diagnostics) {
184
+ throw new CodesysCompilerError(message, response.data.diagnostics);
185
+ }
186
+ throw new Error(message);
187
+ }
188
+ return;
189
+ }
190
+ if (!isProcessAlive(session.pid)) {
191
+ throw new Error(
192
+ `CODESYS exited before completing ${command.action}. See ${session.directory}/codesys.log`,
193
+ );
194
+ }
195
+ if (Date.now() >= deadline) {
196
+ // A saved PID can belong to another process after a reboot. Only kill
197
+ // a process we launched or whose worker answered in this invocation.
198
+ if (!verifiedProcesses.has(session.pid)) {
199
+ throw new Error(
200
+ `CODESYS ${command.action} timed out without a worker response. Close the dedicated IDE if it is still running, then retry. Log: ${session.directory}/codesys.log`,
201
+ );
202
+ }
203
+ await terminate(session);
204
+ throw new Error(
205
+ `CODESYS ${command.action} timed out after ${timeoutMs} ms; the worker was stopped. See ${session.directory}/codesys.log`,
206
+ );
207
+ }
208
+ await delay(200);
209
+ }
210
+ };
211
+
212
+ const launch = async () => {
213
+ await access(config.codesys.executable);
214
+ const fingerprint = createHash("sha256")
215
+ .update(
216
+ JSON.stringify({
217
+ builder: await readFile(resolve(pythonDirectory, "build.py"), "utf8"),
218
+ online: await readFile(resolve(pythonDirectory, "codesys_online.py"), "utf8"),
219
+ command: codesysCommand,
220
+ codesys: config.codesys,
221
+ launcher: getHostCommand(config.codesys),
222
+ worker: await readFile(resolve(pythonDirectory, "worker.py"), "utf8"),
223
+ }),
224
+ )
225
+ .digest("hex");
226
+ const previous = await readSession();
227
+ if (previous && isProcessAlive(previous.pid)) {
228
+ if (previous.fingerprint !== fingerprint) {
229
+ throw new Error(
230
+ "CODESYS settings or worker scripts changed. Run stop-worker, then launch-worker to reload them.",
231
+ );
232
+ }
233
+ await request(previous, { action: "ping" }, config.timeouts.startup);
234
+ console.log(
235
+ `Reusing CODESYS worker (${previous.pid}). Log: ${previous.directory}/codesys.log`,
236
+ );
237
+ return previous;
238
+ }
239
+
240
+ const directory = resolve(config.outDir, ".codesys/worker");
241
+ await rm(directory, { recursive: true, force: true });
242
+ await mkdir(directory, { recursive: true });
243
+ await copyFile(resolve(pythonDirectory, "build.py"), resolve(directory, "build.py"));
244
+ await copyFile(resolve(pythonDirectory, "worker.py"), resolve(directory, "worker.py"));
245
+ await copyFile(
246
+ resolve(pythonDirectory, "codesys_online.py"),
247
+ resolve(directory, "codesys_online.py"),
248
+ );
249
+ console.log(`Starting CODESYS. Log: ${directory}/codesys.log`);
250
+ const pid = await host.startWorker(directory);
251
+ const session = { directory, fingerprint, pid };
252
+ verifiedProcesses.add(pid);
253
+ try {
254
+ await writeJson(statePath, session);
255
+ await request(session, { action: "ping" }, config.timeouts.startup);
256
+ } catch (error) {
257
+ await terminate(session);
258
+ throw error;
259
+ }
260
+ console.log(`CODESYS worker ready (${session.pid})`);
261
+ return session;
262
+ };
263
+
264
+ const checkExit = async (session: Session) => {
265
+ const status = await host.exitStatus(session.directory, session.pid);
266
+ if (!status) {
267
+ throw new Error(
268
+ `CODESYS launcher stopped but its exit status is unavailable. See ${session.directory}/codesys.log`,
269
+ );
270
+ }
271
+ if (status.code !== 0 || status.signal !== null) {
272
+ throw new Error(
273
+ `CODESYS launcher exited with ${status.signal ?? status.code ?? "unknown status"}. See ${session.directory}/codesys.log`,
274
+ );
275
+ }
276
+ };
277
+
278
+ const stop = async () => {
279
+ const session = await readSession();
280
+ if (!session) {
281
+ console.log("CODESYS worker is already stopped");
282
+ return;
283
+ }
284
+ if (!isProcessAlive(session.pid)) {
285
+ await checkExit(session);
286
+ console.log("CODESYS worker is already stopped");
287
+ return;
288
+ }
289
+ await request(session, { action: "stop" }, config.timeouts.stop);
290
+ const deadline = Date.now() + config.timeouts.stop;
291
+ while (isProcessAlive(session.pid)) {
292
+ if (Date.now() >= deadline) {
293
+ const message = `CODESYS stop timed out after ${config.timeouts.stop} ms. See ${session.directory}/codesys.log`;
294
+ try {
295
+ await terminate(session);
296
+ } catch (cause) {
297
+ throw new Error(`${message}; the worker could not be terminated`, { cause });
298
+ }
299
+ throw new Error(`${message}; the worker required forced termination`);
300
+ }
301
+ await delay(200);
302
+ }
303
+ await checkExit(session);
304
+ console.log("CODESYS worker stopped");
305
+ };
306
+
307
+ return { launch, request, stop };
308
+ };
309
+
310
+ /** Container jobs own a temporary worker; always stop it before returning a result. */
311
+ export const withWorkerShutdown = async <T>(
312
+ config: ResolvedConfig,
313
+ worker: Pick<ReturnType<typeof createCodesysWorker>, "stop">,
314
+ operation: () => Promise<T>,
315
+ ) => {
316
+ const stop = async () => {
317
+ try {
318
+ await withWorkerLock(config, () => worker.stop());
319
+ } finally {
320
+ await rm(resolve(config.outDir, ".codesys/session.json"), { force: true });
321
+ }
322
+ };
323
+ return Promise.resolve()
324
+ .then(operation)
325
+ .then(
326
+ async (result) => {
327
+ await stop();
328
+ return result;
329
+ },
330
+ async (failure: unknown) => {
331
+ await stop().catch((shutdownFailure: unknown) => {
332
+ // Diagnostic mapping follows cause; keep the operation there and
333
+ // expose its shutdown failure alongside it in errors.
334
+ throw new AggregateError(
335
+ [failure, shutdownFailure],
336
+ "CODESYS operation and worker shutdown failed",
337
+ {
338
+ cause: failure,
339
+ },
340
+ );
341
+ });
342
+ throw failure;
343
+ },
344
+ );
345
+ };