@wringer/core 0.1.0 → 0.1.2

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 (258) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +71 -2
  3. package/dist/answer-supervisor-connection.d.ts +8 -0
  4. package/dist/app-machine.d.ts +76 -0
  5. package/dist/append-run-event.d.ts +16 -0
  6. package/dist/bind-drivers.d.ts +9 -0
  7. package/dist/build-commands.d.ts +5 -0
  8. package/dist/build-envelope-json-schema.d.ts +1 -0
  9. package/dist/build-envelope.d.ts +16 -0
  10. package/dist/build-launch-guard.d.ts +1 -0
  11. package/dist/build-manifest-write.d.ts +6 -0
  12. package/dist/build-promised-object-reads.d.ts +7 -0
  13. package/dist/build-report.d.ts +10 -0
  14. package/dist/build-run-handle.d.ts +2 -0
  15. package/dist/build-run-view.d.ts +29 -0
  16. package/dist/build-service-key.d.ts +1 -0
  17. package/dist/build-shut-down-steps.d.ts +14 -0
  18. package/dist/build-skip-worktree-marks.d.ts +7 -0
  19. package/dist/build-spawn-error.d.ts +2 -0
  20. package/dist/build-state-paths.d.ts +21 -0
  21. package/dist/build-submodule-adds.d.ts +7 -0
  22. package/dist/builtins/build-doctor-command.d.ts +2 -0
  23. package/dist/builtins/build-exec-command.d.ts +2 -0
  24. package/dist/builtins/build-lifecycle-commands.d.ts +3 -0
  25. package/dist/builtins/build-state-commands.d.ts +2 -0
  26. package/dist/builtins/build-use-pointers.d.ts +9 -0
  27. package/dist/builtins/build-verdict-commands.d.ts +2 -0
  28. package/dist/builtins/call-run-supervisor.d.ts +5 -0
  29. package/dist/builtins/pick-doctor-outcome.d.ts +13 -0
  30. package/dist/builtins/pick-start-action.d.ts +10 -0
  31. package/dist/builtins/pick-stop-plan.d.ts +14 -0
  32. package/dist/builtins/start-run.d.ts +22 -0
  33. package/dist/builtins/stop-data-schema.d.ts +6 -0
  34. package/dist/builtins/stop-run.d.ts +3 -0
  35. package/dist/call-supervisor.d.ts +10 -0
  36. package/dist/cap-data.d.ts +17 -0
  37. package/dist/collect-driver-configs.d.ts +7 -0
  38. package/dist/create-directory.d.ts +1 -0
  39. package/dist/create-evidence-writer.d.ts +13 -0
  40. package/dist/create-file-state.d.ts +36 -0
  41. package/dist/create-log-writer.d.ts +5 -0
  42. package/dist/create-node-repo-host.d.ts +2 -0
  43. package/dist/create-node-state-file-system.d.ts +10 -0
  44. package/dist/create-repo-state.d.ts +2 -0
  45. package/dist/create-socket-folder.d.ts +1 -0
  46. package/dist/create-wringer.d.ts +17 -0
  47. package/dist/decide-guard.d.ts +12 -0
  48. package/dist/define-driver.d.ts +13 -0
  49. package/dist/dispatch.d.ts +10 -0
  50. package/dist/envelope.d.ts +63 -0
  51. package/dist/error-codes.d.ts +25 -0
  52. package/dist/find-cancel-failure.d.ts +2 -0
  53. package/dist/find-colliding-service-key.d.ts +5 -0
  54. package/dist/find-own-value.d.ts +1 -0
  55. package/dist/find-remote-stop-failure.d.ts +2 -0
  56. package/dist/find-verdict-refusal.d.ts +3 -0
  57. package/dist/follow-process.d.ts +10 -0
  58. package/dist/get-lock-value.d.ts +2 -0
  59. package/dist/get-target.d.ts +2 -0
  60. package/dist/host.d.ts +19 -0
  61. package/dist/index.d.ts +60 -0
  62. package/dist/is-error-code.d.ts +1 -0
  63. package/dist/keep-cancelled-call.d.ts +1 -0
  64. package/dist/kill-process-group.d.ts +1 -0
  65. package/dist/launch-line-schema.d.ts +3 -0
  66. package/dist/listen-supervisor-socket.d.ts +8 -0
  67. package/dist/local.d.ts +2 -0
  68. package/dist/make-fence.d.ts +8 -0
  69. package/dist/make-fenced-exec.d.ts +10 -0
  70. package/dist/make-process-supervisor-port.d.ts +7 -0
  71. package/dist/open-drive.d.ts +23 -0
  72. package/dist/open-runless-drive.d.ts +17 -0
  73. package/dist/parse-launch-line.d.ts +2 -0
  74. package/dist/parse-patch-id.d.ts +2 -0
  75. package/dist/parse-report-block.d.ts +9 -0
  76. package/dist/parse-run-log.d.ts +2 -0
  77. package/dist/pick-access-refusal.d.ts +12 -0
  78. package/dist/pick-command-needs.d.ts +15 -0
  79. package/dist/pick-command-plan.d.ts +23 -0
  80. package/dist/pick-context-target.d.ts +7 -0
  81. package/dist/pick-drive-kind.d.ts +8 -0
  82. package/dist/pick-envelope-context.d.ts +1 -0
  83. package/dist/pick-envelope-payload.d.ts +12 -0
  84. package/dist/pick-envelope-run.d.ts +6 -0
  85. package/dist/pick-envelope-target.d.ts +4 -0
  86. package/dist/pick-late-failure-write.d.ts +13 -0
  87. package/dist/pick-log-refusal.d.ts +4 -0
  88. package/dist/pick-merge-base-failure.d.ts +2 -0
  89. package/dist/pick-merge-base-steps.d.ts +13 -0
  90. package/dist/pick-pending-cleanups.d.ts +10 -0
  91. package/dist/pick-submodule-refusal.d.ts +2 -0
  92. package/dist/pick-supervisor-operation.d.ts +16 -0
  93. package/dist/prepare-args.d.ts +3 -0
  94. package/dist/probe-process.d.ts +1 -0
  95. package/dist/quote-shell-word.d.ts +1 -0
  96. package/dist/race-deadline.d.ts +1 -0
  97. package/dist/read-changed-paths.d.ts +7 -0
  98. package/dist/read-default-base.d.ts +2 -0
  99. package/dist/read-fingerprint.d.ts +13 -0
  100. package/dist/read-head-sha.d.ts +2 -0
  101. package/dist/read-launch-host.d.ts +26 -0
  102. package/dist/read-pid-namespace.d.ts +1 -0
  103. package/dist/read-stream-line-batches.d.ts +2 -0
  104. package/dist/read-stream-lines.d.ts +2 -0
  105. package/dist/read-text.d.ts +1 -0
  106. package/dist/read-verdicts.d.ts +5 -0
  107. package/dist/remove-directory.d.ts +1 -0
  108. package/dist/remove-file.d.ts +1 -0
  109. package/dist/render-report-markdown.d.ts +4 -0
  110. package/dist/report-schema.d.ts +51 -0
  111. package/dist/run-events.d.ts +129 -0
  112. package/dist/run-git.d.ts +9 -0
  113. package/dist/run-process.d.ts +19 -0
  114. package/dist/run-supervisor-process.d.ts +2 -0
  115. package/dist/run-supervisor.d.ts +16 -0
  116. package/dist/settle-dispatch-answer.d.ts +9 -0
  117. package/dist/settle-envelope.d.ts +16 -0
  118. package/dist/ssh.d.ts +12 -0
  119. package/dist/start-launch.d.ts +20 -0
  120. package/dist/start-services.d.ts +16 -0
  121. package/dist/supervisor-machine.d.ts +78 -0
  122. package/dist/supervisor-protocol.d.ts +43 -0
  123. package/dist/to-wringer-error.d.ts +2 -0
  124. package/dist/types.d.ts +353 -0
  125. package/dist/wait-for-cancelled-calls.d.ts +2 -0
  126. package/dist/wringer-error.d.ts +12 -0
  127. package/dist/write-envelope-log.d.ts +11 -0
  128. package/dist/write-late-failure.d.ts +11 -0
  129. package/dist/write-new-file.d.ts +1 -0
  130. package/envelope.schema.json +175 -0
  131. package/package.json +21 -5
  132. package/src/answer-supervisor-connection.ts +117 -0
  133. package/src/app-machine.ts +174 -0
  134. package/src/append-run-event.ts +131 -0
  135. package/src/bind-drivers.ts +33 -0
  136. package/src/build-commands.ts +104 -0
  137. package/src/build-envelope-json-schema.ts +34 -0
  138. package/src/build-envelope.ts +40 -0
  139. package/src/build-launch-guard.ts +5 -0
  140. package/src/build-manifest-write.ts +26 -0
  141. package/src/build-promised-object-reads.ts +48 -0
  142. package/src/build-report.ts +55 -0
  143. package/src/build-run-handle.ts +13 -0
  144. package/src/build-run-view.ts +120 -0
  145. package/src/build-service-key.ts +4 -0
  146. package/src/build-shut-down-steps.ts +54 -0
  147. package/src/build-skip-worktree-marks.ts +36 -0
  148. package/src/build-spawn-error.ts +12 -0
  149. package/src/build-state-paths.ts +53 -0
  150. package/src/build-submodule-adds.ts +64 -0
  151. package/src/builtins/build-doctor-command.ts +163 -0
  152. package/src/builtins/build-exec-command.ts +41 -0
  153. package/src/builtins/build-lifecycle-commands.ts +115 -0
  154. package/src/builtins/build-state-commands.ts +152 -0
  155. package/src/builtins/build-use-pointers.ts +26 -0
  156. package/src/builtins/build-verdict-commands.ts +266 -0
  157. package/src/builtins/call-run-supervisor.ts +40 -0
  158. package/src/builtins/pick-doctor-outcome.ts +32 -0
  159. package/src/builtins/pick-start-action.ts +35 -0
  160. package/src/builtins/pick-stop-plan.ts +32 -0
  161. package/src/builtins/start-run.ts +281 -0
  162. package/src/builtins/stop-data-schema.ts +8 -0
  163. package/src/builtins/stop-run.ts +49 -0
  164. package/src/call-supervisor.ts +109 -0
  165. package/src/cap-data.ts +43 -0
  166. package/src/collect-driver-configs.ts +36 -0
  167. package/src/create-directory.ts +6 -0
  168. package/src/create-evidence-writer.ts +82 -0
  169. package/src/create-file-state.ts +193 -0
  170. package/src/create-log-writer.ts +38 -0
  171. package/src/create-node-repo-host.ts +20 -0
  172. package/src/create-node-state-file-system.ts +52 -0
  173. package/src/create-repo-state.ts +15 -0
  174. package/src/create-socket-folder.ts +24 -0
  175. package/src/create-wringer.ts +169 -0
  176. package/src/decide-guard.ts +47 -0
  177. package/src/define-driver.ts +165 -0
  178. package/src/dispatch.ts +589 -0
  179. package/src/envelope.ts +57 -0
  180. package/src/error-codes.ts +39 -0
  181. package/src/find-cancel-failure.ts +7 -0
  182. package/src/find-colliding-service-key.ts +16 -0
  183. package/src/find-own-value.ts +8 -0
  184. package/src/find-remote-stop-failure.ts +19 -0
  185. package/src/find-verdict-refusal.ts +26 -0
  186. package/src/follow-process.ts +147 -0
  187. package/src/get-lock-value.ts +17 -0
  188. package/src/get-target.ts +15 -0
  189. package/src/host.ts +26 -0
  190. package/src/index.ts +75 -0
  191. package/src/is-error-code.ts +3 -0
  192. package/src/keep-cancelled-call.ts +21 -0
  193. package/src/kill-process-group.ts +13 -0
  194. package/src/launch-line-schema.ts +6 -0
  195. package/src/listen-supervisor-socket.ts +75 -0
  196. package/src/local.ts +20 -0
  197. package/src/make-fence.ts +27 -0
  198. package/src/make-fenced-exec.ts +52 -0
  199. package/src/make-process-supervisor-port.ts +65 -0
  200. package/src/open-drive.ts +151 -0
  201. package/src/open-runless-drive.ts +71 -0
  202. package/src/parse-launch-line.ts +19 -0
  203. package/src/parse-patch-id.ts +8 -0
  204. package/src/parse-report-block.ts +39 -0
  205. package/src/parse-run-log.ts +34 -0
  206. package/src/pick-access-refusal.ts +32 -0
  207. package/src/pick-command-needs.ts +29 -0
  208. package/src/pick-command-plan.ts +51 -0
  209. package/src/pick-context-target.ts +22 -0
  210. package/src/pick-drive-kind.ts +23 -0
  211. package/src/pick-envelope-context.ts +10 -0
  212. package/src/pick-envelope-payload.ts +28 -0
  213. package/src/pick-envelope-run.ts +25 -0
  214. package/src/pick-envelope-target.ts +10 -0
  215. package/src/pick-late-failure-write.ts +44 -0
  216. package/src/pick-log-refusal.ts +18 -0
  217. package/src/pick-merge-base-failure.ts +15 -0
  218. package/src/pick-merge-base-steps.ts +37 -0
  219. package/src/pick-pending-cleanups.ts +23 -0
  220. package/src/pick-submodule-refusal.ts +46 -0
  221. package/src/pick-supervisor-operation.ts +52 -0
  222. package/src/prepare-args.ts +22 -0
  223. package/src/probe-process.ts +16 -0
  224. package/src/quote-shell-word.ts +9 -0
  225. package/src/race-deadline.ts +23 -0
  226. package/src/read-changed-paths.ts +39 -0
  227. package/src/read-default-base.ts +21 -0
  228. package/src/read-fingerprint.ts +271 -0
  229. package/src/read-head-sha.ts +23 -0
  230. package/src/read-launch-host.ts +64 -0
  231. package/src/read-pid-namespace.ts +18 -0
  232. package/src/read-stream-line-batches.ts +28 -0
  233. package/src/read-stream-lines.ts +11 -0
  234. package/src/read-text.ts +5 -0
  235. package/src/read-verdicts.ts +57 -0
  236. package/src/remove-directory.ts +6 -0
  237. package/src/remove-file.ts +6 -0
  238. package/src/render-report-markdown.ts +53 -0
  239. package/src/report-schema.ts +37 -0
  240. package/src/run-events.ts +71 -0
  241. package/src/run-git.ts +49 -0
  242. package/src/run-process.ts +168 -0
  243. package/src/run-supervisor-process.ts +51 -0
  244. package/src/run-supervisor.ts +327 -0
  245. package/src/settle-dispatch-answer.ts +26 -0
  246. package/src/settle-envelope.ts +34 -0
  247. package/src/ssh.ts +211 -0
  248. package/src/start-launch.ts +327 -0
  249. package/src/start-services.ts +108 -0
  250. package/src/supervisor-machine.ts +169 -0
  251. package/src/supervisor-protocol.ts +42 -0
  252. package/src/to-wringer-error.ts +20 -0
  253. package/src/types.ts +476 -0
  254. package/src/wait-for-cancelled-calls.ts +37 -0
  255. package/src/wringer-error.ts +25 -0
  256. package/src/write-envelope-log.ts +72 -0
  257. package/src/write-late-failure.ts +40 -0
  258. package/src/write-new-file.ts +17 -0
