@procedures/server 0.41.0 → 0.42.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.
package/src/host.ts CHANGED
@@ -21,9 +21,10 @@
21
21
 
22
22
  import { daemon, localDev, service, type Lifecycle, type Profile, type RunOptions, type ServiceBuilder } from "@mark1russell7/service";
23
23
  import { probeServer, type ProbeKind } from "@mark1russell7/service/http";
24
- import { seconds, type Duration } from "@procedures/client";
24
+ import { hostPolicy, realClock, seconds, type Duration, type HostPolicy, type Interceptor } from "@procedures/client";
25
25
  import { LOCAL_LISTENER_KNOBS, createProcedureHost, type ProcedureHost, type ProcedureHostOptions } from "@procedures/peer";
26
26
  import { lockfileHandle, type LockfileData } from "@procedures/peer/lockfile";
27
+ import { idleInterceptor, idleTimer, idleTimes, type HostIdle } from "./idle.js";
27
28
 
28
29
  /** The mode of a run: in the foreground of a terminal, or as the background host of `mark`. */
29
30
  export type HostRunMode = "foreground" | "background";
@@ -90,6 +91,16 @@ export interface ProcedureHostRunConfig {
90
91
  * The return value has no effect.
91
92
  */
92
93
  readonly declare?: ((svc: ServiceBuilder, host: ProcedureHost) => void) | undefined;
94
+ /**
95
+ * The idle stop (CLI-27): the host stops by itself after this time with no call. Without it, the
96
+ * host stops only at a signal or a stop request, as before.
97
+ *
98
+ * - Each call resets the timer at its start and at its end. An open stream or subscription is a
99
+ * call in flight, and the host does not stop while it is open.
100
+ * - The stop is `life.shutdown()` with the exit code 0. Thus the stop in stages of `run()` applies,
101
+ * and the lockfile goes at the end.
102
+ */
103
+ readonly idle?: HostIdle | undefined;
93
104
  }
94
105
 
95
106
  /** A host that runs under `run()`: the host, the service of the process and its lifecycle. */
@@ -114,8 +125,25 @@ export async function runProcedureHost(config: ProcedureHostRunConfig): Promise<
114
125
  const clock = config.run?.clock;
115
126
  // A probe server of its own: the listeners of the host then answer no probes, unless the host options say so (D5)
116
127
  const dedicated = config.probes;
128
+ // The idle stop (CLI-27): an interceptor of the policy tells the timer about each call. The timer
129
+ // starts after the startup, and it asks the lifecycle for the stop.
130
+ let life: Lifecycle | undefined;
131
+ const idle = config.idle
132
+ ? idleTimer({
133
+ ...idleTimes(config.idle),
134
+ clock: clock ?? realClock,
135
+ inFlight: () => life?.inFlight() ?? 0,
136
+ stop: (cause) => {
137
+ if (life?.state !== "running") return;
138
+ config.log?.(`\nIdle: ${cause}.`);
139
+ void life.shutdown(cause, { code: 0 });
140
+ },
141
+ })
142
+ : undefined;
143
+ const policy = idle ? withInterceptor(config.host.policy ?? hostPolicy(), idleInterceptor(idle)) : config.host.policy;
117
144
  const host = createProcedureHost({
118
145
  ...config.host,
146
+ ...(policy ? { policy } : {}),
119
147
  ...(clock ? { clock } : {}),
120
148
  ...(dedicated && config.host.probes === undefined ? { probes: false } : {}),
121
149
  });
@@ -145,7 +173,12 @@ export async function runProcedureHost(config: ProcedureHostRunConfig): Promise<
145
173
  if (config.lockfile) svc.dependency("lockfile", { system: null, knobs: {}, operations: {} }, lockfileHandle(config.lockfile));
146
174
  const log = config.log;
147
175
  if (log && profile.phases?.some((phase) => phase.name === "stop intake")) svc.onPhase("stop intake", () => log("\nShutting down..."));
