@sjcrh/proteinpaint-rust 2.191.4 → 2.191.7

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 (3) hide show
  1. package/index.js +10 -4
  2. package/package.json +1 -1
  3. package/src/gdcmaf.rs +287 -40
package/index.js CHANGED
@@ -88,7 +88,12 @@ export function run_rust(binfile, input_data, args = [], { signal } = {}) {
88
88
  // will already trigger an error. `stream_rust()` was heavily tested manually
89
89
  // while troubleshooting and fixing the idle rust processes the led to memory leaks
90
90
  // as part of `/gdc/mafBuild` handler code, leave this code as-is for now.
91
- export function stream_rust(binfile, input_data, emitJson) {
91
+ // opts.maxElapsed (ms): how long a tracked 'gdcmaf' process may run before the
92
+ // watchdog kills it. Defaults to 300000 (5 min) when omitted. This is a safety
93
+ // BACKSTOP against leaked/hung processes (e.g. a download that stalls against a
94
+ // slow GDC environment) - it is not itself the cause of a hang; tightening the
95
+ // rust-side per-request timeouts is what makes a stuck download fail fast.
96
+ export function stream_rust(binfile, input_data, emitJson, opts = {}) {
92
97
  const binpath = path.join(__dirname, '/target/release/', binfile)
93
98
 
94
99
  const ps = spawn(binpath)
@@ -99,7 +104,7 @@ export function stream_rust(binfile, input_data, emitJson) {
99
104
  }
100
105
  })
101
106
  // we only want to run this interval loop inside a container, not in dev/test CI
102
- if (binfile == 'gdcmaf') trackByPid(ps.pid, binfile)
107
+ if (binfile == 'gdcmaf') trackByPid(ps.pid, binfile, opts.maxElapsed)
103
108
  const stderr = []
104
109
  try {
105
110
  // from route handler -> input_data -> ps.stdin -> ps.stdout -> transformed stream -> express response.pipe()
@@ -193,8 +198,9 @@ const killedPids = new Set() // will be used to detect killed processes, to help
193
198
  const PSKILL_INTERVAL_MS = 30000 // every 30 seconds
194
199
  let psKillInterval
195
200
 
196
- // default maxElapsed = 5 * 60 * 1000 millisecond = 300000 or 5 minutes, change to 0 to test
197
- // may allow configuration of maxElapsed by dataset/argument
201
+ // maxElapsed: the watchdog kill threshold in ms; defaults to 5 minutes (300000).
202
+ // Configurable per call via stream_rust(..., { maxElapsed }) (wired from
203
+ // serverconfig.features.gdcMafMaxElapsed by the /gdc/mafBuild handler).
198
204
  function trackByPid(pid, name, maxElapsed = 300000) {
199
205
  if (!pid) return
200
206
  // only track by value (integer, string), not reference object
package/package.json CHANGED
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "2.191.4",
2
+ "version": "2.191.7",
3
3
  "name": "@sjcrh/proteinpaint-rust",
4
4
  "type": "module",
5
5
  "description": "Rust-based utilities for proteinpaint",
package/src/gdcmaf.rs CHANGED
@@ -30,6 +30,30 @@ struct ErrorEntry {
30
30
  error: String,
31
31
  }
32
32
 
33
+ // Flatten a std::error::Error (e.g. reqwest::Error) and its `source()` chain into
34
+ // a single string, so the real transport-level cause (e.g. "connection closed
35
+ // before message completed", connection reset, HTTP/2 stream error) is visible in
36
+ // the per-file error rather than only the top-level wrapper message.
37
+ fn format_error_chain(e: &dyn std::error::Error) -> String {
38
+ let mut msg = e.to_string();
39
+ let mut src = e.source();
40
+ while let Some(s) = src {
41
+ msg.push_str(" -> ");
42
+ msg.push_str(&s.to_string());
43
+ src = s.source();
44
+ }
45
+ msg
46
+ }
47
+
48
+ // Render the first bytes of a response body for diagnostics: helps tell whether the
49
+ // server returned gzip (magic 1f 8b), plain text, or an HTML/JSON error page.
50
+ fn preview_bytes(content: &[u8]) -> String {
51
+ let n = content.len().min(64);
52
+ let hex: Vec<String> = content[..n].iter().map(|b| format!("{:02x}", b)).collect();
53
+ let ascii = String::from_utf8_lossy(&content[..n]);
54
+ format!("hex=[{}] ascii=\"{}\"", hex.join(" "), ascii.escape_default())
55
+ }
56
+
33
57
  fn select_maf_col(d: String, columns: &Vec<String>, url: &str) -> Result<(Vec<u8>, i32), (String, String)> {
34
58
  let mut maf_str: String = String::new();
35
59
  let mut header_indices: Vec<usize> = Vec::new();
@@ -201,52 +225,143 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
201
225
  )) as Box<dyn std::error::Error>);
202
226
  };