@@ -0,0 +1,327 @@
1
+ import { dirname } from 'node:path';
2
+ import { match } from 'ts-pattern';
3
+ import { createActor, waitFor } from 'xstate';
4
+ import { buildRunHandle } from './build-run-handle.ts';
5
+ import { buildShutDownSteps } from './build-shut-down-steps.ts';
6
+ import type { StatePort } from './create-file-state.ts';
7
+ import { getTarget } from './get-target.ts';
8
+ import { defaultRefusalHint } from './make-fence.ts';
9
+ import { pickPendingCleanups } from './pick-pending-cleanups.ts';
10
+ import { pickSupervisorOperation } from './pick-supervisor-operation.ts';
11
+ import { raceDeadline } from './race-deadline.ts';
12
+ import type { LaunchHost } from './read-launch-host.ts';
13
+ import type { Endpoints, RunEvent } from './run-events.ts';
14
+ import { appStopGraceMs, startLaunch } from './start-launch.ts';
15
+ import type { Launch } from './start-launch.ts';
16
+ import { startServices } from './start-services.ts';
17
+ import { supervisorMachine } from './supervisor-machine.ts';
18
+ import type { ShutDownPlan } from './supervisor-machine.ts';
19
+ import type { SupervisorRequest } from './supervisor-protocol.ts';
20
+ import { toWringerError } from './to-wringer-error.ts';
21
+ import type { ServiceInstance, TargetDefinition, Wringer } from './types.ts';
22
+ import type { WringerError } from './wringer-error.ts';
23
+
24
+ export interface SupervisorOptions {
25
+ readonly wringer: Wringer;
26
+ readonly launchHost: LaunchHost;
27
+ readonly state: StatePort;
28
+ readonly runID: string;
29
+ readonly target: string;
30
+ readonly readyTimeoutMs: number;
31
+
32
+ // The longest a stop waits for a startup step, or for one cleanup, that ignores the abort.
33
+ readonly stopWaitMs: number;
34
+ readonly now: () => Date;
35
+ readonly reportError: (message: string) => void;
36
+ readonly signal?: AbortSignal | undefined;
37
+ }
38
+
39
+ type Cleanup = readonly [string, () => Promise<void>];
40
+
41
+ // The supervisor owns a run's app and every driver service. It resolves once the run has stopped,
42
+ // by `stop` over the socket or by a crash of the app. Its lifecycle is the supervisor machine; the
43
+ // functions here are the I/O the machine runs.
44
+ export async function runSupervisor(options: SupervisorOptions): Promise<void> {
45
+ const target = getTarget(options.wringer, options.target);
46
+ const run = buildRunHandle(options.state.paths.root, options.runID);
47
+
48
+ const socket = await options.launchHost.createSocketFolder(run.paths.socket);
49
+
50
+ const services = new Map<string, ServiceInstance>();
51
+ const lifetime = new AbortController();
52
+ const serviceCommands = new AbortController();
53
+ const startupCommands = new AbortController();
54
+
55
+ const append = (event: RunEvent): Promise<void> =>
56
+ options.state.appendEvent(options.runID, event);
57
+
58
+ const at = (): string => options.now().toISOString();
59
+ let launch: Launch | undefined;
60
+
61
+ const stopping = new Set<string>();
62
+
63
+ // Each part a failed startup tried to stop and could not. The startup's error never reaches the
64
+ // stop that cancelled it, so the stop takes these from here.
65
+ const startupLeftovers: string[] = [];
66
+
67
+ // Settles once the app's stop has, past its cleanup wait too, so no service, such as a stream
68
+ // recorder, stops while the app can still log.
69
+ let appStopped: Promise<unknown> = Promise.resolve();
70
+
71
+ // Names each part whose stop failed. A part is marked before its stop starts, so two calls never
72
+ // stop the same part.
73
+ const stopStarted = async (): Promise<readonly string[]> => {
74
+ const current = launch;
75
+
76
+ const pending = pickPendingCleanups({
77
+ launched: current !== undefined,
78
+ serviceKeys: [...services.keys()],
79
+ stopping,
80
+ });
81
+
82
+ for (const key of [...pending.app, ...pending.services]) {
83
+ stopping.add(key);
84
+ }
85
+
86
+ const appCleanups = pending.app.flatMap((key): Cleanup[] =>
87
+ current === undefined
88
+ ? []
89
+ : [
90
+ [
91
+ key,
92
+ () => {
93
+ const stoppingApp = current.stop();
94
+
95
+ appStopped = Promise.allSettled([stoppingApp]);
96
+
97
+ return stoppingApp;
98
+ },
99
+ ],
100
+ ],
101
+ );
102
+
103
+ const serviceCleanups = pending.services.flatMap((key): Cleanup[] => {
104
+ const service = services.get(key);
105
+
106
+ return service === undefined ? [] : [[key, () => service.stop()]];
107
+ });
108
+
109
+ const appFailures = await runCleanups(appCleanups, options.stopWaitMs);
110
+
111
+ // The app's own stop ends within its limit, so this wait has a bound.
112
+ await appStopped;
113
+
114
+ const serviceFailures = await runCleanups(serviceCleanups, options.stopWaitMs);
115
+
116
+ return [...startupLeftovers.splice(0), ...appFailures, ...serviceFailures];
117
+ };
118
+
119
+ // Every cleanup runs, even after one fails, and the supervisor ends even when the final log write
120
+ // fails. A failed cleanup or log write goes to the stop's caller, as a part may still be up.
121
+ const shutDown = async (plan: ShutDownPlan): Promise<WringerError | null> => {
122
+ // The commands of a startup the stop gave up on end before the cleanups, so that startup settles
123
+ // early. A service that came up has left this signal, so its stop can still run a command.
124
+ if (plan.gaveUpOnStartup) {
125
+ startupCommands.abort();
126
+ }
127
+
128
+ const failures = await stopStarted();
129
+
130
+ const steps = buildShutDownSteps({ ...plan, failures, at: at() });
131
+
132
+ if (steps.abortCommands) {
133
+ serviceCommands.abort();
134
+ }
135
+
136
+ try {
137
+ for (const event of steps.events) {
138
+ await append(event);
139
+ }
140
+
141
+ await options.state.writeManifest(options.runID);
142
+ } catch (error) {
143
+ const failure = toWringerError(error);
144
+
145
+ // A stop that a crash or a signal began has no caller to take the failure.
146
+ options.reportError(`The run's final log write failed: ${failure.message}`);
147
+
148
+ return failure;
149
+ }
150
+
151
+ return steps.failure;
152
+ };
153
+
154
+ // The run's log is already closed, so a late part's failure goes to the supervisor's own log.
155
+ const cleanUpLate = async (): Promise<void> => {
156
+ for (const failure of await stopStarted()) {
157
+ options.reportError(`A late cleanup failed for ${failure}`);
158
+ }
159
+
160
+ serviceCommands.abort();
161
+ };
162
+
163
+ // A failed first write stops the run like any startup failure, so the socket still closes.
164
+ const startUp = async (): Promise<void> => {
165
+ await append({ type: 'supervisor-ready', at: at(), pid: process.pid, socket });
166
+
167
+ launch = await startTarget(target, {
168
+ launchHost: options.launchHost,
169
+ runDirectory: run.paths.directory,
170
+ appLog: run.childLog('app').path,
171
+ targetName: options.target,
172
+ readyTimeoutMs: options.readyTimeoutMs,
173
+ signal: lifetime.signal,
174
+ onCrash: (reason) => {
175
+ void stop(reason);
176
+ },
177
+ onStopFailed: (reason) => {
178
+ startupLeftovers.push(`app: ${reason}`);
179
+ },
180
+ });
181
+
182
+ lifetime.signal.throwIfAborted();
183
+
184
+ await append({ type: 'launch-ready', at: at(), endpoints: launch.endpoints });
185
+
186
+ await startServices({
187
+ wringer: options.wringer,
188
+ targetName: options.target,
189
+ target,
190
+ endpoints: launch.endpoints,
191
+ run,
192
+ hint: target.refusalHint ?? defaultRefusalHint,
193
+ signal: lifetime.signal,
194
+ commandSignal: serviceCommands.signal,
195
+ startupCommandSignal: startupCommands.signal,
196
+ started: services,
197
+ onReady: (key) => append({ type: 'service-ready', at: at(), service: key }),
198
+ });
199
+
200
+ lifetime.signal.throwIfAborted();
201
+
202
+ await append({ type: 'run-ready', at: at() });
203
+
204
+ await options.state.writeManifest(options.runID);
205
+ };
206
+
207
+ const lifecycle = createActor(supervisorMachine, {
208
+ input: {
209
+ stopWaitMs: options.stopWaitMs,
210
+ startUp,
211
+ abortStartup: () => {
212
+ lifetime.abort();
213
+ },
214
+ shutDown,
215
+ cleanUpLate,
216
+ },
217
+ });
218
+
219
+ const waitForClosed = () =>
220
+ waitFor(lifecycle, (snapshot) => snapshot.hasTag('closed'), { timeout: Infinity });
221
+
222
+ // A second stop, such as a `down` while a crash is still stopping the run, waits for the first.
223
+ const stop = async (crashReason: string | null): Promise<WringerError | null> => {
224
+ lifecycle.send({ type: 'stop', reason: crashReason });
225
+
226
+ const closed = await waitForClosed();
227
+
228
+ return closed.context.failure;
229
+ };
230
+
231
+ const answerRequest = (request: SupervisorRequest, signal: AbortSignal): Promise<unknown> =>
232
+ match(pickSupervisorOperation(request, services, options.now().getTime()))
233
+ .with({ kind: 'stop' }, async () => {
234
+ const failure = await stop(null);
235
+
236
+ if (failure !== null) {
237
+ throw failure;
238
+ }
239
+
240
+ return { pid: process.pid };
241
+ })
242
+ .with({ kind: 'status' }, () => Promise.resolve({ pid: process.pid }))
243
+ .with({ kind: 'call' }, (operation) => {
244
+ const timeouts = operation.cancelAfterMs.map((delayMs) => AbortSignal.timeout(delayMs));
245
+
246
+ return operation.call(AbortSignal.any([signal, ...timeouts]));
247
+ })
248
+ .with({ kind: 'refuse' }, (operation) => Promise.reject(operation.error))
249
+ .exhaustive();
250
+
251
+ const server = await options.launchHost.listenSupervisorSocket({
252
+ path: socket,
253
+ answerRequest,
254
+ onTransportError: (error) => {
255
+ options.reportError(`The supervisor socket failed: ${toWringerError(error).message}`);
256
+ },
257
+ });
258
+
259
+ // The startup writes the socket into the run's log, so it starts once the socket listens.
260
+ lifecycle.start();
261
+
262
+ const onSignal = (): void => {
263
+ void stop('the supervisor was signalled to stop');
264
+ };
265
+
266
+ options.signal?.addEventListener('abort', onSignal, { once: true });
267
+
268
+ // A signal that came while the socket opened fired before the listener, which never sees it.
269
+ if (options.signal?.aborted === true) {
270
+ onSignal();
271
+ }
272
+
273
+ await waitForClosed();
274
+
275
+ await server.close(options.stopWaitMs);
276
+ await options.launchHost.removeDirectory(dirname(socket));
277
+ }
278
+
279
+ // Each cleanup runs, even after one fails, and each failure names its part.
280
+ async function runCleanups(cleanups: readonly Cleanup[], stopWaitMs: number): Promise<string[]> {
281
+ const settled = await Promise.allSettled(
282
+ cleanups.map(([, cleanup]) =>
283
+ raceDeadline(Promise.try(cleanup), AbortSignal.timeout(stopWaitMs)),
284
+ ),
285
+ );
286
+
287
+ return settled.flatMap((result, index) =>
288
+ result.status === 'rejected'
289
+ ? [`${cleanups[index]?.[0] ?? 'unknown'}: ${toWringerError(result.reason).message}`]
290
+ : [],
291
+ );
292
+ }
293
+
294
+ interface TargetStart {
295
+ readonly launchHost: LaunchHost;
296
+ readonly runDirectory: string;
297
+ readonly appLog: string;
298
+ readonly targetName: string;
299
+ readonly readyTimeoutMs: number;
300
+ readonly signal: AbortSignal;
301
+ readonly onCrash: (reason: string) => void;
302
+ readonly onStopFailed: (reason: string) => void;
303
+ }
304
+
305
+ async function startTarget(target: TargetDefinition, start: TargetStart): Promise<Launch> {
306
+ if (target.mode === 'launch') {
307
+ return startLaunch({
308
+ host: start.launchHost,
309
+ plan: target.launch({ runDirectory: start.runDirectory }),
310
+ logPath: start.appLog,
311
+ readyTimeoutMs: start.readyTimeoutMs,
312
+ signal: start.signal,
313
+ onCrash: start.onCrash,
314
+ onStopFailed: start.onStopFailed,
315
+ stopGraceMs: appStopGraceMs,
316
+ });
317
+ }
318
+
319
+ // The run owns no app of an attach target, so its stop is a no-op, and the services stop at once.
320
+ if (target.endpoints === undefined) {
321
+ return { endpoints: {}, stop: () => Promise.resolve() };
322
+ }
323
+
324
+ const endpoints: Endpoints = await target.endpoints({ target: start.targetName });
325
+
326
+ return { endpoints, stop: () => Promise.resolve() };
327
+ }
@@ -0,0 +1,26 @@
1
+ import { buildEnvelope } from './build-envelope.ts';
2
+ import type { EnvelopeInput } from './build-envelope.ts';
3
+ import type { Envelope } from './envelope.ts';
4
+ import { exitCodeFor } from './error-codes.ts';
5
+ import type { ExitCode } from './error-codes.ts';
6
+ import type { WringerError } from './wringer-error.ts';
7
+
8
+ export interface DispatchAnswer {
9
+ readonly envelope: Envelope;
10
+ readonly exitCode: ExitCode;
11
+ }
12
+
13
+ // A failed write of the run's log leaves the command out of the log, so the answer carries that
14
+ // failure in place of the command's own outcome, and no data or truncated payload.
15
+ export function settleDispatchAnswer(
16
+ input: EnvelopeInput,
17
+ logFailure: WringerError | null,
18
+ ): DispatchAnswer {
19
+ const final: EnvelopeInput =
20
+ logFailure === null ? input : { ...input, data: null, truncatedPath: null, error: logFailure };
21
+
22
+ return {
23
+ envelope: buildEnvelope(final),
24
+ exitCode: final.error === null ? 0 : exitCodeFor[final.error.code],
25
+ };
26
+ }
@@ -0,0 +1,34 @@
1
+ import type { EnvelopeInput } from './build-envelope.ts';
2
+ import type { CappedData } from './cap-data.ts';
3
+ import type { Evidence } from './envelope.ts';
4
+ import type { EnvelopePayload } from './pick-envelope-payload.ts';
5
+
6
+ export interface EnvelopeFacts {
7
+ readonly command: string;
8
+ readonly target: string | null;
9
+ readonly instance: string | null;
10
+ readonly feature: string | null;
11
+ readonly sha: string | null;
12
+ readonly durationMs: number;
13
+ readonly driveEvidence: readonly Evidence[];
14
+ readonly payload: EnvelopePayload;
15
+ readonly capped: CappedData;
16
+ }
17
+
18
+ // The envelope's data is what the cap left; a payload that failed to cap fails the envelope unless
19
+ // an earlier failure already did.
20
+ export function settleEnvelope(facts: EnvelopeFacts): EnvelopeInput {
21
+ return {
22
+ command: facts.command,
23
+ target: facts.target,
24
+ instance: facts.instance,
25
+ feature: facts.feature,
26
+ sha: facts.sha,
27
+ durationMs: facts.durationMs,
28
+ data: facts.capped.data,
29
+ evidence: [...facts.driveEvidence, ...facts.capped.evidence],
30
+ cursors: facts.payload.cursors,
31
+ truncatedPath: facts.capped.truncatedPath,
32
+ error: facts.payload.failure ?? facts.capped.failure,
33
+ };
34
+ }
package/src/ssh.ts ADDED
@@ -0,0 +1,211 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { findRemoteStopFailure } from './find-remote-stop-failure.ts';
3
+ import { followProcess } from './follow-process.ts';
4
+ import type { Host } from './host.ts';
5
+ import { quoteShellWord } from './quote-shell-word.ts';
6
+ import { runProcess } from './run-process.ts';
7
+ import type { ProcessResult } from './run-process.ts';
8
+ import { toWringerError } from './to-wringer-error.ts';
9
+ import { WringerError } from './wringer-error.ts';
10
+
11
+ export interface PrefixLayer {
12
+ readonly argv: readonly string[];
13
+
14
+ // `true` when this layer hands the rest of the command to a shell as one string, such as
15
+ // `sh -c` or `docker exec <container> sh -c`.
16
+ readonly shell: boolean;
17
+ }
18
+
19
+ export interface SSHOptions {
20
+ readonly destination: string;
21
+ readonly readOnlyUser?: string;
22
+ readonly prefix?: readonly PrefixLayer[];
23
+ readonly binary?: string;
24
+ }
25
+
26
+ // ssh exits 255 when it fails to connect or log in; any other code is the remote command's.
27
+ const sshConnectionFailedExitCode = 255;
28
+ const stopTimeoutMs = 10_000;
29
+
30
+ interface RemoteIO {
31
+ readonly stdin?: string | undefined;
32
+ readonly signal?: AbortSignal | undefined;
33
+ }
34
+
35
+ export function ssh(options: SSHOptions): Host {
36
+ return {
37
+ kind: 'ssh',
38
+ run: async (request) => {
39
+ const user = request.access === 'read-only' ? options.readOnlyUser : undefined;
40
+ const prefix = options.prefix ?? [];
41
+ const groupFile = `/tmp/wringer-${randomUUID()}.pgid`;
42
+
43
+ const sendRemote = (argv: readonly string[], io: RemoteIO) =>
44
+ runProcess({
45
+ argv: buildSSHArgv({
46
+ binary: options.binary ?? 'ssh',
47
+ destination: options.destination,
48
+ user,
49
+ terminal: false,
50
+ remote: buildRemoteCommand(argv, prefix),
51
+ }),
52
+ ...io,
53
+ });
54
+
55
+ const stops: Promise<ProcessResult>[] = [];
56
+
57
+ const stopRemote = (): void => {
58
+ stops.push(
59
+ sendRemote(buildGroupStopArgv(groupFile), { signal: AbortSignal.timeout(stopTimeoutMs) }),
60
+ );
61
+ };
62
+
63
+ request.signal?.addEventListener('abort', stopRemote, { once: true });
64
+
65
+ const running = sendRemote(buildGroupedArgv(request.argv, groupFile), {
66
+ stdin: request.stdin,
67
+ signal: request.signal,
68
+ });
69
+
70
+ const [ran] = await Promise.allSettled([running]);
71
+
72
+ request.signal?.removeEventListener('abort', stopRemote);
73
+
74
+ const stopped = await Promise.allSettled(stops);
75
+
76
+ const stopFailure = findRemoteStopFailure(stopped);
77
+
78
+ // A stop that failed can leave the remote command running, so the caller hears of it.
79
+ if (ran.status === 'rejected') {
80
+ const failure = toWringerError(ran.reason);
81
+
82
+ throw stopFailure === null
83
+ ? failure
84
+ : new WringerError(failure.code, {
85
+ message: `${failure.message} The remote command may still run: ${stopFailure}`,
86
+ hint: `Check ${options.destination} for the command, and stop it by hand.`,
87
+ });
88
+ }
89
+
90
+ if (ran.value.exitCode === sshConnectionFailedExitCode) {
91
+ throw buildConnectionError(options.destination, ran.value.stderr);
92
+ }
93
+
94
+ return ran.value;
95
+ },
96
+ follow: async (request) => {
97
+ const user = request.access === 'read-only' ? options.readOnlyUser : undefined;
98
+
99
+ // Killing the local ssh leaves a plain remote command running, and an idle `tail -f` never
100
+ // notices. A forced terminal makes sshd hang it up; the terminal ends each line with `\r`,
101
+ // and merges stderr into stdout, so the remote shell drops stderr before the terminal.
102
+ const followed = await followProcess({
103
+ argv: buildSSHArgv({
104
+ binary: options.binary ?? 'ssh',
105
+ destination: options.destination,
106
+ user,
107
+ terminal: true,
108
+ remote: `${buildRemoteCommand(request.argv, options.prefix ?? [])} 2>/dev/null`,
109
+ }),
110
+ signal: request.signal,
111
+ onLine: (line) => {
112
+ request.onLine(line.replace(/\r$/u, ''));
113
+ },
114
+ });
115
+
116
+ // Only ssh's own diagnostics reach its stderr, since the terminal carries the remote's.
117
+ if (followed.exitCode === sshConnectionFailedExitCode && !request.signal.aborted) {
118
+ throw buildConnectionError(options.destination, followed.stderr);
119
+ }
120
+
121
+ return followed.exitCode;
122
+ },
123
+ };
124
+ }
125
+
126
+ interface SSHArgvInput {
127
+ readonly binary: string;
128
+ readonly destination: string;
129
+ readonly user: string | undefined;
130
+ readonly terminal: boolean;
131
+ readonly remote: string;
132
+ }
133
+
134
+ function buildSSHArgv(input: SSHArgvInput): readonly string[] {
135
+ const login = input.user === undefined ? [] : ['-l', input.user];
136
+ const terminal = input.terminal ? ['-tt'] : [];
137
+
138
+ return [
139
+ input.binary,
140
+ '-o',
141
+ 'BatchMode=yes',
142
+ ...terminal,
143
+ ...login,
144
+ '--',
145
+ input.destination,
146
+ input.remote,
147
+ ];
148
+ }
149
+
150
+ // ssh hands its command to the remote login shell, so the outermost layer always parses through a
151
+ // shell. Each prefix layer with `shell: true` parses its rest once more, so the rest is quoted once
152
+ // more, from the innermost layer out.
153
+ function buildRemoteCommand(argv: readonly string[], prefix: readonly PrefixLayer[]): string {
154
+ let words = argv;
155
+
156
+ for (const layer of prefix.toReversed()) {
157
+ words = layer.shell ? [...layer.argv, joinShellWords(words)] : [...layer.argv, ...words];
158
+ }
159
+
160
+ return joinShellWords(words);
161
+ }
162
+
163
+ function joinShellWords(words: readonly string[]): string {
164
+ return words.map((word) => quoteShellWord(word)).join(' ');
165
+ }
166
+
167
+ // An abort can land before the remote command records its group, so the stop waits 2 s for the
168
+ // file. SIGINT goes first, so psql cancels its query on the server; SIGTERM and SIGKILL follow, each
169
+ // 1 s after the one before, to what still runs. dash's `kill` refuses `--`.
170
+ const groupStopScript = [
171
+ 'i=0',
172
+ 'while [ ! -f "$1" ] && [ "$i" -lt 20 ]; do sleep 0.1; i=$((i + 1)); done',
173
+ 'group=$(cat "$1" 2>/dev/null) || exit 0',
174
+ 'for signal in INT TERM KILL; do',
175
+ ' kill -"$signal" "-$group" 2>/dev/null || break',
176
+ ' i=0',
177
+ ' while kill -0 "-$group" 2>/dev/null && [ "$i" -lt 10 ]; do sleep 0.1; i=$((i + 1)); done',
178
+ 'done',
179
+ 'rm -f "$1"',
180
+ ].join('\n');
181
+
182
+ function buildGroupStopArgv(groupFile: string): readonly string[] {
183
+ return ['sh', '-c', groupStopScript, 'wringer', groupFile];
184
+ }
185
+
186
+ // Killing the local ssh leaves the remote command running, so the command runs in a group of its
187
+ // own under `setsid`, which needs no terminal. The group's own shell records it in `groupFile`, so
188
+ // a stop that finds the file finds a group that exists. stdin reaches the command through fd 3.
189
+ const groupedScript = [
190
+ 'exec 3<&0',
191
+ `setsid sh -c 'echo "$$" > "$1"; exec sh -c "$2"' wringer "$1" "$2" <&3 3<&- & child=$!`,
192
+ 'exec 3<&-',
193
+ 'wait "$child"',
194
+ 'status=$?',
195
+
196
+ // A background process can hold ssh's output open after the command exits, so a watcher that
197
+ // holds none of ssh's pipes keeps the file until the whole group has exited.
198
+ '(while kill -0 "-$child" 2>/dev/null; do sleep 0.1; done; rm -f "$1") </dev/null >/dev/null 2>&1 &',
199
+ 'exit "$status"',
200
+ ].join('\n');
201
+
202
+ function buildGroupedArgv(argv: readonly string[], groupFile: string): readonly string[] {
203
+ return ['sh', '-c', groupedScript, 'wringer', groupFile, joinShellWords(argv)];
204
+ }
205
+
206
+ function buildConnectionError(destination: string, stderr: string): WringerError {
207
+ return new WringerError('environment', {
208
+ message: `ssh could not reach ${destination}: ${stderr.trim()}`,
209
+ hint: 'Check that the host is up and that ssh logs in without a prompt.',
210
+ });
211
+ }