148
- const life = await svc.run(config.run);
176
+ life = await svc.run(config.run);
177
+ if (idle) {
178
+ // The timer stops with the lifecycle, also after a stop for another cause
179
+ void life.done.then(() => idle.cancel());
180
+ if (life.state === "running") idle.start();
181
+ }
149
182
  const probeBase = (): string[] => {
150
183
  const address = probes?.server.address();
151
184
  if (typeof address !== "object" || address === null) return [];
@@ -155,3 +188,8 @@ export async function runProcedureHost(config: ProcedureHostRunConfig): Promise<
155
188
  };
156
189
  return { host, service: svc, life, probes: () => (probes ? probeBase() : host.info.probes()) };
157
190
  }
191
+
192
+ /** This function gives a policy with one more interceptor, after the interceptors of the policy. */
193
+ function withInterceptor(policy: HostPolicy, interceptor: Interceptor): HostPolicy {
194
+ return { ...policy, interceptors: [...policy.interceptors, interceptor] };
195
+ }
package/src/idle.ts ADDED
@@ -0,0 +1,119 @@
1
+ /**
2
+ * The idle stop of a procedures host (CLI-27).
3
+ *
4
+ * A host with the option `idle` of `runProcedureHost()` stops by itself after a time with no call.
5
+ * The background host that `mark` starts after a cold command uses it (`MARK_AUTO_WARM`).
6
+ *
7
+ * - `run()` of `@mark1russell7/service` has no idle trigger. The host uses the stop request of the
8
+ * lifecycle, `life.shutdown()`, as an exit signal does. Thus the runner keeps the stop in stages
9
+ * and the exit, and this file has no signal handler, no listener and no `process.exit`.
10
+ * - An interceptor of the host policy resets the timer at the start and at the end of each call.
11
+ * - When the timer fires and a call is in flight (`life.inFlight()`), the host does not stop. An
12
+ * open stream or subscription is a call in flight until its last item (decision D4). The timer
13
+ * then reads the count again at each poll, and the full time starts again after the last call ends.
14
+ * - The timer uses the clock of the run, so a test gives a fake clock.
15
+ */
16
+
17
+ import { toMs, type Clock, type Duration, type Interceptor } from "@procedures/client";
18
+
19
+ /** The default time between two reads of the calls in flight while a call is open: 5 s. */
20
+ export const IDLE_POLL_MS = 5_000;
21
+
22
+ /** The option `idle` of `runProcedureHost()`. */
23
+ export interface HostIdle {
24
+ /** The time with no call, after which the host stops. */
25
+ readonly after: Duration;
26
+ /** The time between two reads of the calls in flight while a call is open. The default is 5 s, or `after` when it is shorter. */
27
+ readonly poll?: Duration | undefined;
28
+ }
29
+
30
+ /** The options of `idleTimer()`. */
31
+ export interface IdleTimerOptions {
32
+ /** The time with no call, in milliseconds. */
33
+ readonly afterMs: number;
34
+ /** The time between two reads of the calls in flight while a call is open, in milliseconds. */
35
+ readonly pollMs: number;
36
+ readonly clock: Clock;
37
+ /** This function gives the count of the calls in flight. */
38
+ readonly inFlight: () => number;
39
+ /** This function stops the host. The timer calls it one time, with the cause of the stop. */
40
+ readonly stop: (cause: string) => void;
41
+ }
42
+
43
+ /** The timer of the idle stop. */
44
+ export interface IdleTimer {
45
+ /** This method starts the timer. Before the start, `touch()` has no effect. */
46
+ start(): void;
47
+ /** This method tells the timer about a call: the full time starts again. */
48
+ touch(): void;
49
+ /** This method stops the timer for good. */
50
+ cancel(): void;
51
+ }
52
+
53
+ /** This function gives the timer of an idle stop. */
54
+ export function idleTimer(options: IdleTimerOptions): IdleTimer {
55
+ const { afterMs, pollMs, clock } = options;
56
+ let started = false;
57
+ let ended = false;
58
+ // True when the last fire found a call in flight: the full time starts after that call ends
59
+ let open = false;
60
+ let cancelTimer: (() => void) | undefined;
61
+ const arm = (delay: number): void => {
62
+ cancelTimer?.();
63
+ cancelTimer = clock.after(delay, fire);
64
+ };
65
+ const fire = (): void => {
66
+ cancelTimer = undefined;
67
+ if (ended) return;
68
+ if (options.inFlight() > 0) {
69
+ open = true;
70
+ arm(pollMs);
71
+ return;
72
+ }
73
+ if (open) {
74
+ open = false;
75
+ arm(afterMs);
76
+ return;
77
+ }
78
+ ended = true;
79
+ options.stop(`the host had no call for ${afterMs} ms`);
80
+ };
81
+ return {
82
+ start() {
83
+ if (started || ended) return;
84
+ started = true;
85
+ arm(afterMs);
86
+ },
87
+ touch() {
88
+ if (!started || ended) return;
89
+ open = false;
90
+ arm(afterMs);
91
+ },
92
+ cancel() {
93
+ ended = true;
94
+ cancelTimer?.();
95
+ cancelTimer = undefined;
96
+ },
97
+ };
98
+ }
99
+
100
+ /** The timer of the option `idle` of a host, in milliseconds. */
101
+ export function idleTimes(idle: HostIdle): { afterMs: number; pollMs: number } {
102
+ const afterMs = toMs(idle.after, "idle.after");
103
+ if (!Number.isFinite(afterMs) || afterMs <= 0) throw new RangeError(`idle.after must be more than 0 ms, got ${afterMs} ms`);
104
+ const pollMs = idle.poll === undefined ? Math.min(IDLE_POLL_MS, afterMs) : toMs(idle.poll, "idle.poll");
105
+ if (!Number.isFinite(pollMs) || pollMs <= 0) throw new RangeError(`idle.poll must be more than 0 ms, got ${pollMs} ms`);
106
+ return { afterMs, pollMs };
107
+ }
108
+
109
+ /** This function gives the interceptor that tells a timer about each call: at its start and at its end. */
110
+ export function idleInterceptor(timer: Pick<IdleTimer, "touch">): Interceptor {
111
+ return async (_invocation, next) => {
112
+ timer.touch();
113
+ try {
114
+ return await next();
115
+ } finally {
116
+ timer.touch();
117
+ }
118
+ };
119
+ }
package/src/index.ts CHANGED
@@ -10,6 +10,8 @@ export { parseConfig, parseTopicPatterns } from "./config.js";
10
10
  export { runServer, startServer, loadProcedureModule, type RunningServer, type ServerRunOptions } from "./run.js";