203
227
 
228
+ let client = reqwest::Client::builder()
229
+ .timeout(Duration::from_secs(30)) // 30-second timeout per request
230
+ .connect_timeout(Duration::from_secs(15))
231
+ // Allow keep-alive connection reuse. Previously this was pool_max_idle_per_host(0)
232
+ // to dodge a "connection closed before message completed" race, but disabling reuse
233
+ // forces a brand-new connection per file, which is far harsher on a server that caps
234
+ // concurrent connections (e.g. qa-int). Mid-body drops are now handled by retrying the
235
+ // whole download (see the retry loop below) rather than by refusing to reuse connections.
236
+ .build()
237
+ .map_err(|e| {
238
+ let client_error = ErrorEntry {
239
+ url: String::new(),
240
+ error: format!("Client build error: {}", e),
241
+ };
242
+ let client_error_js = serde_json::to_string(&client_error)
243
+ .unwrap_or_else(|_| "{\"url\":\"\",\"error\":\"Failed to serialize client build error\"}".to_string());
244
+ writeln!(io::stderr(), "{}", client_error_js).expect("Failed to write client build error to stderr");
245
+ e
246
+ })?;
247
+
248
+ // Number of files downloaded concurrently. Driven by the input JSON "concurrency" field,
249
+ // which the /gdc/mafBuild handler sets from serverconfig.features.gdcMafConcurrency, so it
250
+ // can be dialed down per environment (e.g. qa-int, which appears to cap simultaneous
251
+ // connections) without a rebuild; falls back to 20 when absent.
252
+ let concurrency = file_id_lst_js
253
+ .get("concurrency")
254
+ .and_then(|v| v.as_u64())
255
+ .filter(|n| *n >= 1)
256
+ .unwrap_or(20) as usize;
257
+
204
258
  //downloading maf files parallelly and merge them into single maf file
