dsh-ops 0.0.0-stage → 0.2.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 (140) hide show
  1. package/CHANGELOG.md +202 -0
  2. package/LICENSE +30 -0
  3. package/NOTICE +106 -0
  4. package/PROVENANCE.md +435 -0
  5. package/README.en.md +126 -0
  6. package/README.md +115 -2
  7. package/README.zh.md +116 -0
  8. package/bin/dsh-ops.mjs +1216 -0
  9. package/cordis.patch.yml +160 -0
  10. package/docs/manual-validation.md +53 -0
  11. package/docs/release-0.2.1.md +72 -0
  12. package/docs/schema-baseline.json +64 -0
  13. package/docs/schema-current.json +84 -0
  14. package/docs/schema-measurement.md +17 -0
  15. package/dsh-plugin.json +88 -0
  16. package/icon.svg +12 -0
  17. package/lib/binary.js +409 -0
  18. package/lib/config.js +198 -0
  19. package/lib/handshake.js +252 -0
  20. package/lib/index.js +108 -0
  21. package/lib/jobs.js +42 -0
  22. package/lib/policy.js +64 -0
  23. package/lib/presentation.js +63 -0
  24. package/lib/profile-install.js +61 -0
  25. package/lib/rust.js +194 -0
  26. package/lib/session-shells.js +78 -0
  27. package/lib/shells.js +998 -0
  28. package/lib/tools.js +657 -0
  29. package/locale/en.json +6 -0
  30. package/locale/zh.json +6 -0
  31. package/package.json +114 -4
  32. package/vendor/fastctx/Cargo.lock +3210 -0
  33. package/vendor/fastctx/Cargo.toml +94 -0
  34. package/vendor/fastctx/FORK.md +119 -0
  35. package/vendor/fastctx/LICENSE-APACHE +201 -0
  36. package/vendor/fastctx/NOTICE +40 -0
  37. package/vendor/fastctx/README.md +439 -0
  38. package/vendor/fastctx/THIRD_PARTY_LICENSES.md +17 -0
  39. package/vendor/fastctx/THIRD_PARTY_LICENSES_RUST.md +7914 -0
  40. package/vendor/fastctx/UPSTREAM.md +49 -0
  41. package/vendor/fastctx/build.rs +413 -0
  42. package/vendor/fastctx/src/background_status.rs +403 -0
  43. package/vendor/fastctx/src/binary.rs +75 -0
  44. package/vendor/fastctx/src/bounded_sort.rs +500 -0
  45. package/vendor/fastctx/src/budget.rs +781 -0
  46. package/vendor/fastctx/src/cli/mod.rs +110 -0
  47. package/vendor/fastctx/src/context_guard.rs +289 -0
  48. package/vendor/fastctx/src/control/mod.rs +6 -0
  49. package/vendor/fastctx/src/control/paths.rs +49 -0
  50. package/vendor/fastctx/src/control/settings.rs +753 -0
  51. package/vendor/fastctx/src/control/transaction.rs +531 -0
  52. package/vendor/fastctx/src/edit/document.rs +535 -0
  53. package/vendor/fastctx/src/edit/locks.rs +371 -0
  54. package/vendor/fastctx/src/edit/mod.rs +213 -0
  55. package/vendor/fastctx/src/edit/private_storage/unix.rs +315 -0
  56. package/vendor/fastctx/src/edit/private_storage/windows.rs +793 -0
  57. package/vendor/fastctx/src/edit/private_storage.rs +234 -0
  58. package/vendor/fastctx/src/edit/replace.rs +1030 -0
  59. package/vendor/fastctx/src/edit_server.rs +53 -0
  60. package/vendor/fastctx/src/encoding/reference_v011.rs +587 -0
  61. package/vendor/fastctx/src/encoding/snapshot_pipeline.rs +1678 -0
  62. package/vendor/fastctx/src/encoding.rs +1118 -0
  63. package/vendor/fastctx/src/file_executor.rs +1151 -0
  64. package/vendor/fastctx/src/file_snapshot.rs +1491 -0
  65. package/vendor/fastctx/src/glob_filter.rs +98 -0
  66. package/vendor/fastctx/src/glob_tool.rs +653 -0
  67. package/vendor/fastctx/src/grep_sink.rs +1162 -0
  68. package/vendor/fastctx/src/grep_tool.rs +2449 -0
  69. package/vendor/fastctx/src/lib.rs +45 -0
  70. package/vendor/fastctx/src/main.rs +15 -0
  71. package/vendor/fastctx/src/model.rs +51 -0
  72. package/vendor/fastctx/src/model_guidance.rs +62 -0
  73. package/vendor/fastctx/src/operation.rs +356 -0
  74. package/vendor/fastctx/src/ordered_window.rs +1235 -0
  75. package/vendor/fastctx/src/os_environment.rs +414 -0
  76. package/vendor/fastctx/src/path_codec.rs +850 -0
  77. package/vendor/fastctx/src/paths.rs +244 -0
  78. package/vendor/fastctx/src/process_identity.rs +763 -0
  79. package/vendor/fastctx/src/process_policy.rs +74 -0
  80. package/vendor/fastctx/src/read_tool/batch.rs +496 -0
  81. package/vendor/fastctx/src/read_tool/hex_file.rs +141 -0
  82. package/vendor/fastctx/src/read_tool/image_file.rs +88 -0
  83. package/vendor/fastctx/src/read_tool/mod.rs +245 -0
  84. package/vendor/fastctx/src/read_tool/pdf.rs +470 -0
  85. package/vendor/fastctx/src/read_tool/pdf_disabled.rs +47 -0
  86. package/vendor/fastctx/src/read_tool/pdf_engine.rs +664 -0
  87. package/vendor/fastctx/src/read_tool/text_file.rs +351 -0
  88. package/vendor/fastctx/src/render_plan.rs +468 -0
  89. package/vendor/fastctx/src/runtime/activity.rs +159 -0
  90. package/vendor/fastctx/src/runtime/hosts.rs +99 -0
  91. package/vendor/fastctx/src/runtime/journal.rs +556 -0
  92. package/vendor/fastctx/src/runtime/local_ipc.rs +186 -0
  93. package/vendor/fastctx/src/runtime/mod.rs +746 -0
  94. package/vendor/fastctx/src/runtime/protocol.rs +296 -0
  95. package/vendor/fastctx/src/runtime/session.rs +536 -0
  96. package/vendor/fastctx/src/runtime/windows_process.rs +66 -0
  97. package/vendor/fastctx/src/search_parallelism.rs +106 -0
  98. package/vendor/fastctx/src/search_text.rs +227 -0
  99. package/vendor/fastctx/src/server.rs +359 -0
  100. package/vendor/fastctx/src/server_manifest.rs +468 -0
  101. package/vendor/fastctx/src/server_support.rs +826 -0
  102. package/vendor/fastctx/src/session.rs +629 -0
  103. package/vendor/fastctx/src/shell/apply_patch_hint.rs +41 -0
  104. package/vendor/fastctx/src/shell/bash.rs +263 -0
  105. package/vendor/fastctx/src/shell/buffer.rs +108 -0
  106. package/vendor/fastctx/src/shell/encoding.rs +403 -0
  107. package/vendor/fastctx/src/shell/foreground.rs +115 -0
  108. package/vendor/fastctx/src/shell/jobs/admission.rs +91 -0
  109. package/vendor/fastctx/src/shell/jobs/background.rs +146 -0
  110. package/vendor/fastctx/src/shell/jobs/host.rs +830 -0
  111. package/vendor/fastctx/src/shell/jobs/identity.rs +29 -0
  112. package/vendor/fastctx/src/shell/jobs/mod.rs +1513 -0
  113. package/vendor/fastctx/src/shell/jobs/model.rs +244 -0
  114. package/vendor/fastctx/src/shell/jobs/output_log.rs +1148 -0
  115. package/vendor/fastctx/src/shell/jobs/store.rs +1300 -0
  116. package/vendor/fastctx/src/shell/mod.rs +345 -0
  117. package/vendor/fastctx/src/shell/normalize.rs +389 -0
  118. package/vendor/fastctx/src/shell/output.rs +406 -0
  119. package/vendor/fastctx/src/shell/process.rs +493 -0
  120. package/vendor/fastctx/src/shell_server.rs +156 -0
  121. package/vendor/fastctx/src/skip_report.rs +83 -0
  122. package/vendor/fastctx/src/stdio_transport.rs +177 -0
  123. package/vendor/fastctx/src/tool_schema.rs +204 -0
  124. package/vendor/fastctx/src/traversal.rs +846 -0
  125. package/vendor/fastctx/third-party/pdfium-7763/LICENSE +9 -0
  126. package/vendor/fastctx/third-party/pdfium-7763/licenses/abseil.txt +202 -0
  127. package/vendor/fastctx/third-party/pdfium-7763/licenses/agg23.txt +14 -0
  128. package/vendor/fastctx/third-party/pdfium-7763/licenses/fast_float.txt +27 -0
  129. package/vendor/fastctx/third-party/pdfium-7763/licenses/freetype.txt +169 -0
  130. package/vendor/fastctx/third-party/pdfium-7763/licenses/icu.txt +542 -0
  131. package/vendor/fastctx/third-party/pdfium-7763/licenses/lcms.txt +27 -0
  132. package/vendor/fastctx/third-party/pdfium-7763/licenses/libjpeg_turbo.ijg +260 -0
  133. package/vendor/fastctx/third-party/pdfium-7763/licenses/libjpeg_turbo.md +135 -0
  134. package/vendor/fastctx/third-party/pdfium-7763/licenses/libopenjpeg.txt +32 -0
  135. package/vendor/fastctx/third-party/pdfium-7763/licenses/libpng.txt +134 -0
  136. package/vendor/fastctx/third-party/pdfium-7763/licenses/libtiff.txt +21 -0
  137. package/vendor/fastctx/third-party/pdfium-7763/licenses/llvm-libc.txt +278 -0
  138. package/vendor/fastctx/third-party/pdfium-7763/licenses/pdfium.txt +230 -0
  139. package/vendor/fastctx/third-party/pdfium-7763/licenses/simdutf.txt +18 -0
  140. package/vendor/fastctx/third-party/pdfium-7763/licenses/zlib.txt +29 -0