11
11
  // The runner entry of the procedures host (CLI-8): the only entry that starts run()
12
12
  export { runProcedureHost, HOST_PROFILES, type ProcedureHostRunConfig, type RunningHost, type HostRunMode } from "./host.js";
13
+ // The idle stop of a host (CLI-27): the option `idle` of runProcedureHost()
14
+ export { IDLE_POLL_MS, type HostIdle } from "./idle.js";
13
15
  export type {
14
16
  ServerConfig,
15
17
  TransportConfig,
@@ -25,7 +27,7 @@ export type {
25
27
  // server.start, server.stop and server.status (from @procedures/peer). The root registers
26
28
  // nothing: import "@procedures/server/register", or give the module to createRegistry().
27
29
  export * from "./module.js";
28
- export { serverStartProcedure } from "./procedures/server.start.js";
30
+ export { serverStartProcedure, spawnMarkServer, type MarkServerSpawn, type ServerSpawner, type SpawnedMarkServer } from "./procedures/server.start.js";
29
31
  export { serverStopProcedure } from "./procedures/server.stop.js";
30
32
  export { serverStatusProcedure } from "./procedures/server.status.js";
31
33
  export {
@@ -9,7 +9,7 @@
9
9
  * peer package, because the peer of `mark --server` writes them.
10
10
  */
11
11
 
12
- import { spawn, type ChildProcess } from "node:child_process";
12
+ import { spawn, type ChildProcess, type SpawnOptions } from "node:child_process";
13
13
  import * as fs from "node:fs";
14
14
  import * as path from "node:path";
15
15
  import * as os from "node:os";
@@ -185,6 +185,116 @@ export function findCliPath(): string {
185
185
  return "mark";
186
186
  }
187
187
 
188
+ /** The function that starts a process. The default is `spawn` of `node:child_process`. The tests give their own. */
189
+ export type ServerSpawner = (command: string, args: readonly string[], options: SpawnOptions) => ChildProcess;
190
+
191
+ const defaultSpawner: ServerSpawner = (command, args, options) => spawn(command, [...args], options);
192
+
193
+ /** The options of `spawnMarkServer()`. */
194
+ export interface MarkServerSpawn {
195
+ readonly port: number;
196
+ /** The address of the listener. */
197
+ readonly host: string;
198
+ /** The transport. The default is `http`. */
199
+ readonly transport?: "http" | "websocket" | "both" | undefined;
200
+ /** The idle stop of the server, in milliseconds (`--idle-timeout`, CLI-27). Without it, the server has no idle stop. */
201
+ readonly idleTimeoutMs?: number | undefined;
202
+ /** The root of the `fs.*` procedures of the server (`--fs-root`). Without it, the server has no root. */
203
+ readonly fsRoot?: string | undefined;
204
+ /** The function that starts the process. The default is `spawn`. */
205
+ readonly spawner?: ServerSpawner | undefined;
206
+ }
207
+
208
+ /** The process of a server that `spawnMarkServer()` started, and the command line of the process. */
209
+ export interface SpawnedMarkServer {
210
+ readonly child: ChildProcess;
211
+ readonly command: string;
212
+ readonly args: readonly string[];
213
+ /** The log file of the server: its standard output and its standard error. */
214
+ readonly logFile: string;
215
+ }
216
+
217
+ /**
218
+ * This function starts `mark --server` as a detached background process in the current folder,
219
+ * and gives the process at once. It does not wait for the readiness. `server.start` and the
220
+ * auto-warm of `mark` (CLI-27) use it.
221
+ *
222
+ * - The command line is the command that a user runs: `mark --server --port <port> --host <host>`.
223
+ * - The output goes to `~/.mark/server.log`. The old log moves to `server.log.1`.
224
+ * - The process is detached and unref'd, so the caller can exit at once.
225
+ * - An error of the spawn goes to the event `error` of the process, and it does not throw.
226
+ *
227
+ * This function does not use `runCommand()` or `shell.run`. They wait for the end of the process,
228
+ * and this process must outlive its caller. `SERVER-LAYERS.md` records the exception.
229
+ */
230
+ export function spawnMarkServer(options: MarkServerSpawn): SpawnedMarkServer {
231
+ const transport = options.transport ?? "http";
232
+
233
+ // Rotate log file
234
+ rotateLog();
235
+
236
+ // Ensure directories exist
237
+ fs.mkdirSync(SERVERS_DIR, { recursive: true });
238
+
239
+ // Open log file for writing
240
+ const logFd = fs.openSync(LOG_PATH, "a");
241
+
242
+ // Find CLI path
243
+ const cliPath = findCliPath();
244
+
245
+ // Build arguments: the root is a global flag, before --server
246
+ const args = [
247
+ ...(options.fsRoot !== undefined ? ["--fs-root", options.fsRoot] : []),
248
+ "--server",
249
+ "--port",
250
+ String(options.port),
251
+ "--host",
252
+ options.host,
253
+ ];
254
+ if (transport !== "http") {
255
+ args.push("--transport", transport);
256
+ }
257
+ if (options.idleTimeoutMs !== undefined) {
258
+ args.push("--idle-timeout", String(options.idleTimeoutMs));
259
+ }
260
+
261
+ // Determine how to spawn
262
+ let spawnCmd: string;
263
+ let spawnArgs: string[];
264
+
265
+ if (cliPath === "mark" || cliPath.endsWith(".bin/mark") || cliPath.endsWith(".bin\\mark")) {
266
+ // Use mark command directly
267
+ spawnCmd = cliPath;
268
+ spawnArgs = args;
269
+ } else {
270
+ // Use node to run the JS file
271
+ spawnCmd = process.execPath;
272
+ spawnArgs = [cliPath, ...args];
273
+ }
274
+
275
+ // Spawn daemon process
276
+ let child: ChildProcess;
277
+ try {
278
+ child = (options.spawner ?? defaultSpawner)(spawnCmd, spawnArgs, {
279
+ detached: true,
280
+ stdio: ["ignore", logFd, logFd],
281
+ env: { ...process.env },
282
+ cwd: process.cwd(),
283
+ // No console window on Windows (deep dive CLI-16)
284
+ windowsHide: true,
285
+ });
286
+ } finally {
287
+ fs.closeSync(logFd);
288
+ }
289
+ // A spawn that fails, for example without `mark` in PATH, emits "error". Without a listener,
290
+ // the event ends the process of the caller.
291
+ child.once("error", () => undefined);
292
+
293
+ // Unref so parent can exit
294
+ child.unref();
295
+ return { child, command: spawnCmd, args: spawnArgs, logFile: LOG_PATH };
296
+ }
297
+
188
298
  /**
189
299
  * The rule under a root of the files (roadmap R33, tracker item O36): a root refuses it. It starts
190
300
  * a mark server without the root, and it writes the log and the lock files in `~/.mark`.
@@ -227,55 +337,12 @@ export const serverStartProcedure: Procedure<
227
337
  };
228
338
  }
229
339
 
230
- // Rotate log file
231
- rotateLog();
232
-
233
- // Ensure directories exist
234
- fs.mkdirSync(SERVERS_DIR, { recursive: true });
235
-
236
- // Open log file for writing
237
- const logFd = fs.openSync(LOG_PATH, "a");
238
-
239
- // Find CLI path
240
- const cliPath = findCliPath();
241
-
242
- // Build arguments
243
- const args = ["--server", "--port", String(port), "--host", host];
244
- if (transport !== "http") {
245
- args.push("--transport", transport);
246
- }
247
-
248
- // Determine how to spawn
249
- let spawnCmd: string;
250
- let spawnArgs: string[];
251
-
252
- if (cliPath === "mark" || cliPath.endsWith(".bin/mark") || cliPath.endsWith(".bin\\mark")) {
253
- // Use mark command directly
254
- spawnCmd = cliPath;
255
- spawnArgs = args;
256
- } else {
257
- // Use node to run the JS file
258
- spawnCmd = process.execPath;
259
- spawnArgs = [cliPath, ...args];
260
- }
261
-
262
- // Spawn daemon process
263
- const child = spawn(spawnCmd, spawnArgs, {
264
- detached: true,
265
- stdio: ["ignore", logFd, logFd],
266
- env: { ...process.env },
267
- cwd: process.cwd(),
268
- // No console window on Windows (deep dive CLI-16)
269
- windowsHide: true,
270
- });
340
+ // The detached process of the server, with its log (CLI-27 shares this start with the auto-warm of mark)
341
+ const { child } = spawnMarkServer({ port, host, transport });
271
342
  // The end of the process stops the wait. The listener of "error" also catches a spawn that
272
343
  // fails, for example without `mark` in PATH.
273
344
  const end = endOf(child);
274
345
 
275
- // Unref so parent can exit
276
- child.unref();
277
- fs.closeSync(logFd);
278
-
279
346
  // The wait for the readiness (CLI-9): the readiness probe, and host.info for the identity of the server
280
347
  const result = await waitForServer({ url: readinessUrl(host, port), port, before: existing?.peerId, end, timeoutMs });
281
348
  if ("ready" in result) {