205
259
  let download_futures = futures::stream::iter(url.into_iter().map(|url| {
206
- let req_headers_clone = req_headers.clone();
260
+ let client = client.clone();
261
+ let req_headers = req_headers.clone();
207
262
  async move {
208
- let client = reqwest::Client::builder()
209
- .timeout(Duration::from_secs(60)) // 60-second timeout per request
210
- .connect_timeout(Duration::from_secs(15))
211
- .default_headers(req_headers_clone.clone())
212
- .build()
213
- .map_err(|_e| {
214
- let client_error = ErrorEntry {
215
- url: url.clone(),
216
- error: "Client build error".to_string(),
217
- };
218
- let client_error_js = serde_json::to_string(&client_error).unwrap();
219
- writeln!(io::stderr(), "{}", client_error_js).expect("Failed to build reqwest client!");
220
- });
221
- match client.unwrap().get(&url).send().await {
222
- Ok(resp) if resp.status().is_success() => match resp.bytes().await {
223
- Ok(content) => {
224
- let mut decoder = GzDecoder::new(&content[..]);
225
- let mut decompressed_content = Vec::new();
226
- match decoder.read_to_end(&mut decompressed_content) {
227
- Ok(_) => {
228
- let text = String::from_utf8_lossy(&decompressed_content).to_string();
229
- return Ok((url.clone(), text));
230
- }
231
- Err(e) => {
232
- let error_msg = format!("Failed to decompress downloaded MAF file: {}", e);
233
- Err((url.clone(), error_msg))
234
- }
235
- }
263
+ // Bounded retry for transient transport failures, up to MAX_ATTEMPTS with backoff.
264
+ // The whole download (send + body read) is inside the loop so a mid-body drop
265
+ // ("connection closed before message completed") is retried, not just connect/send
266
+ // failures. Non-2xx responses and decompression failures are NOT transient and break
267
+ // immediately. The metadata captured before consuming the body feeds the error text.
268
+ const MAX_ATTEMPTS: u32 = 3;
269
+ let mut attempt: u32 = 0;
270
+ loop {
271
+ attempt += 1;
272
+
273
+ let mut request = client.get(&url);
274
+ for (name, value) in req_headers.iter() {
275
+ request = request.header(name.clone(), value.clone());
276
+ }
277
+
278
+ // --- send ---
279
+ let resp = match request.send().await {
280
+ Ok(resp) => resp,
281
+ Err(e) if attempt < MAX_ATTEMPTS && (e.is_request() || e.is_timeout() || e.is_connect()) => {
282
+ tokio::time::sleep(Duration::from_millis(500 * attempt as u64)).await;
283
+ continue;
236
284
  }
237
285
  Err(e) => {
238
- let error_msg = format!("Failed to decompress downloaded MAF file: {}", e);
239
- Err((url.clone(), error_msg))
286
+ break Err((url.clone(), format!("Server request failed: {}", format_error_chain(&e))));
240
287
  }
241
- },
242
- Ok(resp) => {
243
- let error_msg = format!("HTTP error: {}", resp.status());
244
- Err((url.clone(), error_msg))
245
- }
246
- Err(e) => {
247
- let error_msg = format!("Server request failed: {}", e);
248
- Err((url.clone(), error_msg))
288
+ };
289
+
290
+ // Capture response metadata before the body is consumed, for diagnostics.
291
+ let status = resp.status();
292
+ let version = format!("{:?}", resp.version());
293
+ let content_type = resp
294
+ .headers()
295
+ .get(reqwest::header::CONTENT_TYPE)
296
+ .and_then(|v| v.to_str().ok())
297
+ .unwrap_or("")
298
+ .to_string();
299
+ let content_encoding = resp
300
+ .headers()
301
+ .get(reqwest::header::CONTENT_ENCODING)
302
+ .and_then(|v| v.to_str().ok())
303
+ .unwrap_or("")
304
+ .to_string();
305
+
306
+ if !status.is_success() {
307
+ // non-2xx: a server-side decision, not transient - capture a snippet of the
308
+ // error body (often an HTML/JSON page from a proxy/WAF) and stop.
309
+ let body_snippet = match resp.text().await {
310
+ Ok(t) => t.chars().take(200).collect::<String>().escape_default().to_string(),
311
+ Err(_) => String::new(),
312
+ };
313
+ break Err((
314
+ url.clone(),
315
+ format!(
316
+ "HTTP error {} ({}, content-type '{}', content-encoding '{}'), body: {}",
317
+ status, version, content_type, content_encoding, body_snippet
318
+ ),
319
+ ));
249
320
  }
321
+
322
+ // --- read body ---
323
+ let content = match resp.bytes().await {
324
+ Ok(content) => content,
325
+ Err(_e) if attempt < MAX_ATTEMPTS => {
326
+ // transport/read failure AFTER headers (e.g. "connection closed before
327
+ // message completed") - typically transient, so retry the whole download
328
+ tokio::time::sleep(Duration::from_millis(500 * attempt as u64)).await;
329
+ continue;
330
+ }
331
+ Err(e) => {
332
+ break Err((
333
+ url.clone(),
334
+ format!(
335
+ "Failed to read response body (HTTP {}, {}, content-type '{}', content-encoding '{}'): {}",
336
+ status,
337
+ version,
338
+ content_type,
339
+ content_encoding,
340
+ format_error_chain(&e)
341
+ ),
342
+ ));
343
+ }
344
+ };
345
+
346
+ // --- decompress (not transient: do not retry) ---
347
+ let mut decoder = GzDecoder::new(&content[..]);
348
+ let mut decompressed_content = Vec::new();
349
+ break match decoder.read_to_end(&mut decompressed_content) {
350
+ Ok(_) => Ok((url.clone(), String::from_utf8_lossy(&decompressed_content).to_string())),
351
+ Err(e) => Err((
352
+ url.clone(),
353
+ format!(
354
+ "Failed to decompress MAF file (HTTP {}, {}, content-type '{}', content-encoding '{}', body {} bytes, first bytes {}): {}",
355
+ status,
356
+ version,
357
+ content_type,
358
+ content_encoding,
359
+ content.len(),
360
+ preview_bytes(&content),
361
+ e
362
+ ),
363
+ )),
364
+ };
250
365
  }
251
366
  }
252
367
  }));
@@ -264,7 +379,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
264
379
  }
265
380
 
266
381
  download_futures
267
- .buffer_unordered(20)
382
+ .buffer_unordered(concurrency)
268
383
  .for_each(|result| {
269
384
  let encoder = Arc::clone(&encoder); // Clone the Arc for each task
270
385
  let maf_col_cp = maf_col.clone();
@@ -316,3 +431,135 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
316
431
  io::stderr().flush().expect("Failed to flush stderr");
317
432
  Ok(())
318
433
  }
434
+
435
+ #[cfg(test)]
436
+ mod tests {
437
+ use super::*;
438
+
439
+ // ---- select_maf_col ----
440
+
441
+ // Minimal MAF-like content: a comment line, a header line (detected by the
442
+ // "Hugo_Symbol" substring), then data rows.
443
+ const MAF: &str = "# version 2.4\n\
444
+ Hugo_Symbol\tEntrez_Gene_Id\tChromosome\tStart_Position\n\
445
+ TP53\t7157\tchr17\t7577120\n\
446
+ KRAS\t3845\tchr12\t25398284\n";
447
+
448
+ fn cols(list: &[&str]) -> Vec<String> {
449
+ list.iter().map(|s| s.to_string()).collect()
450
+ }
451
+
452
+ #[test]
453
+ fn select_maf_col_selects_and_orders_requested_columns() {
454
+ // request a subset, in an order different from the file's column order
455
+ let (bytes, rows) = select_maf_col(MAF.to_string(), &cols(&["Chromosome", "Hugo_Symbol"]), "u").unwrap();
456
+ assert_eq!(rows, 2, "two data rows should be emitted");
457
+ assert_eq!(String::from_utf8(bytes).unwrap(), "chr17\tTP53\nchr12\tKRAS\n");
458
+ }
459
+
460
+ #[test]
461
+ fn select_maf_col_skips_comment_lines_and_header() {
462
+ // comment (#) and header lines must not be counted or emitted as data
463
+ let (bytes, rows) = select_maf_col(MAF.to_string(), &cols(&["Hugo_Symbol"]), "u").unwrap();
464
+ assert_eq!(rows, 2);
465
+ assert_eq!(String::from_utf8(bytes).unwrap(), "TP53\nKRAS\n");
466
+ }
467
+
468
+ #[test]
469
+ fn select_maf_col_errors_on_missing_column() {
470
+ let err = select_maf_col(MAF.to_string(), &cols(&["No_Such_Column"]), "the-url").unwrap_err();
471
+ assert_eq!(err.0, "the-url", "error should carry the url");
472
+ assert!(
473
+ err.1.contains("No_Such_Column"),
474
+ "error should name the missing column: {}",
475
+ err.1
476
+ );
477
+ }
478
+
479
+ #[test]
480
+ fn select_maf_col_header_only_yields_no_rows() {
481
+ let header_only = "Hugo_Symbol\tChromosome\n";
482
+ let (bytes, rows) = select_maf_col(header_only.to_string(), &cols(&["Hugo_Symbol"]), "u").unwrap();
483
+ assert_eq!(rows, 0);
484
+ assert!(bytes.is_empty());
485
+ }
486
+
487
+ // NOTE: a data row with fewer fields than the header currently panics
488
+ // (maf_cont_lst[*x] index out of bounds); that ragged-row hardening is
489
+ // deferred to Step 5, where a test for graceful handling should be added.
490
+
491
+ // ---- format_error_chain ----
492
+
493
+ #[derive(Debug)]
494
+ struct TestErr {
495
+ msg: String,
496
+ src: Option<Box<dyn std::error::Error + 'static>>,
497
+ }
498
+ impl std::fmt::Display for TestErr {
499
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
500
+ write!(f, "{}", self.msg)
501
+ }
502
+ }
503
+ impl std::error::Error for TestErr {
504
+ fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
505
+ self.src.as_deref()
506
+ }
507
+ }
508
+
509
+ #[test]
510
+ fn format_error_chain_single_error() {
511
+ let e = TestErr {
512
+ msg: "top".into(),
513
+ src: None,
514
+ };
515
+ assert_eq!(format_error_chain(&e), "top");
516
+ }
517
+
518
+ #[test]
519
+ fn format_error_chain_walks_source_chain() {
520
+ let root = TestErr {
521
+ msg: "root".into(),
522
+ src: None,
523
+ };
524
+ let middle = TestErr {
525
+ msg: "middle".into(),
526
+ src: Some(Box::new(root)),
527
+ };
528
+ let top = TestErr {
529
+ msg: "top".into(),
530
+ src: Some(Box::new(middle)),
531
+ };
532
+ assert_eq!(format_error_chain(&top), "top -> middle -> root");
533
+ }
534
+
535
+ // ---- preview_bytes ----
536
+
537
+ #[test]
538
+ fn preview_bytes_shows_gzip_magic() {
539
+ // gzip magic 1f 8b should be visible so we can tell gzip from plaintext
540
+ let out = preview_bytes(&[0x1f, 0x8b, 0x08, 0x00]);
541
+ assert!(out.contains("hex=[1f 8b 08 00]"), "got: {}", out);
542
+ }
543
+
544
+ #[test]
545
+ fn preview_bytes_renders_plaintext_in_ascii() {
546
+ let out = preview_bytes(b"Hugo_Symbol");
547
+ assert!(
548
+ out.contains("Hugo_Symbol"),
549
+ "ascii preview should show plaintext: {}",
550
+ out
551
+ );
552
+ }
553
+
554
+ #[test]
555
+ fn preview_bytes_truncates_to_64_bytes() {
556
+ // 100 'A' (0x41) bytes -> only 64 should be rendered in the hex section
557
+ let out = preview_bytes(&[0x41u8; 100]);
558
+ assert_eq!(
559
+ out.matches("41").count(),
560
+ 64,
561
+ "should cap the hex preview at 64 bytes: {}",
562
+ out
563
+ );
564
+ }
565
+ }