@@ -0,0 +1,830 @@
1
+ //! Detached-supervisor launch, output capture, tree ownership, and kill acknowledgement.
2
+
3
+ use super::identity::{identity_is_alive, process_identity, supervisor_isolation_warning};
4
+ use super::model::{
5
+ CAPTURE_ERROR_FILE, CaptureErrorRecord, EXIT_FILE, ExitRecord, JOB_SCHEMA_VERSION, JobMeta,
6
+ LaunchSpec, META_FILE, OUTPUT_TRUNCATION_FILE, OutputTruncationRecord, TerminationKind,
7
+ };
8
+ use super::output_log::OutputLogWriter;
9
+ use super::store::{
10
+ clear_kill_request, kill_requested, read_json, request_kill, unix_nanos_now, utc_now,
11
+ write_atomic_json,
12
+ };
13
+ use crate::paths::display_path;
14
+ use crate::shell::normalize::{LogNormalizer, NormalizedEvent};
15
+ use crate::shell::process::{exit_code, spawn_bash};
16
+ use std::io::{BufRead, BufReader, Read, Write};
17
+ use std::process::{Child, Command, Stdio};
18
+ use std::sync::atomic::{AtomicBool, Ordering};
19
+ use std::sync::{Arc, mpsc};
20
+ use std::time::{Duration, Instant};
21
+
22
+ // A wait-for-it bound, not a promise of promptness: exceeding it only delays a
23
+ // failure report, while cutting it close reports a slow machine as broken. Ten
24
+ // seconds was not enough to launch a supervisor on a real Snapdragon device.
25
+ const STARTUP_TIMEOUT: Duration = Duration::from_secs(30);
26
+ const CONTROL_POLL: Duration = Duration::from_millis(20);
27
+ const READER_JOIN_TIMEOUT: Duration = Duration::from_secs(2);
28
+ const WATCHDOG_IDENTITY_TIMEOUT: Duration = Duration::from_secs(1);
29
+ const OUTPUT_EVENT_QUEUE: usize = 64;
30
+
31
+ enum OutputEvent {
32
+ Normalized(NormalizedEvent),
33
+ Failed(String),
34
+ Finished,
35
+ }
36
+
37
+ struct OutputEvents {
38
+ receiver: mpsc::Receiver<OutputEvent>,
39
+ discard_output: Arc<AtomicBool>,
40
+ }
41
+
42
+ /// Launches a detached supervisor and waits only until its process tree and immutable metadata exist.
43
+ pub(crate) fn launch_supervisor(
44
+ executable: &std::path::Path,
45
+ spec: &LaunchSpec,
46
+ ) -> Result<(), String> {
47
+ let encoded = serde_json::to_vec(spec)
48
+ .map_err(|error| format!("Cannot encode the background job launch request: {error}"))?;
49
+ let mut child = spawn_detached(executable, &spec.environment, &spec.cwd)?;
50
+ let mut stdin = child.stdin.take().ok_or_else(|| {
51
+ "Cannot start the background job supervisor: its launch pipe was not created.".to_string()
52
+ })?;
53
+ stdin.write_all(&encoded).and_then(|()| stdin.flush()).map_err(|error| {
54
+ let _ = child.kill();
55
+ let _ = child.wait();
56
+ format!("Cannot start the background job supervisor: cannot send its launch request ({error}).")
57
+ })?;
58
+ drop(stdin);
59
+ let stdout = child.stdout.take().ok_or_else(|| {
60
+ let _ = child.kill();
61
+ let _ = child.wait();
62
+ "Cannot start the background job supervisor: its readiness pipe was not created."
63
+ .to_string()
64
+ })?;
65
+ let (sender, receiver) = mpsc::channel();
66
+ std::thread::spawn(move || {
67
+ let mut line = String::new();
68
+ let result = BufReader::new(stdout)
69
+ .read_line(&mut line)
70
+ .map(|_| line)
71
+ .map_err(|error| error.to_string());
72
+ let _ = sender.send(result);
73
+ });
74
+ match receiver.recv_timeout(STARTUP_TIMEOUT) {
75
+ Ok(Ok(line)) if line.trim_end() == "READY" => Ok(()),
76
+ Ok(Ok(line)) if line.starts_with("ERROR\t") => {
77
+ let encoded = line["ERROR\t".len()..].trim_end();
78
+ let message =
79
+ serde_json::from_str::<String>(encoded).unwrap_or_else(|_| encoded.to_string());
80
+ let _ = child.wait();
81
+ Err(message)
82
+ }
83
+ Ok(Ok(line)) => {
84
+ abort_failed_launch(&mut child, spec);
85
+ Err(format!(
86
+ "Cannot start the background job supervisor: invalid readiness response {:?}.",
87
+ line.trim_end()
88
+ ))
89
+ }
90
+ Ok(Err(error)) => {
91
+ abort_failed_launch(&mut child, spec);
92
+ Err(format!(
93
+ "Cannot start the background job supervisor: cannot read its readiness response ({error})."
94
+ ))
95
+ }
96
+ Err(_) => {
97
+ abort_failed_launch(&mut child, spec);
98
+ Err(format!(
99
+ "Cannot start the background job supervisor: it did not become ready within {} seconds.",
100
+ STARTUP_TIMEOUT.as_secs()
101
+ ))
102
+ }
103
+ }
104
+ }
105
+
106
+ fn spawn_detached(
107
+ executable: &std::path::Path,
108
+ environment: &crate::session::SessionEnvironment,
109
+ cwd: &std::path::Path,
110
+ ) -> Result<Child, String> {
111
+ #[cfg(unix)]
112
+ {
113
+ let mut command = Command::new(executable);
114
+ environment.configure_command(&mut command);
115
+ command
116
+ .arg("job-bootstrap")
117
+ .current_dir(cwd)
118
+ .stdin(Stdio::piped())
119
+ .stdout(Stdio::piped())
120
+ .stderr(Stdio::null())
121
+ .spawn()
122
+ .map_err(|error| format!("Cannot start the background job supervisor: {error}."))
123
+ }
124
+ #[cfg(windows)]
125
+ {
126
+ use std::os::windows::process::CommandExt;
127
+ use windows_sys::Win32::System::Threading::{
128
+ CREATE_BREAKAWAY_FROM_JOB, CREATE_NEW_PROCESS_GROUP, DETACHED_PROCESS,
129
+ };
130
+
131
+ fn command(
132
+ executable: &std::path::Path,
133
+ flags: u32,
134
+ environment: &crate::session::SessionEnvironment,
135
+ cwd: &std::path::Path,
136
+ ) -> Command {
137
+ let mut command = Command::new(executable);
138
+ environment.configure_command(&mut command);
139
+ command
140
+ .arg("job-host")
141
+ .current_dir(cwd)
142
+ .stdin(Stdio::piped())
143
+ .stdout(Stdio::piped())
144
+ .stderr(Stdio::null())
145
+ .creation_flags(crate::process_policy::noninteractive_creation_flags(flags));
146
+ command
147
+ }
148
+
149
+ let detached = DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP;
150
+ match command(
151
+ executable,
152
+ detached | CREATE_BREAKAWAY_FROM_JOB,
153
+ environment,
154
+ cwd,
155
+ )
156
+ .spawn()
157
+ {
158
+ Ok(child) => Ok(child),
159
+ Err(error) if error.raw_os_error() == Some(5) => {
160
+ command(executable, detached, environment, cwd)
161
+ .spawn()
162
+ .map_err(|error| {
163
+ format!("Cannot start the background job supervisor: {error}.")
164
+ })
165
+ }
166
+ Err(error) => Err(format!(
167
+ "Cannot start the background job supervisor: {error}."
168
+ )),
169
+ }
170
+ }
171
+ #[cfg(not(any(unix, windows)))]
172
+ {
173
+ let mut command = Command::new(executable);
174
+ environment.configure_command(&mut command);
175
+ command
176
+ .arg("job-host")
177
+ .current_dir(cwd)
178
+ .stdin(Stdio::piped())
179
+ .stdout(Stdio::piped())
180
+ .stderr(Stdio::null())
181
+ .spawn()
182
+ .map_err(|error| format!("Cannot start the background job supervisor: {error}."))
183
+ }
184
+ }
185
+
186
+ fn abort_failed_launch(child: &mut Child, spec: &LaunchSpec) {
187
+ let _ = child.kill();
188
+ let _ = child.wait();
189
+ let meta = read_json::<JobMeta>(&spec.job_dir.join(META_FILE), "job metadata")
190
+ .ok()
191
+ .flatten();
192
+ let Some(meta) = meta else {
193
+ return;
194
+ };
195
+ if !identity_is_alive(&meta.supervisor) {
196
+ return;
197
+ }
198
+ let record = super::model::JobRecord {
199
+ id: spec.job_id.clone(),
200
+ directory: spec.job_dir.clone(),
201
+ meta,
202
+ status: super::model::JobStatus::Running,
203
+ ended_sort_key: std::time::UNIX_EPOCH,
204
+ };
205
+ let _ = request_kill(&record);
206
+ let deadline = Instant::now() + Duration::from_secs(6);
207
+ while identity_is_alive(&record.meta.supervisor) && Instant::now() < deadline {
208
+ std::thread::sleep(CONTROL_POLL);
209
+ }
210
+ }
211
+
212
+ /// Unix intermediate process: creates a new session, starts the final host, and exits immediately.
213
+ #[cfg(unix)]
214
+ pub(crate) fn run_bootstrap() -> Result<(), String> {
215
+ let mut launch = Vec::new();
216
+ std::io::stdin()
217
+ .read_to_end(&mut launch)
218
+ .map_err(|error| format!("Cannot read the background job launch request: {error}"))?;
219
+ if unsafe { libc::setsid() } < 0 {
220
+ return Err(format!(
221
+ "Cannot detach the background job supervisor session: {}",
222
+ std::io::Error::last_os_error()
223
+ ));
224
+ }
225
+ let executable = std::env::current_exe()
226
+ .map_err(|error| format!("Cannot locate the background job supervisor binary: {error}"))?;
227
+ let spec: LaunchSpec = serde_json::from_slice(&launch)
228
+ .map_err(|error| format!("Cannot parse the background job launch request: {error}"))?;
229
+ let mut command = Command::new(executable);
230
+ spec.environment.configure_command(&mut command);
231
+ let mut child = command
232
+ .arg("job-host")
233
+ .current_dir(&spec.cwd)
234
+ .stdin(Stdio::piped())
235
+ .stdout(Stdio::inherit())
236
+ .stderr(Stdio::null())
237
+ .spawn()
238
+ .map_err(|error| format!("Cannot start the background job supervisor: {error}"))?;
239
+ let mut stdin = child
240
+ .stdin
241
+ .take()
242
+ .ok_or_else(|| "Cannot create the final supervisor launch pipe.".to_string())?;
243
+ stdin
244
+ .write_all(&launch)
245
+ .and_then(|()| stdin.flush())
246
+ .map_err(|error| format!("Cannot forward the background job launch request: {error}"))?;
247
+ Ok(())
248
+ }
249
+
250
+ /// Final detached process entry point. Startup failures use the inherited readiness pipe.
251
+ pub(crate) fn run_job_host() -> Result<(), String> {
252
+ let result = (|| {
253
+ let mut source = Vec::new();
254
+ std::io::stdin()
255
+ .read_to_end(&mut source)
256
+ .map_err(|error| format!("Cannot read the background job launch request: {error}"))?;
257
+ let spec: LaunchSpec = serde_json::from_slice(&source)
258
+ .map_err(|error| format!("Cannot parse the background job launch request: {error}"))?;
259
+ supervise(spec)
260
+ })();
261
+ if let Err(error) = &result {
262
+ write_startup_error(error);
263
+ }
264
+ result
265
+ }
266
+
267
+ fn supervise(spec: LaunchSpec) -> Result<(), String> {
268
+ spec.encoding
269
+ .as_deref()
270
+ .map(crate::shell::encoding::validate_output_encoding)
271
+ .transpose()
272
+ .map_err(|error| format!("Cannot start the background job supervisor: {error}"))?;
273
+ let mut process = spawn_bash(
274
+ &spec.bash,
275
+ &spec.command,
276
+ &spec.cwd,
277
+ spec.login_shell,
278
+ &spec.environment,
279
+ &spec.utf8_locale,
280
+ )
281
+ .map_err(|error| format!("Cannot start the command: {error}."))?;
282
+ let watchdog_deadline = Instant::now() + WATCHDOG_IDENTITY_TIMEOUT;
283
+ let (mut watchdog, mut early_exit) = loop {
284
+ match WatchdogGuard::arm(process.id(), &spec.environment, &spec.cwd) {
285
+ Ok(Some(watchdog)) => break (watchdog, None),
286
+ Ok(None) => match process.try_wait() {
287
+ Ok(Some(status)) => {
288
+ let code = exit_code(status);
289
+ process.terminate_tree().map_err(|error| {
290
+ format!(
291
+ "The command exited before orphan protection was established, but its descendant process tree could not be terminated: {error}"
292
+ )
293
+ })?;
294
+ break (WatchdogGuard::disarmed(), Some(code));
295
+ }
296
+ Ok(None) if Instant::now() < watchdog_deadline => {
297
+ std::thread::sleep(CONTROL_POLL);
298
+ }
299
+ Ok(None) => {
300
+ let error = "Cannot identify the background command process for orphan protection after 1 second.";
301
+ return match process.terminate_tree() {
302
+ Ok(_) => Err(error.to_string()),
303
+ Err(termination_error) => Err(format!(
304
+ "{error} Terminating the unprotected command tree also failed: {termination_error}."
305
+ )),
306
+ };
307
+ }
308
+ Err(error) => {
309
+ let message = format!(
310
+ "Cannot inspect the background command while establishing orphan protection: {error}."
311
+ );
312
+ return match process.terminate_tree() {
313
+ Ok(_) => Err(message),
314
+ Err(termination_error) => Err(format!(
315
+ "{message} Terminating the unprotected command tree also failed: {termination_error}."
316
+ )),
317
+ };
318
+ }
319
+ },
320
+ Err(error) => {
321
+ let _ = process.terminate_tree();
322
+ return Err(error);
323
+ }
324
+ }
325
+ };
326
+ let supervisor = process_identity(std::process::id()).ok_or_else(|| {
327
+ let _ = process.terminate_tree();
328
+ watchdog.disarm();
329
+ "Cannot identify the background job supervisor process. The command was terminated."
330
+ .to_string()
331
+ })?;
332
+ let meta = JobMeta {
333
+ schema_version: JOB_SCHEMA_VERSION,
334
+ command: spec.command,
335
+ cwd: display_path(&spec.cwd),
336
+ login_shell: spec.login_shell,
337
+ encoding: spec.encoding.clone(),
338
+ supervisor,
339
+ origin: spec.origin,
340
+ started_at: utc_now(),
341
+ started_at_unix_nanos: unix_nanos_now(),
342
+ isolation_warning: supervisor_isolation_warning(),
343
+ };
344
+ let output = process.take_output();
345
+ let mut log = match OutputLogWriter::with_limit(&spec.job_dir, spec.output_limit_bytes) {
346
+ Ok(log) => Some(log),
347
+ Err(error) => {
348
+ let _ = process.terminate_tree();
349
+ watchdog.disarm();
350
+ return Err(format!(
351
+ "Cannot start the background job supervisor: {error}"
352
+ ));
353
+ }
354
+ };
355
+ if let Err(error) = write_atomic_json(&spec.job_dir.join(META_FILE), &meta) {
356
+ let _ = process.terminate_tree();
357
+ watchdog.disarm();
358
+ return Err(format!(
359
+ "Cannot start the background job supervisor: {error}"
360
+ ));
361
+ }
362
+ let (events, reader) = spawn_reader(output);
363
+ if let Err(error) = write_ready() {
364
+ match process.terminate_tree() {
365
+ Ok(_) => watchdog.disarm(),
366
+ Err(termination_error) => {
367
+ return Err(format!(
368
+ "{error} Terminating the command tree also failed: {termination_error}."
369
+ ));
370
+ }
371
+ }
372
+ return Err(error);
373
+ }
374
+
375
+ let mut total_lines = 0_u64;
376
+ let mut had_loss = false;
377
+ let mut capture_error = None;
378
+ let mut output_truncation = None;
379
+ let (status, termination) = loop {
380
+ let drained_output = drain_output_events(
381
+ &events,
382
+ &spec.job_dir,
383
+ &mut log,
384
+ &mut total_lines,
385
+ &mut had_loss,
386
+ &mut capture_error,
387
+ &mut output_truncation,
388
+ );
389
+ if let Some(writer) = log.as_mut()
390
+ && let Err(error) = writer.flush_if_idle()
391
+ {
392
+ capture_failure(
393
+ &spec.job_dir,
394
+ &mut log,
395
+ &mut total_lines,
396
+ error,
397
+ &mut had_loss,
398
+ &mut capture_error,
399
+ );
400
+ events.discard_output.store(true, Ordering::Release);
401
+ }
402
+
403
+ if let Some(status) = early_exit.take() {
404
+ break (status, TerminationKind::Exited);
405
+ }
406
+ match process.try_wait() {
407
+ Ok(Some(status)) => {
408
+ let code = exit_code(status);
409
+ process.terminate_tree().map_err(|error| {
410
+ format!(
411
+ "The command exited, but its descendant process tree could not be terminated: {error}"
412
+ )
413
+ })?;
414
+ break (code, TerminationKind::Exited);
415
+ }
416
+ Ok(None) => {}
417
+ Err(error) => {
418
+ capture_failure(
419
+ &spec.job_dir,
420
+ &mut log,
421
+ &mut total_lines,
422
+ format!("cannot monitor the command process: {error}"),
423
+ &mut had_loss,
424
+ &mut capture_error,
425
+ );
426
+ let code = exit_code(process.terminate_tree().map_err(|termination_error| {
427
+ format!(
428
+ "Cannot monitor the command process ({error}) or terminate its process tree ({termination_error})."
429
+ )
430
+ })?);
431
+ break (code, TerminationKind::Exited);
432
+ }
433
+ }
434
+
435
+ if kill_requested(&spec.job_dir) {
436
+ match process.try_wait() {
437
+ Ok(Some(status)) => {
438
+ clear_kill_request(&spec.job_dir);
439
+ let _ = process.terminate_tree();
440
+ break (exit_code(status), TerminationKind::Exited);
441
+ }
442
+ Ok(None) => {
443
+ if let Ok(status) = process.terminate_tree() {
444
+ clear_kill_request(&spec.job_dir);
445
+ break (exit_code(status), TerminationKind::Killed);
446
+ }
447
+ }
448
+ Err(error) => {
449
+ capture_failure(
450
+ &spec.job_dir,
451
+ &mut log,
452
+ &mut total_lines,
453
+ format!("cannot inspect the command before termination: {error}"),
454
+ &mut had_loss,
455
+ &mut capture_error,
456
+ );
457
+ if let Ok(status) = process.terminate_tree() {
458
+ clear_kill_request(&spec.job_dir);
459
+ break (exit_code(status), TerminationKind::Killed);
460
+ }
461
+ }
462
+ }
463
+ }
464
+ if drained_output {
465
+ std::thread::yield_now();
466
+ } else {
467
+ std::thread::sleep(CONTROL_POLL);
468
+ }
469
+ };
470
+
471
+ let deadline = Instant::now() + READER_JOIN_TIMEOUT;
472
+ while Instant::now() < deadline {
473
+ let drained_output = drain_output_events(
474
+ &events,
475
+ &spec.job_dir,
476
+ &mut log,
477
+ &mut total_lines,
478
+ &mut had_loss,
479
+ &mut capture_error,
480
+ &mut output_truncation,
481
+ );
482
+ if reader.is_finished() {
483
+ break;
484
+ }
485
+ if drained_output {
486
+ std::thread::yield_now();
487
+ } else {
488
+ std::thread::sleep(Duration::from_millis(10));
489
+ }
490
+ }
491
+ if reader.is_finished() {
492
+ let _ = reader.join();
493
+ } else {
494
+ capture_failure(
495
+ &spec.job_dir,
496
+ &mut log,
497
+ &mut total_lines,
498
+ "the output reader did not finish within 2 seconds after the process tree exited"
499
+ .to_string(),
500
+ &mut had_loss,
501
+ &mut capture_error,
502
+ );
503
+ }
504
+ drain_output_events(
505
+ &events,
506
+ &spec.job_dir,
507
+ &mut log,
508
+ &mut total_lines,
509
+ &mut had_loss,
510
+ &mut capture_error,
511
+ &mut output_truncation,
512
+ );
513
+ if let Some(writer) = log.as_mut() {
514
+ if let Err(error) = writer.finish() {
515
+ capture_failure(
516
+ &spec.job_dir,
517
+ &mut log,
518
+ &mut total_lines,
519
+ error,
520
+ &mut had_loss,
521
+ &mut capture_error,
522
+ );
523
+ } else {
524
+ total_lines = writer.total_lines();
525
+ }
526
+ }
527
+ watchdog.disarm();
528
+ let exit = ExitRecord {
529
+ exit_code: status,
530
+ total_lines,
531
+ had_loss: had_loss || capture_error.is_some() || output_truncation.is_some(),
532
+ ended_at: utc_now(),
533
+ ended_at_unix_nanos: unix_nanos_now(),
534
+ termination,
535
+ capture_error,
536
+ output_truncation,
537
+ };
538
+ write_atomic_json(&spec.job_dir.join(EXIT_FILE), &exit)
539
+ .map_err(|error| format!("Cannot write the background job exit record: {error}"))
540
+ }
541
+
542
+ fn spawn_reader(
543
+ mut output: impl Read + Send + 'static,
544
+ ) -> (OutputEvents, std::thread::JoinHandle<()>) {
545
+ // Backpressure keeps an arbitrarily chatty command bounded by the pipe and
546
+ // disk writer instead of accumulating its full output in supervisor memory.
547
+ let (sender, receiver) = mpsc::sync_channel(OUTPUT_EVENT_QUEUE);
548
+ let discard_output = Arc::new(AtomicBool::new(false));
549
+ let reader_discard = Arc::clone(&discard_output);
550
+ let reader = std::thread::spawn(move || {
551
+ let mut normalizer = LogNormalizer::new();
552
+ let mut buffer = [0_u8; 16 * 1024];
553
+ loop {
554
+ let read = match output.read(&mut buffer) {
555
+ Ok(read) => read,
556
+ Err(error) => {
557
+ if !reader_discard.load(Ordering::Acquire) {
558
+ let mut events = Vec::new();
559
+ normalizer.finish(&mut events);
560
+ if !send_normalized_events(&sender, events, &reader_discard) {
561
+ return;
562
+ }
563
+ }
564
+ let _ = sender.send(OutputEvent::Failed(format!(
565
+ "cannot read the merged command output: {error}"
566
+ )));
567
+ return;
568
+ }
569
+ };
570
+ if read == 0 {
571
+ break;
572
+ }
573
+ if reader_discard.load(Ordering::Acquire) {
574
+ continue;
575
+ }
576
+ let mut events = Vec::new();
577
+ normalizer.push(&buffer[..read], &mut events);
578
+ if !send_normalized_events(&sender, events, &reader_discard) {
579
+ return;
580
+ }
581
+ }
582
+ if !reader_discard.load(Ordering::Acquire) {
583
+ let mut events = Vec::new();
584
+ normalizer.finish(&mut events);
585
+ if !send_normalized_events(&sender, events, &reader_discard) {
586
+ return;
587
+ }
588
+ }
589
+ let _ = sender.send(OutputEvent::Finished);
590
+ });
591
+ (
592
+ OutputEvents {
593
+ receiver,
594
+ discard_output,
595
+ },
596
+ reader,
597
+ )
598
+ }
599
+
600
+ fn send_normalized_events(
601
+ sender: &mpsc::SyncSender<OutputEvent>,
602
+ events: Vec<NormalizedEvent>,
603
+ discard_output: &AtomicBool,
604
+ ) -> bool {
605
+ for event in events {
606
+ if discard_output.load(Ordering::Acquire) {
607
+ return true;
608
+ }
609
+ if sender.send(OutputEvent::Normalized(event)).is_err() {
610
+ return false;
611
+ }
612
+ }
613
+ true
614
+ }
615
+
616
+ fn drain_output_events(
617
+ events: &OutputEvents,
618
+ directory: &std::path::Path,
619
+ log: &mut Option<OutputLogWriter>,
620
+ total_lines: &mut u64,
621
+ had_loss: &mut bool,
622
+ capture_error: &mut Option<CaptureErrorRecord>,
623
+ output_truncation: &mut Option<OutputTruncationRecord>,
624
+ ) -> bool {
625
+ let mut drained = false;
626
+ while let Ok(event) = events.receiver.try_recv() {
627
+ drained = true;
628
+ match event {
629
+ OutputEvent::Normalized(event) => {
630
+ if let Some(writer) = log.as_mut() {
631
+ match writer.append(event) {
632
+ Ok(Some(seq)) => *total_lines = seq,
633
+ Ok(None) => {}
634
+ Err(error) => capture_failure(
635
+ directory,
636
+ log,
637
+ total_lines,
638
+ error,
639
+ had_loss,
640
+ capture_error,
641
+ ),
642
+ }
643
+ }
644
+ if log.is_none() || log.as_ref().is_some_and(OutputLogWriter::quota_exceeded) {
645
+ // Keep draining the OS pipe, but stop allocating and sending one event per
646
+ // discarded line once the immutable output prefix is sealed.
647
+ events.discard_output.store(true, Ordering::Release);
648
+ }
649
+ if output_truncation.is_none()
650
+ && let Some(writer) = log.as_ref().filter(|writer| writer.quota_exceeded())
651
+ {
652
+ let record = OutputTruncationRecord {
653
+ limit_bytes: writer.byte_limit(),
654
+ persisted_log_bytes: writer.persisted_log_bytes(),
655
+ persisted_index_bytes: writer.persisted_index_bytes(),
656
+ after_seq: writer.total_lines(),
657
+ };
658
+ *had_loss = true;
659
+ output_truncation.clone_from(&Some(record.clone()));
660
+ if let Err(error) =
661
+ write_atomic_json(&directory.join(OUTPUT_TRUNCATION_FILE), &record)
662
+ {
663
+ capture_failure(
664
+ directory,
665
+ log,
666
+ total_lines,
667
+ format!("cannot persist the background output quota notice: {error}"),
668
+ had_loss,
669
+ capture_error,
670
+ );
671
+ }
672
+ }
673
+ }
674
+ OutputEvent::Failed(error) => {
675
+ capture_failure(directory, log, total_lines, error, had_loss, capture_error);
676
+ events.discard_output.store(true, Ordering::Release);
677
+ }
678
+ OutputEvent::Finished => {}
679
+ }
680
+ }
681
+ drained
682
+ }
683
+
684
+ fn capture_failure(
685
+ directory: &std::path::Path,
686
+ log: &mut Option<OutputLogWriter>,
687
+ total_lines: &mut u64,
688
+ reason: String,
689
+ had_loss: &mut bool,
690
+ capture_error: &mut Option<CaptureErrorRecord>,
691
+ ) {
692
+ if let Some(writer) = log.as_mut() {
693
+ let _ = writer.finish();
694
+ *total_lines = writer.preserved_lines();
695
+ }
696
+ *log = None;
697
+ *had_loss = true;
698
+ if capture_error.is_some() {
699
+ return;
700
+ }
701
+ let record = CaptureErrorRecord {
702
+ after_seq: *total_lines,
703
+ reason,
704
+ };
705
+ capture_error.clone_from(&Some(record.clone()));
706
+ let _ = write_atomic_json(&directory.join(CAPTURE_ERROR_FILE), &record);
707
+ }
708
+
709
+ fn write_ready() -> Result<(), String> {
710
+ let mut stdout = std::io::stdout().lock();
711
+ stdout
712
+ .write_all(b"READY\n")
713
+ .and_then(|()| stdout.flush())
714
+ .map_err(|error| {
715
+ format!(
716
+ "Cannot acknowledge background job supervisor startup: {error}. The command was terminated."
717
+ )
718
+ })
719
+ }
720
+
721
+ pub(super) fn write_startup_error(error: &str) {
722
+ let encoded =
723
+ serde_json::to_string(error).unwrap_or_else(|_| "\"unknown startup error\"".to_string());
724
+ let _ = writeln!(std::io::stdout().lock(), "ERROR\t{encoded}");
725
+ }
726
+
727
+ #[cfg(unix)]
728
+ struct WatchdogGuard {
729
+ writer: Option<std::io::PipeWriter>,
730
+ }
731
+
732
+ #[cfg(unix)]
733
+ impl WatchdogGuard {
734
+ fn arm(
735
+ process_pid: u32,
736
+ environment: &crate::session::SessionEnvironment,
737
+ cwd: &std::path::Path,
738
+ ) -> Result<Option<Self>, String> {
739
+ use std::os::unix::process::CommandExt;
740
+
741
+ let Some(identity) = process_identity(process_pid) else {
742
+ return Ok(None);
743
+ };
744
+ let (reader, writer) = std::io::pipe().map_err(|error| {
745
+ format!("Cannot create the background command orphan-protection pipe: {error}")
746
+ })?;
747
+ let executable = std::env::current_exe()
748
+ .map_err(|error| format!("Cannot locate the orphan-protection helper: {error}"))?;
749
+ let mut command = Command::new(executable);
750
+ environment.configure_command(&mut command);
751
+ command
752
+ .arg("job-watch")
753
+ .arg(identity.pid.to_string())
754
+ .arg(&identity.started)
755
+ .stdin(Stdio::from(reader))
756
+ .current_dir(cwd)
757
+ .stdout(Stdio::null())
758
+ .stderr(Stdio::null());
759
+ unsafe {
760
+ command.pre_exec(|| {
761
+ if libc::setsid() < 0 {
762
+ return Err(std::io::Error::last_os_error());
763
+ }
764
+ Ok(())
765
+ });
766
+ }
767
+ command.spawn().map_err(|error| {
768
+ format!("Cannot start the background command orphan-protection helper: {error}")
769
+ })?;
770
+ Ok(Some(Self {
771
+ writer: Some(writer),
772
+ }))
773
+ }
774
+
775
+ fn disarmed() -> Self {
776
+ Self { writer: None }
777
+ }
778
+
779
+ fn disarm(&mut self) {
780
+ if let Some(mut writer) = self.writer.take() {
781
+ let _ = writer.write_all(b"D");
782
+ }
783
+ }
784
+ }
785
+
786
+ #[cfg(not(unix))]
787
+ struct WatchdogGuard;
788
+
789
+ #[cfg(not(unix))]
790
+ impl WatchdogGuard {
791
+ fn arm(
792
+ _process_pid: u32,
793
+ _environment: &crate::session::SessionEnvironment,
794
+ _cwd: &std::path::Path,
795
+ ) -> Result<Option<Self>, String> {
796
+ Ok(Some(Self))
797
+ }
798
+
799
+ fn disarmed() -> Self {
800
+ Self
801
+ }
802
+
803
+ fn disarm(&mut self) {}
804
+ }
805
+
806
+ /// Unix helper waits for a clean-disarm byte; EOF means the host died and the command group must die.
807
+ #[cfg(unix)]
808
+ pub(crate) fn run_watchdog(pid: u32, started: String) -> Result<(), String> {
809
+ let mut byte = [0_u8; 1];
810
+ let read = std::io::stdin()
811
+ .read(&mut byte)
812
+ .map_err(|error| format!("Cannot read the orphan-protection pipe: {error}"))?;
813
+ if read == 0 {
814
+ let identity = super::model::ProcessIdentity { pid, started };
815
+ if identity_is_alive(&identity) {
816
+ let process_group =
817
+ i32::try_from(pid).map_err(|_| format!("Cannot address process group {pid}."))?;
818
+ let result = unsafe { libc::kill(-process_group, libc::SIGKILL) };
819
+ if result != 0 {
820
+ let error = std::io::Error::last_os_error();
821
+ if error.raw_os_error() != Some(libc::ESRCH) {
822
+ return Err(format!(
823
+ "Cannot terminate orphaned process group {pid}: {error}"
824
+ ));
825
+ }
826
+ }
827
+ }
828
+ }
829
+ Ok(())
830
+ }