|
1 | 1 | import fs from 'fs/promises' |
| 2 | +import http from 'http' |
2 | 3 | import { test } from 'uvu' |
3 | 4 | import * as td from 'testdouble' |
4 | 5 | import * as assert from 'uvu/assert' |
@@ -362,6 +363,28 @@ const testsIconRegex = [ |
362 | 363 | '<link rel="shortcut icon" href="https://example.com/favicon.ico">', |
363 | 364 | 'https://example.com/favicon.ico', |
364 | 365 | ], |
| 366 | + // Unquoted href followed by additional attributes — the regex must |
| 367 | + // capture only the URL, not bleed across `>` or whitespace. |
| 368 | + [ |
| 369 | + '<link rel="icon" href=/foo.png class="x">', |
| 370 | + '/foo.png', |
| 371 | + ], |
| 372 | + [ |
| 373 | + '<link rel="icon" type="image/x-icon" href=/bar.ico />', |
| 374 | + '/bar.ico', |
| 375 | + ], |
| 376 | + // The leak we observed in cognano builds: nature.com served a link tag |
| 377 | + // whose captured href ended with `>` and the saved file became |
| 378 | + // `something.png>`, which then crashed sharp with "unsupported image |
| 379 | + // format". The fix tightens `[^"]+` URL captures to `[^"\s>]+`. |
| 380 | + [ |
| 381 | + '<link rel="icon" type="image/png" sizes="48x48" href=/static/favicon-48x48.png>', |
| 382 | + '/static/favicon-48x48.png', |
| 383 | + ], |
| 384 | + [ |
| 385 | + '<link rel="icon" href=/foo.svg/>', |
| 386 | + '/foo.svg', |
| 387 | + ], |
365 | 388 | ] |
366 | 389 | for (const t of testsIconRegex) { |
367 | 390 | const [tag, path] = t |
@@ -490,4 +513,136 @@ test('saveFile writeStream finish is awaited (file is complete on return)', asyn |
490 | 513 | assert.equal(stats.size, result.size, 'reported size should match actual file size') |
491 | 514 | }) |
492 | 515 |
|
| 516 | +// --- Stream hang regression tests --- |
| 517 | +// |
| 518 | +// Reproduce the deadlock observed in cognano builds: a remote server returns |
| 519 | +// response headers and a few bytes, then stops sending data. The current |
| 520 | +// `await res.end` in saveImage/saveFile hangs forever in this case, because |
| 521 | +// `req.setTimeout` aborts the request without emitting the 'end' event that |
| 522 | +// the awaited promise listens for. The lock around saveImage is therefore |
| 523 | +// never released and downstream workers deadlock. |
| 524 | +// |
| 525 | +// After the fix, saveImage/saveFile must reject within roughly the configured |
| 526 | +// timeout window (default 1500ms) instead of hanging. |
| 527 | + |
| 528 | +async function startStallingServer (): Promise<{ port: number, close: () => Promise<void> }> { |
| 529 | + const sockets: import('net').Socket[] = [] |
| 530 | + const server = http.createServer((_req, res) => { |
| 531 | + // Send headers and a few bytes, then deliberately never end. |
| 532 | + // No Content-Length, so the client cannot know the body is complete. |
| 533 | + res.writeHead(200, { 'Content-Type': 'image/png' }) |
| 534 | + res.write(Buffer.alloc(64, 0)) |
| 535 | + }) |
| 536 | + server.on('connection', (socket) => { sockets.push(socket) }) |
| 537 | + await new Promise<void>(resolve => server.listen(0, '127.0.0.1', resolve)) |
| 538 | + const addr = server.address() |
| 539 | + if (!addr || typeof addr === 'string') throw new Error('failed to bind test server') |
| 540 | + return { |
| 541 | + port: addr.port, |
| 542 | + close: async () => { |
| 543 | + for (const s of sockets) s.destroy() |
| 544 | + await new Promise<void>(resolve => server.close(() => resolve())) |
| 545 | + }, |
| 546 | + } |
| 547 | +} |
| 548 | + |
| 549 | +test('saveImage rejects (does not hang) when remote stalls mid-response', async () => { |
| 550 | + const { port, close } = await startStallingServer() |
| 551 | + // Unique URL per run so the existsSync fast-path in saveImage doesn't |
| 552 | + // bypass the download (which is what we are exercising). |
| 553 | + const url = `http://127.0.0.1:${port}/stall-img-${Date.now()}-${Math.random().toString(36).slice(2)}.png` |
| 554 | + try { |
| 555 | + const start = Date.now() |
| 556 | + const TEST_BUDGET = 8000 // generous: 1500ms ROTION_TIMEOUT default + slack |
| 557 | + let outcome: 'resolved' | 'rejected' | 'hang' = 'hang' |
| 558 | + |
| 559 | + await Promise.race([ |
| 560 | + files.saveImage(url, 'stall-test-img') |
| 561 | + .then(() => { outcome = 'resolved' }) |
| 562 | + .catch(() => { outcome = 'rejected' }), |
| 563 | + new Promise<void>(resolve => setTimeout(resolve, TEST_BUDGET)), |
| 564 | + ]) |
| 565 | + |
| 566 | + const elapsed = Date.now() - start |
| 567 | + assert.equal( |
| 568 | + outcome, |
| 569 | + 'rejected', |
| 570 | + `saveImage should reject when remote stalls (elapsed=${elapsed}ms, outcome=${outcome})`, |
| 571 | + ) |
| 572 | + assert.ok( |
| 573 | + elapsed < TEST_BUDGET, |
| 574 | + `saveImage should fail fast, took ${elapsed}ms (>= ${TEST_BUDGET}ms budget)`, |
| 575 | + ) |
| 576 | + } finally { |
| 577 | + await close() |
| 578 | + } |
| 579 | +}) |
| 580 | + |
| 581 | +test('saveFile rejects (does not hang) when remote stalls mid-response', async () => { |
| 582 | + const { port, close } = await startStallingServer() |
| 583 | + const url = `http://127.0.0.1:${port}/stall-file-${Date.now()}-${Math.random().toString(36).slice(2)}.bin` |
| 584 | + try { |
| 585 | + const start = Date.now() |
| 586 | + const TEST_BUDGET = 8000 |
| 587 | + let outcome: 'resolved' | 'rejected' | 'hang' = 'hang' |
| 588 | + |
| 589 | + await Promise.race([ |
| 590 | + files.saveFile(url, 'stall-test-file') |
| 591 | + .then(() => { outcome = 'resolved' }) |
| 592 | + .catch(() => { outcome = 'rejected' }), |
| 593 | + new Promise<void>(resolve => setTimeout(resolve, TEST_BUDGET)), |
| 594 | + ]) |
| 595 | + |
| 596 | + const elapsed = Date.now() - start |
| 597 | + assert.equal( |
| 598 | + outcome, |
| 599 | + 'rejected', |
| 600 | + `saveFile should reject when remote stalls (elapsed=${elapsed}ms, outcome=${outcome})`, |
| 601 | + ) |
| 602 | + assert.ok( |
| 603 | + elapsed < TEST_BUDGET, |
| 604 | + `saveFile should fail fast, took ${elapsed}ms (>= ${TEST_BUDGET}ms budget)`, |
| 605 | + ) |
| 606 | + } finally { |
| 607 | + await close() |
| 608 | + } |
| 609 | +}) |
| 610 | + |
| 611 | +// --- saveImage webp same-file regression test --- |
| 612 | +// |
| 613 | +// When the source URL ends in `.webp`, replaceExt(urlPath, '.webp') yields |
| 614 | +// the same path as filePath, so the conversion step |
| 615 | +// `sharp(filePath).webp().toFile(webpPath)` fails with |
| 616 | +// "Cannot use same file for input and output" and the function returns |
| 617 | +// without populated width/height. The fix short-circuits the re-encode |
| 618 | +// when the downloaded file is already at the target webp path. |
| 619 | + |
| 620 | +test('saveImage downloads .webp URL without "Cannot use same file" error', async () => { |
| 621 | + const sharp = (await import('sharp')).default |
| 622 | + const webpBuffer = await sharp({ |
| 623 | + create: { width: 12, height: 8, channels: 3, background: { r: 12, g: 34, b: 56 } }, |
| 624 | + }).webp().toBuffer() |
| 625 | + |
| 626 | + const server = http.createServer((_req, res) => { |
| 627 | + res.writeHead(200, { 'Content-Type': 'image/webp', 'Content-Length': String(webpBuffer.length) }) |
| 628 | + res.end(webpBuffer) |
| 629 | + }) |
| 630 | + await new Promise<void>(resolve => server.listen(0, '127.0.0.1', resolve)) |
| 631 | + const addr = server.address() |
| 632 | + if (!addr || typeof addr === 'string') throw new Error('failed to bind test server') |
| 633 | + const port = addr.port |
| 634 | + const url = `http://127.0.0.1:${port}/test-webp-${Date.now()}-${Math.random().toString(36).slice(2)}.webp` |
| 635 | + |
| 636 | + try { |
| 637 | + const ipws = await files.saveImage(url, 'webp-saveimage-test') |
| 638 | + assert.match(ipws.path, /^\/images\/.*\.webp$/, `expected .webp path, got: ${ipws.path}`) |
| 639 | + // The fix should still report metadata (width/height) instead of bailing |
| 640 | + // out after the sharp error. |
| 641 | + assert.equal(ipws.width, 12, `width should match source, got: ${ipws.width}`) |
| 642 | + assert.equal(ipws.height, 8, `height should match source, got: ${ipws.height}`) |
| 643 | + } finally { |
| 644 | + await new Promise<void>(resolve => server.close(() => resolve())) |
| 645 | + } |
| 646 | +}) |
| 647 | + |
493 | 648 | test.run() |
0 commit comments