@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.
- package/index.js +10 -4
- package/package.json +1 -1
- 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
|
-
|
|
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
|
-
//
|
|
197
|
-
//
|
|
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
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
|
|
260
|
+
let client = client.clone();
|
|
261
|
+
let req_headers = req_headers.clone();
|
|
207
262
|
async move {
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
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
|
-
|
|
239
|
-
Err((url.clone(), error_msg))
|
|
286
|
+
break Err((url.clone(), format!("Server request failed: {}", format_error_chain(&e))));
|
|
240
287
|
}
|
|
241
|
-
}
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
}
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
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(
|
|
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
|
+
}
|