Compare commits

...

12 Commits

Author SHA1 Message Date
Xavier Roche
935ea5a89b An LTO build collapses the installed-header symbol test's candidate list (#1246)
Ubuntu's 3.49.21-2 failed on four architectures (amd64, ppc64el, riscv64, s390x) with `207_install-headers-symbols.test` reporting "only 1 symbols reached the link probe, the candidate list is broken". Debian built the same source everywhere. Ubuntu compiles with `-flto=auto -ffat-lto-objects` by default and Debian does not.

The test picks its probe candidates by matching identifiers in the installed headers against the library's hidden symbol names. Under LTO, GCC renames a local to `abortf_.lto_priv.0`, the names stop matching, and the candidate list empties, so the `probed >= 5` floor fires. The floor was right; what fed it was broken. Stripping the clone suffixes before the comparison takes an `-O3 -flto` build from 1 candidate to 58, and an ordinary build from 32 to 65 by recovering its `.isra` and `.constprop` clones.

That floor turns out to be a weak detector on its own: a strip handling only `.lto_priv` still passed while losing 30 of 54 candidates. The canary header now declares one clone-only name per suffix and requires each to be reported, so dropping any single suffix fails the test.

Nothing in CI builds with LTO, which is how this reached the archive. The deb job runs inside debian:sid and gets Debian's flags; every other leg uses configure's defaults. The matrix gains an `-O3 -flto` leg.

Signed-off-by: Xavier Roche <roche@httrack.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-14 08:30:21 +00:00
Xavier Roche
3a0cf262d6 The wizard verdict switch is unreachable by any test (#1245)
The verdict half of a wizard answer moves into `hts_wizard_apply_verdict()`, leaving `hts_acceptlink_()` one call where a switch used to be. #1239 made the filter half a testable function and left this one unreachable: `hts_acceptlink_()` is static, and only the interactive `query3` callback gets there.

The self-test drives the new function against a real `httrackp`, so it asserts what an answer does to the crawl rather than what it means. An accepting answer must not clear a refusal the crawl already computed, answer 4 caps the recursion instead of deciding the link, `*` switches the wizard to automatic, and an answer in no known range only warns. Eight mutants each turn tests/293 red, including the three shapes the call site used to hide.

Behavior is unchanged for every int answer, both scope-range boundaries included. The dead commented-out blocks under answers 3 and 4 went with the move. The widened filter reservation is still untested: reaching it needs a crawl driving `query3`.

Signed-off-by: Xavier Roche <roche@httrack.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-14 10:22:44 +02:00
Xavier Roche
47a41fb39e webhttrack's browser dependency drags httrack toward autoremoval (#1235)
The autoremoval gatherer reads only the first alternative of a disjunction and never looks at Recommends, so webhttrack's `chromium | firefox-esr | www-browser` in Depends put the whole httrack source on chromium's RC bug clock. [#1128867](https://bugs.debian.org/1128867) has 3.49.14-1 marked for removal from testing on 9 September. Nothing here wants chromium in particular: `src/webhttrack.in` searches `x-www-browser`, then `www-browser`, then a dozen binaries by name.

#436 flipped this list the other way round in June, back when firefox-esr's RC bugs were doing the same to us, and six weeks later chromium got one. Whichever real browser leads the list is big and bug-prone, so the disjunction moves to Recommends, where the gatherer cannot follow it. Apt installs Recommends by default, so a normal install is unchanged. The `webhttrack` wrapper does still exit when it finds no browser (`src/webhttrack.in:97`), but the package also ships `htsserver`, which serves the same UI over HTTP to a browser on another machine, so the dependency is strong rather than absolute.

This only beats the removal if it is uploaded and migrates before 9 September. Testing is still on 3.49.14-1, with i386 and riscv64 at Needs-Build.

Signed-off-by: Xavier Roche <roche@httrack.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-14 07:10:54 +00:00
Xavier Roche
fabbf80ea5 The wizard can only answer about one exact host (#1239)
The interactive wizard could only answer about the exact host it asked
about, so a mirror of www.example.com stopped again at
download.example.com, and Skip All stayed unusable while any unknown
subdomain might still turn up. Reported against the GUI as
xroche/httrack-windows#96.

The engine now enumerates the domain scopes for a host and takes two new
answer ranges over them. hts_wizard_host_scope() does the splitting so a
front end never has to: it derives the scopes from the same question
string it was handed, which may carry a protocol, credentials and a port.
Answers HTS_WIZARD_SCOPE_INCLUDE+k and HTS_WIZARD_SCOPE_EXCLUDE+k then
take or drop the k-th scope. An index is only safe instead of a finished
pattern because of that helper: the menu label and the applied filter come
out of the same code and cannot drift.

Each answer emits two filters, since +*.example.co.uk/* misses the apex,
which also meant widening the filter-array reservation before the answer
switch. It made room for exactly one insert while HT_INSERT_FILTERS0
asserts on overflow, so the second insert would have aborted on the
boundary at filptr == maxfilter - 2. hts_wizard_scope_answer() carries the
range decision for both the filter and the verdict halves, so the two
cannot disagree.

No public suffix list is involved: every suffix down to a two-label domain
is offered and the user picks the boundary. A bare TLD is never offered,
an IP literal has no scopes, and a fully-qualified www.foo.com. stops at
foo.com. instead of offering the root label as com.

Only the CLI and WinHTTrack need the new menu entries. WebHTTrack answers
"" to every question and Android registers no query3 at all, which is
#1237.

Closes #1117

Signed-off-by: Xavier Roche <xroche@gmail.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-14 08:51:10 +02:00
Xavier Roche
af0e36e326 A -%F footer that exactly fills the page buffer aborts the crawl (#1241)
* A footer that exactly fills the page buffer aborts the crawl

#670 dropped an oversized footer instead of emitting an unterminated buffer,
but left the success path alone: it appends the closing newline with
strcatbuff, which aborts rather than clips, so the one expansion length that
fills tempo exactly has nowhere to put that byte. Reserve the newline before
formatting, so an expansion that no longer fits takes the existing drop path.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Xavier Roche <roche@httrack.com>

* Format the touched call to clang-format

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Xavier Roche <roche@httrack.com>

* Size the sweep's path instead of inheriting the temp one

The footer template caps at 253 chars, so the number of {path} references and
the literal padding both hang off the path's length. On the Windows runner the
temp directory is 80 chars, which pushed the template past the cap and failed
the test on its own guard.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Xavier Roche <roche@httrack.com>

* Sweep both line endings and assert the footer arrives whole

The fixture was LF-only, so strlen(eol) was always 1 and a fix reserving one
byte rather than the eol's length passed while CRLF pages still aborted. The
emitted length was unchecked too, so a clipping fix read as success.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Xavier Roche <roche@httrack.com>

* Fail on a temp path too long, and name the length that died

A skip would red the Windows leg anyway, since it compares the skip set
exactly, while silently dropping the coverage on every other platform. The
wider path leaves more room before that bites. The abort message named the
template, identical at every step, rather than the expansion that aborted.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Xavier Roche <roche@httrack.com>

---------

Signed-off-by: Xavier Roche <roche@httrack.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-14 01:14:40 +00:00
Xavier Roche
2dc30ab490 The default footer still uses the legacy %s form (#1240)
* Default footer uses named fields instead of %s

The named-field footer (-%F "{url}") has been the documented model since #667,
but the default template still carried the positional %s form, so any field a
user added to it was silently ignored: a "%s" anywhere selects legacy mode for
the whole string. WebHTTrack carried its own copy of that literal; it now takes
the engine macro.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Xavier Roche <roche@httrack.com>

* Point the WebHTTrack footer test at the macro's own comment

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Xavier Roche <roche@httrack.com>

---------

Signed-off-by: Xavier Roche <roche@httrack.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-14 00:55:41 +00:00
Xavier Roche
d681929d1a An abandoned WebHTTrack server never stops, holding the macOS app open (#1236)
* An abandoned WebHTTrack server never stops on its own

The watchdog only started consulting the heartbeat after the launcher died,
and on macOS the launcher waits on "open -W", which returns when the whole
browser quits. Closing the window therefore left htsserver running with the
app bundle open, so the disk image could not be ejected without a force kill.

Bind the session to the user's attention when idle and to the work when busy:
the page now says goodbye on pagehide, a running mirror vetoes every exit so a
crawl is never lost, and the launcher waits on the server rather than on the
browser. /ping was also the one reply served without cache headers, and
smallserver() returned 0 unconditionally, so every clean quit was reported as
"Unable to create the server".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Signed-off-by: Xavier Roche <roche@httrack.com>

* Keep the idle fallback at 120s and settle the test before judging survivors

A backgrounded tab has its timers throttled, so the fallback stays where it
was; promptness is the goodbye's job. The two servers that must stay alive
started after the one whose death ends the wait, so the test judged them
before either had outlived a timeout of its own.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Signed-off-by: Xavier Roche <roche@httrack.com>

* Report the killed server before the pings that failed because of it

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Signed-off-by: Xavier Roche <roche@httrack.com>

* Count windows instead of racing a browser's throttled timers

A single pending-goodbye deadline cannot tell "the last window left" from "one
of several left", so closing one of two windows ended a session the other still
had open: a hidden tab's timers are throttled to as little as one wake-up a
minute, and its cancelling ping arrived after the countdown had fired. Each page
now carries an id, pings under it and drops it on pagehide, and the server exits
when the last one is gone.

Liveness is counted in watchdog ticks rather than wall clock, so a suspended
laptop no longer ages a session out from under the user. A bare connection no
longer cancels a departure, since any local peer can open one. And a failed
thread spawn left commandRunning latched, which the new mirror veto would have
turned into a server that never exits.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Signed-off-by: Xavier Roche <roche@httrack.com>

* Take a farewell only from a request holding the session id

/ping is a GET, so it clears neither the session-id gate nor the Origin check,
both of which are POST-only. That was harmless while a heartbeat could only
extend a session; ending one on an unauthenticated request handed every local
process, and every page the user visits, a way to close someone's WebHTTrack.
A full window table now refuses newcomers instead of evicting, so a flood of
ids cannot push the real window out either.

Also from review: query_alnum_value left a half-read value in the caller's
buffer when it returned false, the new predicates and flags were plain ints in
a tree that spells booleans hts_boolean, and --ping-timeout re-parsed with the
bare sscanf that #614 cost us on --port.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Signed-off-by: Xavier Roche <roche@httrack.com>

* Prove a flood of window ids cannot evict the real one

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Signed-off-by: Xavier Roche <roche@httrack.com>

* Name the launcher's pid with a type MSVC has

pid_t is POSIX, and the old code only ever named it inside an #ifndef _WIN32
block. Hoisting the parent check into a function signature put it where Windows
compiles it, and webhttrack.vcxproj stopped at seven errors on one line.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Signed-off-by: Xavier Roche <roche@httrack.com>

---------

Signed-off-by: Xavier Roche <roche@httrack.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 14:09:21 +00:00
Xavier Roche
1483289eec debian: record the 3.49.21-2 upload (#1234)
The revision carried the hppa FTBFS fix to the buildds as a quilt patch against
the frozen orig. The patch itself stays out of git, where the fix is already in
the source; only the changelog entry belongs here, so the file stays continuous
for the next upload.

Signed-off-by: Xavier Roche <roche@httrack.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-13 09:14:28 +00:00
Xavier Roche
1751c4b593 mkdeb.sh reaches debsign before it discovers the key is wrong (#1233)
The key is only resolved when debsign runs, which on a release is after the
tarball has been built. Ask gpg for it at startup instead, and name the
0x-prefixed fingerprint form in the error, since a short id is what people reach
for first and it is the ambiguous one.

Signed-off-by: Xavier Roche <roche@httrack.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-13 06:21:17 +00:00
Xavier Roche
bad28b629c A lost Windows runner leaves almost no telemetry behind (#1229)
The suite watchdog's commit statuses are the only evidence that survives a lost runner, and most of it was being thrown away: they went to the PR merge commit, whose SHA GitHub collects, leaving 3 of 146 recorded kills with any telemetry at all. They now go to the PR head, which costs us a line in the PR's checks list.

Each status carries more too. Loop lag and the number of posts that failed since one landed separate a box that stopped from a network that broke; `n=`/`f=`/`e=` come from one `GetTcpIPv4Statistics` call; `m=`/`c=`/`a=` are summed from the process array already enumerated for `p=` and `h=`. The cadence halves to 15s, and the in-flight test name is clipped to 30 characters to pay for the new fields. The widest real line measures 103 of the 140 characters GitHub keeps.

The rest closes two unsound spots in the Windows kill paths, neither of which explains the deaths. `stop_server` re-read `/proc/<pid>/winpid` after signalling its target, so the number it handed to `taskkill /F /T` could already belong to a stranger, and a hosted runner reissues a freed PID within a second rather than in theory. Nothing checked the image before firing either. Both the winpid and the image are now read while the target is alive and checked against `tasklist`, with a warning annotation when they disagree, which is also the meter for how often a tree kill would have left our own trees.

Refs #1228.
2026-08-13 08:08:03 +02:00
Xavier Roche
6584fded15 mkdeb.sh regenerates an orig that debian/patches cannot apply to (#1232)
A quilt patch is written against the tarball it will be applied to. Once the fix
it backports is upstream, HEAD carries it too, so an orig regenerated from HEAD
makes the patch fail or apply with fuzz. Require --orig whenever the series file
is non-empty, unsigned builds included: this one breaks the build, not policy.

Signed-off-by: Xavier Roche <roche@httrack.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-13 05:43:13 +00:00
Xavier Roche
d8f0cec17d Three suite tests fail on an emulated buildd for being slow, not broken (#1231)
* Three suite tests fail on an emulated buildd for being slow, not broken

hppa reported 3.49.21-1 as Build-Attempted with 105, 151 and 269 red, all three
on wall clock under qemu-user: the guard's own diagnostics dump took 31s of a
30s bound, one configure run passed a hard 300s cap, and the pairwise header
sweep outran the 600s harness budget mid-batch.

Each now measures the property instead of the host. 105 times the guard to its
DUMP announcement, leaving the dump (minutes, emulated) out of the bound. 151
watches configure's output for silence rather than capping its total, and skips
when the budget runs out while it is still making progress. The sweep runs in
slices so it can be given up on, paces itself against what 269 hands it, and 269
declares the larger budget its n^2 compiles need, so the emulated leg still runs
it to completion instead of pacing out.

The emulated leg is the CI counterpart of that buildd and was green on the same
commit: it prints test-suite.log now, so a test that skipped rather than ran
stops reading as coverage.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Xavier Roche <roche@httrack.com>

* Fix the review's two skip-masking holes and the unbounded budget raise

A hung configure could reach the budget skip before the silence window closed, so
a wedge reported SKIP; the skip now needs a run that is still writing and enough
budget left for the detector to speak. 151 also paced inside run(), before
accept/reject read the verdict, so a configure that answered wrongly could exit 77
instead of 1: pacing moved to the callers, which judge first.

The per-test raise is bounded and normalized through one budget parser, since bash
test errors rather than compares past intmax and would have left the guard unarmed,
and a leading zero read as octal in arithmetic and decimal in test. Renamed
TEST_TIMEOUT_AT_LEAST so each use site carries the upwards-only rule, documented in
AGENTS.md, and the sweep takes an explicit --budget rather than sniffing the
environment, so the MSVC job cannot report a paced skip as a header break.

Tests for each: 151 drives run() through a configure_cmd seam with a child that
hangs and one that only crawls, 105 requires the DUMP announcement to be seen while
the guard runs (its fallback made the latency bound vacuous) and pins the hostile
budget values, and the sweep counts the units that reached the compiler.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Xavier Roche <roche@httrack.com>

* Two of the new assertions passed a mutant; make them bite

Mutation-testing the added tests found two that could not see the bug they were
written for. The verdict check stubbed run() out, so a pacer left inside the real
one stayed invisible: it now drives the real run() through a child that answers
wrongly with the budget spent. The announcement check only asked that the marker
appear while the guard ran, which a driver announcing after the dump still
satisfies: a slow ps widens the dump, and the marker must now precede it.

Writing the first exposed a third: reject takes one argument fewer than accept, so
the extra one reached run() as an env assignment and the child failed to exec,
which made the probe pass on the wrong answer.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Xavier Roche <roche@httrack.com>

* The verdict probe left the pacer disarmed

It numbered its run past the case count, so the pacer it exists to catch declined
to fire on a negative "steps left", and the mutant that puts pacing back inside
run() survived. Keep cases ahead of the run number.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Xavier Roche <roche@httrack.com>

* A killed configure left its child running and hung the macOS job

The macOS leg reported no test failure and then sat until its 20-minute step
timeout: bash 3.2 does not replace the subshell around the child with the child
itself, so killing that subshell left the run alive, and it outlived make check.
Give each run its own process group and kill the group.

The probe that exposed it now covers it: a child that spawns a child of its own,
and nothing of it left running afterwards.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Xavier Roche <roche@httrack.com>

---------

Signed-off-by: Xavier Roche <roche@httrack.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-13 01:01:27 +02:00
42 changed files with 1951 additions and 264 deletions

View File

@@ -18,18 +18,29 @@ concurrency:
jobs:
build:
name: build (${{ matrix.arch }}, ${{ matrix.cc }})
name: build (${{ matrix.arch }}, ${{ matrix.cc }}${{ matrix.label }})
runs-on: ${{ matrix.runner }}
strategy:
fail-fast: false
matrix:
# cflags is spelled out everywhere: an exported empty CFLAGS reads as
# "set" to configure, which then drops its own -g -O2.
include:
- { arch: x86-64, runner: ubuntu-24.04, cc: gcc }
- { arch: x86-64, runner: ubuntu-24.04, cc: clang }
- { arch: arm64, runner: ubuntu-24.04-arm, cc: gcc }
- { arch: arm64, runner: ubuntu-24.04-arm, cc: clang }
- { arch: x86-64, runner: ubuntu-24.04, cc: gcc, cflags: -g -O2 }
- { arch: x86-64, runner: ubuntu-24.04, cc: clang, cflags: -g -O2 }
- { arch: arm64, runner: ubuntu-24.04-arm, cc: gcc, cflags: -g -O2 }
- { arch: arm64, runner: ubuntu-24.04-arm, cc: clang, cflags: -g -O2 }
# Ubuntu builds httrack this way; no other leg here does.
- {
arch: x86-64,
runner: ubuntu-24.04,
cc: gcc,
label: " -O3 -flto",
cflags: -g -O3 -flto=auto -ffat-lto-objects,
}
env:
CC: ${{ matrix.cc }}
CFLAGS: ${{ matrix.cflags }}
steps:
- uses: actions/checkout@v7
with:

View File

@@ -270,8 +270,9 @@ jobs:
# Through the environment, never argv, which the process list exposes.
WATCHDOG_TOKEN: ${{ secrets.GITHUB_TOKEN }}
WATCHDOG_REPO: ${{ github.repository }}
# github.sha here is the PR's merge commit, so statuses posted against it stay out of the PR's checks UI.
WATCHDOG_SHA: ${{ github.sha }}
# The PR head, not github.sha: a merge commit is garbage-collected, and
# these statuses are the only trace a lost runner leaves (#1228).
WATCHDOG_SHA: ${{ github.event.pull_request.head.sha || github.sha }}
WATCHDOG_CONTEXT: windows-suite (${{ matrix.platform }}, ${{ matrix.configuration }})
WATCHDOG_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
run: |

View File

@@ -30,6 +30,11 @@ the operational checklist: toolchain, invariants, and how to ship a change.
check`, or `PATH="<bld>/src:$PATH"` for a manual run.
- Give new `.test` scripts `set -e`: the older ones predate the rule, so several
`local-crawl.sh` calls with no `set -e` report PASS on any non-last failure.
- Each test runs under a 600s wall-clock guard that reports a wedge as 124. A test
whose own work outlasts it raises the budget with a `# TEST_TIMEOUT_AT_LEAST: N`
line, at column 0 within its first 40 lines, and paces itself with
`skip_if_out_of_budget` so a host too slow to finish skips instead. The value only
ever raises the budget: nothing can disarm the guard.
- Run teardown with errexit off: `trap 'set +e; cleanup' EXIT`. Under `set -e` a
failing cleanup command becomes the test's exit status (#773). Keep the other
signals on their own `trap` line, or errexit stays off for the rest of the run.

10
debian/changelog vendored
View File

@@ -1,3 +1,13 @@
httrack (3.49.21-2) unstable; urgency=medium
* Fix the FTBFS on hppa: three suite tests measured the build host rather
than the property they cover, and failed on the qemu-user buildd purely
for being slower there. Patched from upstream, which now skips a step a
host is too slow to finish instead of failing the build
(skip-emulated-host-test-failures.patch).
-- Xavier Roche <xavier@debian.org> Thu, 13 Aug 2026 07:26:38 +0200
httrack (3.49.21-1) unstable; urgency=medium
* New upstream release: a site answering to several hostnames can now be

5
debian/control vendored
View File

@@ -29,7 +29,10 @@ Description: Copy websites to your computer (Offline browser)
Package: webhttrack
Architecture: any
Multi-Arch: foreign
Depends: ${misc:Depends}, ${shlibs:Depends}, webhttrack-common, sensible-utils, chromium | firefox-esr | www-browser
Depends: ${misc:Depends}, ${shlibs:Depends}, webhttrack-common, sensible-utils
# Recommends, not Depends: the autoremoval gatherer follows only a disjunction's
# first alternative, which ties the httrack source to whichever browser leads it.
Recommends: firefox-esr | chromium | www-browser
Replaces: webhttrack-common (<< 3.43.9-2)
Breaks: webhttrack-common (<< 3.43.9-2)
Suggests: httrack, httrack-doc

View File

@@ -1,26 +1,85 @@
// Function aimed to ping the webhttrack server regularly to keep it alive
// If the browser window is closed, the server will eventually shutdown
function ping_server() {
var iframe = document.getElementById('pingiframe');
if (iframe && iframe.src) {
iframe.src = iframe.src;
setTimeout(ping_server, 30000);
}
// Tell the server this window is alive, so an abandoned server stops instead of
// outliving the session. The period is the one htsweb.c sizes its timeout from.
var PING_PERIOD = 5000;
// Identifies this window for as long as it is open. The server counts windows,
// so closing one of two must not read as the session ending.
var PING_WINDOW =
String(Math.random()).replace(/[^0-9]/g, "") + String(new Date().getTime());
function ping_url(extra) {
// Unique, or a cached response would never reach the server again.
return "/ping?w=" + PING_WINDOW + "&t=" + new Date().getTime() +
(extra ? "&" + extra : "");
}
// Create an invisible iframe to hold the server ping result
// Only modern browsers will support that, but old browsers are compatible
// with the legacy "wait for browser PID" mode
if (document && document.createElement && document.body
&& document.body.appendChild && document.getElementById) {
var iframe = document.createElement('iframe');
if (iframe) {
iframe.id = 'pingiframe';
iframe.style.display = "none";
iframe.style.visibility = "hidden";
iframe.width = iframe.height = 0;
iframe.src = "/ping";
document.body.appendChild(iframe);
ping_server();
}
// An iframe is the fallback only: reassigning its src can push a history entry,
// which would turn the Back button into a walk through past heartbeats.
function ping_send(url) {
if (window.fetch) {
fetch(url, {cache : "no-store"});
return true;
}
var iframe = document.getElementById('pingiframe');
if (!iframe) {
return false;
}
iframe.src = url;
return true;
}
function ping_server() {
if (ping_send(ping_url())) {
setTimeout(ping_server, PING_PERIOD);
}
}
// The session id this page carries, empty on the few pages that hold no form.
function ping_sid() {
var f = document.getElementsByName('sid');
return f && f.length ? f[0].value : "";
}
// Closing the window is the common case, and waiting out the timeout for it
// would hold the server open long after the user considers it gone. The server
// takes this only from a request holding the session id, so it goes as a POST;
// a page without one falls back to the timeout.
function ping_leaving() {
var sid = ping_sid();
if (!sid) {
return;
}
var url = ping_url("e=bye");
var body = "sid=" + encodeURIComponent(sid);
var type = "application/x-www-form-urlencoded";
if (navigator.sendBeacon) {
navigator.sendBeacon(url, new Blob([ body ], {type : type}));
} else if (window.XMLHttpRequest) {
// Synchronous: the page is going away, and an async send dies with it.
var x = new XMLHttpRequest();
x.open("POST", url, false);
x.setRequestHeader("Content-Type", type);
x.send(body);
}
}
// Old browsers reach none of this and stay on the legacy "wait for the launcher
// to die" mode.
if (document && document.createElement && document.body &&
document.body.appendChild && document.getElementById) {
if (!window.fetch) {
var iframe = document.createElement('iframe');
if (iframe) {
iframe.id = 'pingiframe';
iframe.style.display = "none";
iframe.style.visibility = "hidden";
iframe.width = iframe.height = 0;
document.body.appendChild(iframe);
}
}
ping_server();
// pagehide, not unload: Safari's back/forward cache never fires unload.
if (window.addEventListener) {
window.addEventListener('pagehide', ping_leaving, false);
}
}

View File

@@ -146,6 +146,13 @@ typedef const char *(*t_hts_htmlcheck_query3)(t_hts_callbackarg *carg,
httrackp *opt,
const char *question);
/* query3 answers HTS_WIZARD_SCOPE_INCLUDE+k and HTS_WIZARD_SCOPE_EXCLUDE+k take
or drop the k-th host scope hts_wizard_host_scope() enumerates. The stride
outruns any hostname's label count, so it cannot collide with the plain
single-digit answers. */
#define HTS_WIZARD_SCOPE_INCLUDE 1000
#define HTS_WIZARD_SCOPE_EXCLUDE 2000
/* Per-tick progress hook: 'back' is the transfer slot array of 'back_max'
entries, back_index the active one; lien_tot/lien_ntot and stats report
queue size and running totals, stat_time the elapsed time. */

View File

@@ -221,9 +221,11 @@ Please visit our Website: http://www.httrack.com
/* Copyright (C) 1998 Xavier Roche and other contributors */
#define HTTRACK_AFF_AUTHORS "[XR&CO'2014]"
/* Named fields (hts_footer_format); a "%s" anywhere would switch the template
back to the legacy positional model, a user's own additions included. */
#define HTS_DEFAULT_FOOTER \
"<!-- Mirrored from %s%s by HTTrack Website Copier/" HTTRACK_AFF_VERSION \
" " HTTRACK_AFF_AUTHORS ", %s -->"
"<!-- Mirrored from {url} by HTTrack Website Copier/" HTTRACK_AFF_VERSION \
" " HTTRACK_AFF_AUTHORS ", {date} -->"
/* Honest crawler User-Agent; no fake OS/browser to go stale. */
#define HTS_DEFAULT_USER_AGENT \
"Mozilla/5.0 (compatible; HTTrack/" HTTRACK_AFF_VERSION \

View File

@@ -4136,6 +4136,19 @@ hts_boolean hts_is_control_free(const char *str) {
return hts_is_control_free_sized(str, strlen(str));
}
hts_boolean hts_host_is_ipv4(const char *host, size_t len) {
size_t i;
int dots = 0;
for (i = 0; i < len; i++) {
if (host[i] == '.')
dots++;
else if (host[i] < '0' || host[i] > '9')
return HTS_FALSE;
}
return dots > 0 ? HTS_TRUE : HTS_FALSE;
}
hts_boolean hts_proxy_is_socks(const char *name) {
if (name == NULL)
return HTS_FALSE;

View File

@@ -255,6 +255,10 @@ hts_boolean hts_is_control_free_sized(const char *str, size_t len);
/* Same over a NUL-terminated string. */
hts_boolean hts_is_control_free(const char *str);
/* TRUE if host[0..len) is an IPv4 literal: digits and dots, at least one dot.
Such a host has no domain structure to reverse or to widen into. */
hts_boolean hts_host_is_ipv4(const char *host, size_t len);
/* TRUE if this -P proxy name (which keeps its scheme) is a SOCKS5 proxy. */
hts_boolean hts_proxy_is_socks(const char *name);

View File

@@ -874,14 +874,14 @@ int htsparse(htsmoduleStruct * str, htsmoduleStructExtended * stre) {
tempo[0] = '\0';
strcatbuff(tempo, eol);
// hts_footer_format returns <0 on overflow, leaving tempo
// unterminated; emitting it would abort in strcatbuff
// below.
if (hts_footer_format(tempo + strlen(tempo),
sizeof(tempo) - strlen(tempo),
StringBuff(opt->footer), fields,
sizeof(fields) / sizeof(fields[0])) >=
0) {
// Overflow (<0) leaves tempo unterminated, and the closing
// eol is reserved below because strcatbuff aborts rather
// than clips: either one would kill the crawl.
if (hts_footer_format(
tempo + strlen(tempo),
sizeof(tempo) - strlen(tempo) - strlen(eol),
StringBuff(opt->footer), fields,
sizeof(fields) / sizeof(fields[0])) >= 0) {
strcatbuff(tempo, eol);
HT_ADD(tempo);
}

View File

@@ -4112,25 +4112,27 @@ static int st_hashkey_bounds(httrackp *opt, int argc, char **argv) {
return 0;
}
/* Prints the filter answer <n> emits for (adr, fil) [up]; with no arguments,
asserts every answer against its expected pattern (#1119). */
/* Prints the filter answer <n> emits for (adr, fil) [up] in [slot]; with no
arguments, asserts every answer against its expected pattern (#1119). */
static int st_wizardfilter(httrackp *opt, int argc, char **argv) {
char pattern[HTS_URLMAXSIZE * 2];
htsbuff f = htsbuff_array(pattern);
(void) opt;
if (argc >= 3) {
hts_wizard_answer_filter(&f, atoi(argv[0]), argv[1], argv[2],
argc >= 4 && atoi(argv[3]) != 0 ? HTS_TRUE
: HTS_FALSE);
hts_wizard_answer_filter(
&f, argc >= 5 ? atoi(argv[4]) : 0, atoi(argv[0]), argv[1], argv[2],
argc >= 4 && atoi(argv[3]) != 0 ? HTS_TRUE : HTS_FALSE);
printf("%s\n", pattern);
return 0;
}
#define EMITS(n, adr, fil, up, expect) \
#define EMITS_SLOT(slot, n, adr, fil, up, expect) \
do { \
hts_wizard_answer_filter(&f, (n), (adr), (fil), (up)); \
hts_wizard_answer_filter(&f, (slot), (n), (adr), (fil), (up)); \
assertf(strcmp(pattern, (expect)) == 0); \
} while (0)
#define EMITS(n, adr, fil, up, expect) \
EMITS_SLOT(0, (n), (adr), (fil), (up), (expect))
/* the host-wide answers: 2 forbids, 5 (allowed to go up) and 6 authorize */
EMITS(2, "foo.com", "/index.html", HTS_FALSE, "-foo.com/*");
@@ -4171,11 +4173,252 @@ static int st_wizardfilter(httrackp *opt, int argc, char **argv) {
EMITS(3, "foo.com", "/x", HTS_FALSE, "");
EMITS(4, "foo.com", "/x", HTS_FALSE, "");
EMITS(50, "foo.com", "/x", HTS_FALSE, "");
/* only slot 0 is ever filled outside the host-scope answers */
EMITS_SLOT(1, 2, "foo.com", "/x", HTS_FALSE, "");
EMITS_SLOT(1, 6, "foo.com", "/x", HTS_FALSE, "");
/* the host-scope answers (#1117): both slots, the starred one missing the
apex is why the second exists */
#define SCOPE_IN HTS_WIZARD_SCOPE_INCLUDE
#define SCOPE_EX HTS_WIZARD_SCOPE_EXCLUDE
EMITS_SLOT(0, SCOPE_IN, "www.example.co.uk", "/x", HTS_FALSE,
"+*.www.example.co.uk/*");
EMITS_SLOT(1, SCOPE_IN, "www.example.co.uk", "/x", HTS_FALSE,
"+www.example.co.uk/*");
EMITS_SLOT(0, SCOPE_IN + 1, "www.example.co.uk", "/x", HTS_FALSE,
"+*.example.co.uk/*");
EMITS_SLOT(1, SCOPE_IN + 1, "www.example.co.uk", "/x", HTS_FALSE,
"+example.co.uk/*");
EMITS_SLOT(0, SCOPE_EX + 1, "www.example.co.uk", "/x", HTS_FALSE,
"-*.example.co.uk/*");
EMITS_SLOT(1, SCOPE_EX + 1, "www.example.co.uk", "/x", HTS_FALSE,
"-example.co.uk/*");
/* the port rides along, the credentials do not */
EMITS_SLOT(0, SCOPE_IN + 1, "www.foo.com:8080", "/x", HTS_FALSE,
"+*.foo.com:8080/*");
EMITS_SLOT(1, SCOPE_IN, "user:pass@www.foo.com", "/x", HTS_FALSE,
"+www.foo.com/*");
/* past the last scope, and slot 2, emit nothing */
EMITS_SLOT(0, SCOPE_IN + 2, "www.foo.com", "/x", HTS_FALSE, "");
EMITS_SLOT(2, SCOPE_IN, "www.foo.com", "/x", HTS_FALSE, "");
/* what the pair must and must not catch */
EMITS_SLOT(0, SCOPE_IN + 1, "www.example.co.uk", "/x", HTS_FALSE,
"+*.example.co.uk/*");
assertf(strjoker("a.b.example.co.uk/x", pattern + 1, NULL, NULL) != NULL);
assertf(strjoker("example.co.uk/x", pattern + 1, NULL, NULL) == NULL);
assertf(strjoker("notexample.co.uk/x", pattern + 1, NULL, NULL) == NULL);
assertf(strjoker("example.co.uk.evil.com/x", pattern + 1, NULL, NULL) ==
NULL);
EMITS_SLOT(1, SCOPE_IN + 1, "www.example.co.uk", "/x", HTS_FALSE,
"+example.co.uk/*");
assertf(strjoker("example.co.uk/x", pattern + 1, NULL, NULL) != NULL);
assertf(strjoker("notexample.co.uk/x", pattern + 1, NULL, NULL) == NULL);
#undef SCOPE_IN
#undef SCOPE_EX
#undef EMITS_SLOT
#undef EMITS
printf("wizardfilter self-test OK\n");
return 0;
}
/* Prints the domain scopes offered for <question>; with no argument, asserts
the enumeration (#1117). */
static int st_wizardscope(httrackp *opt, int argc, char **argv) {
char scope[HTS_URLMAXSIZE];
int k;
(void) opt;
if (argc >= 1) {
for (k = 0; hts_wizard_host_scope(argv[0], k, scope, sizeof(scope)); k++)
printf("%d %s\n", k, scope);
return 0;
}
#define SCOPE(question, k, expect) \
do { \
assertf(hts_wizard_host_scope((question), (k), scope, sizeof(scope))); \
assertf(strcmp(scope, (expect)) == 0); \
} while (0)
/* poisoned first: comparing against '\0' cannot see a clear that never ran */
#define NOSCOPE_SIZED(question, k, size) \
do { \
memset(scope, 'X', sizeof(scope)); \
assertf(!hts_wizard_host_scope((question), (k), scope, (size))); \
assertf(scope[0] == '\0'); \
} while (0)
#define NOSCOPE(question, k) NOSCOPE_SIZED((question), (k), sizeof(scope))
/* k widens by one label at a time, starting at the host itself */
SCOPE("download.example.co.uk/x", 0, "download.example.co.uk");
SCOPE("download.example.co.uk/x", 1, "example.co.uk");
SCOPE("download.example.co.uk/x", 2, "co.uk");
NOSCOPE("download.example.co.uk/x", 3); /* "uk" is a bare TLD */
SCOPE("example.com", 0, "example.com"); /* an adr with no fil works */
NOSCOPE("example.com", 1);
NOSCOPE("localhost/x", 0); /* nothing to widen into */
NOSCOPE("download.example.co.uk/x", -1);
/* protocol and credentials are stripped, the port kept */
SCOPE("ftp://user:pass@www.foo.com/x", 1, "foo.com");
SCOPE("www.foo.com:8080/x", 0, "www.foo.com:8080");
SCOPE("www.foo.com:8080/x", 1, "foo.com:8080");
NOSCOPE("www.foo.com:8080/x", 2);
/* a path that carries dots or a colon must not be read as host labels */
SCOPE("foo.com/a.b.c/d:e", 0, "foo.com");
NOSCOPE("foo.com/a.b.c/d:e", 1);
/* the shared predicate: a dotless run of digits is a hostname, not an IP */
assertf(hts_host_is_ipv4("1.2.3.4", 7));
assertf(!hts_host_is_ipv4("12345", 5));
assertf(!hts_host_is_ipv4("foo.com", 7));
/* an IP literal splits on dots without being a domain */
NOSCOPE("192.168.1.1/x", 0);
NOSCOPE("192.168.1.1:8080/x", 0);
NOSCOPE("[3ffe:b80:1234::1]/x", 0);
/* the dots inside this one reach the label walk unless brackets are refused
*/
NOSCOPE("[::ffff:1.2.3.4]/x", 0);
/* the root label of a fully-qualified host is not a label */
SCOPE("www.foo.com./x", 0, "www.foo.com.");
SCOPE("www.foo.com./x", 1, "foo.com.");
NOSCOPE("www.foo.com./x", 2); /* "com." is still a bare TLD */
NOSCOPE(".", 0);
/* the destination must fit the scope and its terminator, and never truncate
*/
{
const char *q = "www.example.com/x";
const size_t need = strlen("www.example.com");
memset(scope, 'X', sizeof(scope));
assertf(hts_wizard_host_scope(q, 0, scope, need + 1));
assertf(strcmp(scope, "www.example.com") == 0);
NOSCOPE_SIZED(q, 0, need);
NOSCOPE_SIZED(q, 0, 4);
NOSCOPE_SIZED(q, 0, 1);
}
#undef SCOPE
#undef NOSCOPE
#undef NOSCOPE_SIZED
printf("wizardscope self-test OK\n");
return 0;
}
/* #1117: which host-scope range an answer falls in. */
static int st_wizardscopeanswer(httrackp *opt, int argc, char **argv) {
(void) opt;
(void) argc;
(void) argv;
#define ANSWER(n, expect) assertf(hts_wizard_scope_answer(n) == (expect))
/* the plain answers, and the boundary just below the first range */
ANSWER(-999, HTS_DEFAULT);
ANSWER(-1, HTS_DEFAULT);
ANSWER(0, HTS_DEFAULT);
ANSWER(7, HTS_DEFAULT);
ANSWER(50, HTS_DEFAULT);
ANSWER(HTS_WIZARD_SCOPE_INCLUDE - 1, HTS_DEFAULT);
/* include runs up to the exclude base, and exclude has no upper end */
ANSWER(HTS_WIZARD_SCOPE_INCLUDE, HTS_FALSE);
ANSWER(HTS_WIZARD_SCOPE_EXCLUDE - 1, HTS_FALSE);
ANSWER(HTS_WIZARD_SCOPE_EXCLUDE, HTS_TRUE);
ANSWER(INT_MAX, HTS_TRUE);
#undef ANSWER
printf("wizardscopeanswer self-test OK\n");
return 0;
}
/* Poison: comparing the recursion cap against 0 would not see a stray write of
the level the crawl uses. */
#define PRIO_UNSET 42
/* Prints what answer `n` does to the crawl; with no arguments, asserts every
answer, on both an allowed and an already refused link. */
static int st_wizardverdict(httrackp *opt, int argc, char **argv) {
const hts_wizard asked = opt->wizard;
FILE *const projectlog = opt->log;
char line[HTS_URLMAXSIZE];
FILE *log;
int url, depth;
url = 0;
depth = PRIO_UNSET;
opt->wizard = HTS_WIZARD_ASK;
if (argc >= 1) {
hts_wizard_apply_verdict(opt, atoi(argv[0]), "foo.com", "/a/b.html", &url,
&depth);
printf("forbidden=%d stop=%d prio=%d\n", url,
opt->wizard == HTS_WIZARD_AUTO, depth);
opt->wizard = asked;
return 0;
}
opt->log = NULL; /* the battery walks the answers that warn */
/* answer `n` over a link the crawl had left at `in`: the verdict it must leave,
whether it stops the questions, and the recursion cap it must set. */
#define APPLIES(n, in, forbidden, stop, prio) \
do { \
url = (in); \
depth = PRIO_UNSET; \
opt->wizard = HTS_WIZARD_ASK; \
hts_wizard_apply_verdict(opt, (n), "foo.com", "/a/b.html", &url, &depth); \
assertf(url == (forbidden)); \
assertf(opt->wizard == ((stop) ? HTS_WIZARD_AUTO : HTS_WIZARD_ASK)); \
assertf(depth == (prio)); \
} while (0)
/* '*' refuses and stops the questions */
APPLIES(-1, 0, 1, 1, PRIO_UNSET);
APPLIES(-1, 1, 1, 1, PRIO_UNSET);
/* the refusing answers, 3 included although it emits no filter yet */
APPLIES(0, 0, 1, 0, PRIO_UNSET);
APPLIES(1, 0, 1, 0, PRIO_UNSET);
APPLIES(2, 0, 1, 0, PRIO_UNSET);
APPLIES(3, 0, 1, 0, PRIO_UNSET);
/* 4 caps the recursion and decides the link neither way */
APPLIES(4, 0, 0, 0, 1);
APPLIES(4, 1, 1, 0, 1);
/* an accepting answer never clears a refusal the crawl already computed */
APPLIES(5, 1, 1, 0, PRIO_UNSET);
APPLIES(6, 1, 1, 0, PRIO_UNSET);
APPLIES(7, 1, 1, 0, PRIO_UNSET);
APPLIES(50, 1, 1, 0, PRIO_UNSET);
APPLIES(-999, 1, 1, 0, PRIO_UNSET);
APPLIES(6, 0, 0, 0, PRIO_UNSET);
/* both ends of each scope range: include allows, exclude forbids */
APPLIES(HTS_WIZARD_SCOPE_INCLUDE, 0, 0, 0, PRIO_UNSET);
APPLIES(HTS_WIZARD_SCOPE_EXCLUDE - 1, 0, 0, 0, PRIO_UNSET);
APPLIES(HTS_WIZARD_SCOPE_EXCLUDE, 0, 1, 0, PRIO_UNSET);
APPLIES(INT_MAX, 0, 1, 0, PRIO_UNSET);
/* an answer in no range is not taken for an accept, nor for a refusal */
APPLIES(8, 0, 0, 0, PRIO_UNSET);
APPLIES(8, 1, 1, 0, PRIO_UNSET);
APPLIES(999, 0, 0, 0, PRIO_UNSET);
APPLIES(-2, 0, 0, 0, PRIO_UNSET);
APPLIES(-1000, 0, 0, 0, PRIO_UNSET);
APPLIES(INT_MIN, 0, 0, 0, PRIO_UNSET);
APPLIES(HTS_WIZARD_SCOPE_INCLUDE - 1, 0, 0, 0, PRIO_UNSET);
#undef APPLIES
/* the warning is all an unknown answer does, so a known one must be silent */
log = tmpfile();
assertf(log != NULL);
opt->log = log;
hts_wizard_apply_verdict(opt, 6, "foo.com", "/a/b.html", &url, &depth);
hts_wizard_apply_verdict(opt, 8, "foo.com", "/a/b.html", &url, &depth);
rewind(log);
assertf(fgets(line, (int) sizeof(line), log) != NULL);
assertf(strstr(line, "unknown answer 8") != NULL);
assertf(fgets(line, (int) sizeof(line), log) == NULL);
fclose(log);
opt->log = projectlog;
opt->wizard = asked;
printf("wizardverdict self-test OK\n");
return 0;
}
#undef PRIO_UNSET
/* #159: hts_redirect_same_savefile decides whether a redirect is a same-file
* alias. */
static int st_redirect_samefile(httrackp *opt, int argc, char **argv) {
@@ -9687,8 +9930,14 @@ static const struct selftest_entry {
st_hashkey_bounds},
{"redirect-samefile", "", "same-file redirect detection self-test (#159)",
st_redirect_samefile},
{"wizardfilter", "[<answer> <adr> <fil> [up]]",
{"wizardfilter", "[<answer> <adr> <fil> [up [slot]]]",
"filter emitted by a wizard answer", st_wizardfilter},
{"wizardscope", "[<question>]",
"domain scopes the wizard can offer for a host", st_wizardscope},
{"wizardscopeanswer", "", "host-scope range of a wizard answer",
st_wizardscopeanswer},
{"wizardverdict", "[<answer>]", "what a wizard answer applies",
st_wizardverdict},
{"mime", "<filename>", "MIME type for a filename", st_mime},
{"charset", "<charset> <hex:..|string>",
"convert a string to UTF-8 from a charset", st_charset},

View File

@@ -93,9 +93,16 @@ int commandReturnSet = 0;
httrackp *global_opt = NULL;
static void (*pingFun)(void*) = NULL;
static void (*pingFun)(void *, smallserver_client_event, const char *) = NULL;
static void* pingFunArg = NULL;
/* Report a client liveness event, if anybody is listening. */
static void client_event(smallserver_client_event ev, const char *window) {
if (pingFun != NULL) {
pingFun(pingFunArg, ev, window);
}
}
/* Extern */
extern void webhttrack_main(char *cmd);
extern void webhttrack_lock(void);
@@ -385,6 +392,43 @@ static void copy_header_value(char *dst, size_t size, const char *value) {
strlncatbuff(dst, value, size, size - 1);
}
/** Copy query parameter "name"'s alphanumeric value into dst; true when a
non-empty one fit, and dst is left empty otherwise. Query-string counterpart
to the POST-body checker below. */
static hts_boolean query_alnum_value(char *dst, size_t size, const char *query,
const char *name) {
const size_t namelen = strlen(name);
const char *s = query;
dst[0] = '\0';
while (*s != '\0') {
const char *const amp = strchr(s, '&');
if (strncmp(s, name, namelen) == 0 && s[namelen] == '=') {
const char *v = s + namelen + 1;
size_t n = 0;
while (*v != '\0' && *v != '&' && n + 1 < size &&
isalnum((unsigned char) *v)) {
dst[n++] = *v++;
}
dst[n] = '\0';
/* Truncated, or not alphanumeric to its end, is no value at all: it must
not reach a caller that trusted the return. */
if (n > 0 && (*v == '\0' || *v == '&')) {
return HTS_TRUE;
}
dst[0] = '\0';
return HTS_FALSE;
}
if (amp == NULL) {
break;
}
s = amp + 1;
}
return HTS_FALSE;
}
/** Does the urlencoded request body present the expected session id?
True only if at least one "sid" field is present and every occurrence
matches, so it holds whichever one a later last-write-wins parse keeps.
@@ -686,8 +730,7 @@ int smallserver(T_SOC soc, char *url, char *method, char *data, char *path) {
};
initStrElt initStr[] = {
{"user", HTS_DEFAULT_USER_AGENT},
{"footer", "<!-- Mirrored from %s%s by HTTrack Website Copier/3.x "
"[XR&CO'2014], %s -->"},
{"footer", HTS_DEFAULT_FOOTER},
{"url2",
"+*.png +*.gif +*.jpg +*.jpeg +*.css +*.js -ad.doubleclick.net/*"},
{NULL, NULL}};
@@ -723,6 +766,8 @@ int smallserver(T_SOC soc, char *url, char *method, char *data, char *path) {
LLint length = 0;
const char *error_redirect = NULL;
hts_boolean denied = HTS_FALSE;
/* The request proved it holds the session id. */
hts_boolean authed = HTS_FALSE;
char origin[256];
char host[256];
@@ -753,9 +798,7 @@ int smallserver(T_SOC soc, char *url, char *method, char *data, char *path) {
while((soc_c = (T_SOC) accept(soc, NULL, NULL)) == INVALID_SOCKET) ;
/* Ping */
if (pingFun != NULL) {
pingFun(pingFunArg);
}
client_event(SMALLSERVER_CLIENT_REQUEST, NULL);
/* Lock */
webhttrack_lock();
@@ -865,6 +908,8 @@ int smallserver(T_SOC soc, char *url, char *method, char *data, char *path) {
buffer[0] = '\0';
meth = 0;
denied = HTS_TRUE;
} else {
authed = HTS_TRUE;
}
}
@@ -1158,6 +1203,7 @@ int smallserver(T_SOC soc, char *url, char *method, char *data, char *path) {
if (url && *++url == '/' && (pos = strchr(url, ' ')) && !(*pos = '\0')) {
char fsfile[1024];
const char *file;
const char *query = "";
FILE *fp;
char *qpos;
@@ -1166,6 +1212,7 @@ int smallserver(T_SOC soc, char *url, char *method, char *data, char *path) {
if (error_redirect == NULL) {
if ((qpos = strchr(url, '?'))) {
*qpos = '\0';
query = qpos + 1;
}
if (strcmp(url, "/") == 0) {
file = "/server/index.html";
@@ -1790,13 +1837,32 @@ int smallserver(T_SOC soc, char *url, char *method, char *data, char *path) {
}
}
fclose(fp);
} else if (strcmp(file, "/ping") == 0 ||
strncmp(file, "/ping?", 6) == 0) {
} else if (strcmp(file, "/ping") == 0) {
/* A cached heartbeat would never reach us again, and silence is
what the watchdog reads as a dead window. */
char error_hdr[] =
"HTTP/1.0 200 Pong\r\n" "Server: httrack small server\r\n"
"Content-type: text/html\r\n";
"HTTP/1.0 200 Pong\r\n"
"Server: httrack small server\r\n"
"Content-type: text/html\r\n"
"Cache-Control: no-cache, must-revalidate, private\r\n"
"Pragma: no-cache\r\n";
char window[SMALLSERVER_WINDOW_ID_MAX + 1];
StringCat(headers, error_hdr);
if (query_alnum_value(window, sizeof(window), query, "w")) {
char verb[SMALLSERVER_WINDOW_ID_MAX + 1];
/* Ending a session is a command, so it carries the session id
like every other one. A heartbeat can only extend a life, and
any local peer or visited page can send one of those. */
client_event(
authed && query_alnum_value(verb, sizeof(verb), query, "e") &&
strcmp(verb, "bye") == 0
? SMALLSERVER_CLIENT_LEAVING
: SMALLSERVER_CLIENT_PING,
window);
}
} else {
char error_hdr[] =
"HTTP/1.0 404 Not Found\r\n" "Server: httrack small server\r\n"
@@ -1873,6 +1939,10 @@ int smallserver(T_SOC soc, char *url, char *method, char *data, char *path) {
#endif
}
/* Only the UI asking to quit is a clean stop; losing the socket or the buffer
is what the caller reports as a failure. */
retour = willexit;
StringFree(headers);
StringFree(output);
StringFree(tmpbuff);
@@ -1918,7 +1988,9 @@ int htslang_uninit(void) {
return 1;
}
void smallserver_setpinghandler(void (*fun)(void*), void*arg) {
void smallserver_setpinghandler(void (*fun)(void *, smallserver_client_event,
const char *),
void *arg) {
pingFun = fun;
pingFunArg = arg;
}

View File

@@ -94,7 +94,20 @@ extern httrackp *global_opt;
#define min(a,b) ((a)>(b)?(b):(a))
#define max(a,b) ((a)>(b)?(a):(b))
extern void smallserver_setpinghandler(void (*fun)(void*), void*arg);
/* What the UI just told us about itself, reported to the ping handler. */
typedef enum {
SMALLSERVER_CLIENT_REQUEST, /* any request; claims no window */
SMALLSERVER_CLIENT_PING, /* one window's heartbeat */
SMALLSERVER_CLIENT_LEAVING /* that window is closing */
} smallserver_client_event;
/* Longest window id a page may claim; a longer one is ignored. */
#define SMALLSERVER_WINDOW_ID_MAX 32
/* fun() receives the id of the window the event came from, NULL when no window
claimed it. */
extern void smallserver_setpinghandler(
void (*fun)(void *, smallserver_client_event, const char *), void *arg);
extern int smallserver_setkey(const char *key, const char *value);
extern int smallserver_setkeyint(const char *key, LLint value);
extern int smallserver_setkeyarr(const char *key, int id, const char *key2, const char *value);

View File

@@ -557,19 +557,6 @@ static char *path_basename_dup(const char *path) {
return strdupt(b);
}
/* A host made only of digits and dots is an IPv4 literal (never reversed). */
static int surt_host_is_ip(const char *h, size_t n) {
size_t i;
int dots = 0;
for (i = 0; i < n; i++) {
if (h[i] == '.')
dots++;
else if (h[i] < '0' || h[i] > '9')
return 0;
}
return dots > 0;
}
/* SURT-canonicalize url into out (no newline): scheme and userinfo dropped,
host lowercased with a leading www[digits] label stripped and the scheme
default port removed, labels reversed and comma-joined then ')', path+query
@@ -637,7 +624,7 @@ static int surt_canon(const char *url, wbuf *out) {
hostbuf[hlen] = '\0';
if (!is_ipv6)
is_ip = surt_host_is_ip(hostbuf, hlen);
is_ip = hts_host_is_ipv4(hostbuf, hlen);
if (!is_ipv6 && !is_ip && hlen >= 4 && hostbuf[0] == 'w' &&
hostbuf[1] == 'w' && hostbuf[2] == 'w') {

View File

@@ -105,49 +105,133 @@ static void htsweb_sig_brpipe(int code) {
/* Threads that never return; no wait may count on them draining. */
static int nonjoinable_threads = 0;
/* Server/client ping handling */
/* Session lifetime: each window pings under its own id and drops it when it
closes, so an abandoned server stops instead of outliving the session and
holding its payload open (a mounted disk image, on macOS). Windows are
counted, not timed: closing one of several must not end the session. */
#define PING_PERIOD 5
/* Silence tolerated from one window. Generous: a hidden tab has its timers
throttled to as little as one wake-up a minute. */
static int pingTimeout = 120;
/* Once the last window leaves, only a page navigation can bring one back, and
that takes a fraction of a second over the loopback. */
#define LEAVE_GRACE max(2, min(5, pingTimeout / 4))
/* Windows tracked at once. A full table refuses newcomers rather than evicting:
dropping a live window is what would let a flood of ids end the session. */
#define MAX_WINDOWS 16
static htsmutex pingMutex = HTSMUTEX_INIT;
static unsigned int pingId = 0;
static unsigned int getPingId(void) {
unsigned int id;
hts_mutexlock(&pingMutex);
id = pingId;
hts_mutexrelease(&pingMutex);
return id;
/* Seconds the watchdog has been awake, not wall-clock: time(NULL) jumps across
a laptop suspend, and a suspended machine must not age a session. */
static int ticks = 0;
static struct {
char id[SMALLSERVER_WINDOW_ID_MAX + 1];
int last_seen;
} windows[MAX_WINDOWS];
static int windowCount = 0;
static int emptySince = 0; /* tick the last window left at */
static hts_boolean anyWindow = HTS_FALSE; /* a window has claimed an id */
static int lastSeen = 0; /* tick of the last request of any kind */
static hts_boolean anyRequest = HTS_FALSE; /* something has connected */
/* Drop windows[i], moving the last entry into its slot: a caller removing while
it iterates must walk backwards. Caller holds pingMutex. */
static void window_forget(int i) {
windows[i] = windows[--windowCount];
if (windowCount == 0) {
emptySince = ticks;
}
}
static void ping(void) {
static void pingHandler(void *arg, smallserver_client_event ev,
const char *window) {
int i = 0;
(void) arg;
hts_mutexlock(&pingMutex);
pingId++;
lastSeen = ticks;
anyRequest = HTS_TRUE;
/* A bare request names no window: any local peer can open a connection, but
none may cancel a real window's departure. */
if (window != NULL) {
while (i < windowCount && strcmp(windows[i].id, window) != 0) {
i++;
}
if (ev == SMALLSERVER_CLIENT_LEAVING) {
if (i < windowCount) {
window_forget(i);
}
} else if (i < windowCount) {
windows[i].last_seen = ticks;
} else if (windowCount < MAX_WINDOWS) {
windows[windowCount].id[0] = '\0';
strlncatbuff(windows[windowCount].id, window,
sizeof(windows[windowCount].id),
sizeof(windows[windowCount].id) - 1);
windows[windowCount++].last_seen = ticks;
anyWindow = HTS_TRUE;
}
}
hts_mutexrelease(&pingMutex);
}
static void client_ping(void *pP) {
#ifndef _WIN32
/* Timeout to 120s ; normally client pings every 30 second */
static int timeout = 120;
/* Wait for parent to die (legacy browser mode). */
const pid_t ppid = (pid_t) (uintptr_t) pP;
while (!kill(ppid, 0)) {
sleep(1);
}
/* Parent (webhttrack script) is dead: is client pinging ? */
for(;;) {
unsigned int id = getPingId();
sleep(timeout);
if (getPingId() == id) {
break;
}
}
/* Die! */
fprintf(stderr,
"Parent process %d died, and client did not ping for %ds: exiting!\n",
(int) ppid, timeout);
exit(EXIT_FAILURE);
/* True unless the launcher we were started from is known to be gone. */
static hts_boolean parent_is_alive(uintptr_t ppid) {
#ifdef _WIN32
(void) ppid;
return HTS_TRUE; /* no cheap probe; the heartbeat carries this */
#else
/* kill(0) would signal our own process group, never a parent. */
return ppid == 0 || kill((pid_t) ppid, 0) == 0 ? HTS_TRUE : HTS_FALSE;
#endif
}
static void pingHandler(void*arg) {
ping();
static void client_ping(void *pP) {
/* uintptr_t, not pid_t: MSVC has no such type, and this signature is not
inside a POSIX guard. */
const uintptr_t ppid = (uintptr_t) pP;
const char *why = NULL;
while (why == NULL) {
int i;
Sleep(1000);
/* A mirror in flight outranks every rule below: it may have hours of
crawling behind it, and the user can always come back to its page. */
if (commandRunning) {
continue;
}
hts_mutexlock(&pingMutex);
ticks++;
/* A window that stops pinging without a goodbye crashed with its browser.
*/
for (i = windowCount; i-- > 0;) {
if (ticks - windows[i].last_seen >= pingTimeout) {
window_forget(i);
}
}
if (anyWindow && windowCount == 0 && ticks - emptySince >= LEAVE_GRACE) {
why = "the interface was closed";
} else if (!anyWindow &&
ticks - lastSeen >= pingTimeout
/* No window ever pinged: a browser too old for it, or none
opened. Fall back to the launcher dying with that browser,
rather than to silence, which a reader also produces. */
&& (!anyRequest || !parent_is_alive(ppid))) {
why = "the interface went silent";
}
hts_mutexrelease(&pingMutex);
/* Re-read after the decision: a mirror may have started while it was made,
and exiting now would lose it. */
if (commandRunning) {
why = NULL;
}
}
fprintf(stderr, "Exiting: %s\n", why);
exit(EXIT_SUCCESS);
}
int main(int argc, char *argv[]) {
@@ -184,6 +268,7 @@ int main(int argc, char *argv[]) {
fprintf(stderr, "** Warning: use the webhttrack frontend if available\n");
fprintf(stderr,
"usage: %s [--port <port>] [--bind <address>] [--ppid parent-pid] "
"[--ping-timeout <seconds>] "
"<path-to-html-root-dir> [key value [key value]..]\n",
argv[0]);
fprintf(stderr, "example: %s /usr/share/httrack/\n", argv[0]);
@@ -288,6 +373,17 @@ int main(int argc, char *argv[]) {
fprintf(stderr, "couldn't set the parent PID to %s\n", argv[i + 1]);
return -1;
}
} else if (strcmp(argv[i], "--ping-timeout") == 0 && i + 1 < argc) {
/* Bounded, not just parsed: %d wrapping a huge value into a plausible one
is what #614 cost on --port, two cases above. */
char *end = NULL;
const long v = strtol(argv[i + 1], &end, 10);
if (end == argv[i + 1] || *end != '\0' || v < 1 || v > 86400) {
fprintf(stderr, "couldn't set the ping timeout to %s\n", argv[i + 1]);
return -1;
}
pingTimeout = (int) v;
} else if (i + 1 < argc) {
smallserver_setkey(argv[i], argv[i + 1]);
} else {
@@ -304,9 +400,7 @@ int main(int argc, char *argv[]) {
/* pinger */
if (parentPid > 0) {
if (hts_newthread(client_ping, (void *) (uintptr_t) parentPid) == 0) {
#ifndef _WIN32
nonjoinable_threads++; /* client_ping() only ever leaves through exit() */
#endif
}
smallserver_setpinghandler(pingHandler, NULL);
}
@@ -389,7 +483,13 @@ static void back_launch_cmd(void *pP) {
void webhttrack_main(char *cmd) {
commandRunning = 1;
DEBUG(fprintf(stderr, "commandRunning=1\n"));
hts_newthread(back_launch_cmd, (void *) strdup(cmd));
if (hts_newthread(back_launch_cmd, (void *) strdup(cmd)) != 0) {
/* Nothing else clears the flag, and while it is set the watchdog holds the
server open for a mirror that never started. */
commandRunning = 0;
commandEnd = 1;
commandReturn = -1;
}
}
void webhttrack_lock(void) {

View File

@@ -181,7 +181,84 @@ static void wizard_cat_path(htsbuff *f, const char *sign, const char *adr,
htsbuff_catn(f, fil, len);
}
void hts_wizard_answer_filter(htsbuff *f, int n, const char *adr,
HTSEXT_API hts_boolean hts_wizard_host_scope(const char *question, int k,
char *dst, size_t dstsize) {
const char *host, *port, *slash, *end, *scope;
size_t len;
if (dst == NULL || dstsize == 0)
return HTS_FALSE;
dst[0] = '\0';
if (question == NULL || k < 0)
return HTS_FALSE;
host = jump_identification_const(question);
port = jump_toport_const(question);
slash = strchr(host, '/');
end = host + strlen(host);
scope = host;
if (slash != NULL && slash < end)
end = slash;
/* the port belongs to the filter, so keep it and only bound the label walk */
if (port != NULL && port < end)
slash = port;
else
slash = end;
/* a fully-qualified "foo.com." ends on the root label, which is not one */
if (slash > host && slash[-1] == '.')
slash--;
if (slash == host || *host == '[') /* no host, or an IPv6 literal */
return HTS_FALSE;
if (hts_host_is_ipv4(host, (size_t) (slash - host)))
return HTS_FALSE;
/* widen by dropping one leading label per step, and never offer a bare TLD */
for (; k > 0; k--) {
const char *dot = memchr(scope, '.', (size_t) (slash - scope));
if (dot == NULL)
return HTS_FALSE;
scope = dot + 1;
}
if (memchr(scope, '.', (size_t) (slash - scope)) == NULL)
return HTS_FALSE;
len = (size_t) (end - scope);
if (len >= dstsize)
return HTS_FALSE;
memcpy(dst, scope, len);
dst[len] = '\0';
return HTS_TRUE;
}
hts_tristate hts_wizard_scope_answer(int n) {
if (n >= HTS_WIZARD_SCOPE_EXCLUDE)
return HTS_TRUE;
if (n >= HTS_WIZARD_SCOPE_INCLUDE)
return HTS_FALSE;
return HTS_DEFAULT;
}
/* The subdomain form of the scope in slot 0, its apex in slot 1: the starred
one does not match the apex, so a whole-domain answer needs both. */
static void wizard_cat_scope(htsbuff *f, const char *sign, const char *adr,
int n, int slot) {
char scope[HTS_URLMAXSIZE];
const int k =
n - (hts_wizard_scope_answer(n) == HTS_TRUE ? HTS_WIZARD_SCOPE_EXCLUDE
: HTS_WIZARD_SCOPE_INCLUDE);
if (slot >= HTS_WIZARD_MAX_FILTERS ||
!hts_wizard_host_scope(adr, k, scope, sizeof(scope)))
return;
htsbuff_cpy(f, sign);
if (slot == 0)
htsbuff_cat(f, "*.");
htsbuff_cat(f, scope);
htsbuff_cat(f, "/*");
}
void hts_wizard_answer_filter(htsbuff *f, int slot, int n, const char *adr,
const char *fil, hts_boolean seeker_up) {
size_t dir = hts_lastcharoffset(fil);
@@ -189,6 +266,13 @@ void hts_wizard_answer_filter(htsbuff *f, int n, const char *adr,
dir--;
htsbuff_cpy(f, "");
if (hts_wizard_scope_answer(n) != HTS_DEFAULT) {
wizard_cat_scope(f, hts_wizard_scope_answer(n) == HTS_TRUE ? "-" : "+", adr,
n, slot);
return;
}
if (slot != 0) /* every other answer emits a single filter */
return;
switch (n) {
case 0: /* this link only */
wizard_cat_path(f, "-", adr, fil, (size_t) -1);
@@ -238,6 +322,46 @@ void hts_wizard_answer_filter(htsbuff *f, int n, const char *adr,
}
}
void hts_wizard_apply_verdict(httrackp *opt, int n, const char *adr,
const char *fil, int *forbidden_url,
int *set_prio_to) {
switch (n) {
case -1: /* skip this link and every question after it */
*forbidden_url = 1;
opt->wizard = HTS_WIZARD_AUTO;
break;
case 0: /* this link */
case 1: /* this directory and below */
case 2: /* the whole host */
case 3: /* the parent directory, which emits no filter yet */
*forbidden_url = 1;
break;
case 4: /* wizard filters both allow and forbid, so an isolated link taken
with no depth limit would mirror the whole site */
*set_prio_to = 0 + 1; /* recursion level 0 */
break;
case 5: /* this directory and below, or the whole host */
case 6: /* the whole host */
case 7: /* this directory, files only */
case 50: /* nothing to do */
case -999: /* the "!" answer, and anything the front end could not parse */
break;
default: /* a scope answer forbids like 2 or allows like 6 */
if (hts_wizard_scope_answer(n) == HTS_TRUE)
*forbidden_url = 1;
else if (hts_wizard_scope_answer(n) == HTS_DEFAULT)
hts_log_print(opt, LOG_WARNING,
"(wizard) unknown answer %d at %s%s, keeping the computed "
"verdict",
n, adr, fil);
break;
}
}
static int hts_acceptlink_(httrackp * opt, int ptr,
const char *adr, const char *fil, const char *tag,
const char *attribute, int *set_prio_to,
@@ -765,8 +889,9 @@ static int hts_acceptlink_(httrackp * opt, int ptr,
n = force_mirror;
}
/* sanity check - reallocate filters HERE */
if ((*_FILTERS_PTR) + 1 >= opt->maxfilter) {
/* sanity check - reallocate filters HERE (a host-scope answer emits two)
*/
if ((*_FILTERS_PTR) + 2 >= opt->maxfilter) {
opt->maxfilter += HTS_FILTERSINC;
if (filters_init(&_FILTERS, opt->maxfilter, HTS_FILTERSINC) == 0) {
printf("PANIC! : Too many filters : >%d [%d]\n", (*_FILTERS_PTR),
@@ -780,64 +905,21 @@ static int hts_acceptlink_(httrackp * opt, int ptr,
}
}
// here we have enough room for a new filter if necessary
switch (n) {
case -1: // sauter tout le reste
forbidden_url = 1;
opt->wizard = HTS_WIZARD_AUTO; // sauter tout le reste
break;
case 0: // forbid the same link: adr/fil
case 1: // forbid the whole directory and subdirs: adr/path/*
case 2: // the whole address: adr/*
forbidden_url = 1;
break;
case 3: // ** A FAIRE
forbidden_url = 1;
/*
{
int i=strlen(adr)-1;
while((adr[i]!='/') && (i>0)) i--;
if (i>0) {
hts_wizard_apply_verdict(opt, n, adr, fil, &forbidden_url, set_prio_to);
}
} */
break;
//
case 4: // same link
// PAS BESOIN!!
/*HT_INSERT_FILTERS0; // insérer en 0
strcpybuff(_FILTERS[0],"+");
strcatbuff(_FILTERS[0],adr);
if (*fil!='/') strcatbuff(_FILTERS[0],"/");
strcatbuff(_FILTERS[0],fil); */
// étant donné le renversement wizard/primary filter (les primary autorisent up/down ET interdisent)
// il faut éviter d'un lien isolé effectue un miroir total..
*set_prio_to = 0 + 1; // niveau de récursion=0 (pas de miroir)
break;
case 5: // allow the whole directory and its children, or the domain
case 6: // same domain
case 7: // allow this directory
break;
case 50: // nothing to do
break;
} // switch
/* the pattern half of the answer: a new answer needs both switches */
/* the pattern half of the answer */
{
char BIGSTK pattern[HTS_FILTER_SLOT_SIZE];
htsbuff f = htsbuff_array(pattern);
int slot;
hts_wizard_answer_filter(
&f, n, adr, fil,
(opt->seeker & HTS_SEEKER_UP) != 0 ? HTS_TRUE : HTS_FALSE);
if (f.len != 0) {
for (slot = 0; slot < HTS_WIZARD_MAX_FILTERS; slot++) {
hts_wizard_answer_filter(
&f, slot, n, adr, fil,
(opt->seeker & HTS_SEEKER_UP) != 0 ? HTS_TRUE : HTS_FALSE);
if (f.len == 0)
break;
HT_INSERT_FILTERS0; // insert at slot 0
strlcpybuff(_FILTERS[0], pattern, HTS_FILTER_SLOT_SIZE);
}

View File

@@ -57,12 +57,30 @@ hts_boolean hts_robots_forbids(httrackp *opt, const char *adr, const char *fil,
hts_boolean filters_decided,
hts_boolean filters_refused);
/* Builds into `f` the filter answer `n` adds for the link (adr,fil), and leaves
`f` empty when the answer adds none. `seeker_up` is the HTS_SEEKER_UP bit of
opt->seeker, read by answer 5. */
void hts_wizard_answer_filter(htsbuff *f, int n, const char *adr,
/* Most filters one wizard answer can add. Slots must stay contiguous: the
caller stops at the first empty one. */
#define HTS_WIZARD_MAX_FILTERS 2
/* Builds into `f` the `slot`-th filter answer `n` adds for the link (adr,fil),
and leaves `f` empty past the last one. Only the host-scope answers emit a
second, because their starred form misses the apex. `seeker_up` is the
HTS_SEEKER_UP bit of opt->seeker, read by answer 5. */
void hts_wizard_answer_filter(htsbuff *f, int slot, int n, const char *adr,
const char *fil, hts_boolean seeker_up);
/* Which host-scope range answer `n` falls in: HTS_TRUE excludes the scope,
HTS_FALSE includes it, HTS_DEFAULT for any answer outside both ranges. */
hts_tristate hts_wizard_scope_answer(int n);
/* Applies the verdict half of answer `n` for the link (adr,fil): refuses it,
stops the questions, or bans recursion from it. Each effect is one-way, so an
answer deciding none of them leaves *forbidden_url, *set_prio_to and
opt->wizard alone; the filter half is hts_wizard_answer_filter(), and a new
answer needs both. */
void hts_wizard_apply_verdict(httrackp *opt, int n, const char *adr,
const char *fil, int *forbidden_url,
int *set_prio_to);
/* A (tag, attribute) pair naming a reference kind. */
#ifndef HTS_DEF_DEFSTRUCT_htspair_t
#define HTS_DEF_DEFSTRUCT_htspair_t

View File

@@ -452,6 +452,23 @@ HTSEXT_API char *jump_identification(char *);
HTSEXT_API const char *jump_identification_const(const char *);
/** Write into dst the k-th domain scope the wizard can offer for the string
query3 was handed (an "adr" or an "adr[/fil]"). Scopes widen as k grows: for
download.example.co.uk/x, "download.example.co.uk", then "example.co.uk",
then "co.uk". A front end enumerates its menu by looping until this returns
HTS_FALSE, and answers HTS_WIZARD_SCOPE_INCLUDE+k or
HTS_WIZARD_SCOPE_EXCLUDE+k.
A bare TLD is never offered, and an IP literal has no scopes, so an empty
menu is normal. Protocol and credentials are stripped and the port is kept,
so a caller must not split the host itself.
dst is emptied on every failure, and HTS_URLMAXSIZE bytes always suffice. A
shorter dst also returns HTS_FALSE, which the loop cannot tell from the end
of the list. */
HTSEXT_API hts_boolean hts_wizard_host_scope(const char *question, int k,
char *dst, size_t dstsize);
/** Like jump_identification() and also strip a leading "www." host prefix,
returning a pointer into the input to the normalized host. */
HTSEXT_API char *jump_normalized(char *);

View File

@@ -712,6 +712,20 @@ static const char *__cdecl htsshow_query3(t_hts_callbackarg * carg,
"5 Mirror this link (useful)\n"
"6 Mirror all links located on the same domain as this link\n" "\n",
question);
/* the domain scopes are host-dependent, so the engine enumerates them */
{
char scope[HTS_URLMAXSIZE];
int k;
for (k = 0; hts_wizard_host_scope(question, k, scope, sizeof(scope)); k++)
printf("%d Mirror %s and every host below it\n",
HTS_WIZARD_SCOPE_INCLUDE + k, scope);
for (k = 0; hts_wizard_host_scope(question, k, scope, sizeof(scope)); k++)
printf("%d Ignore %s and every host below it\n",
HTS_WIZARD_SCOPE_EXCLUDE + k, scope);
if (k != 0)
printf("\n");
}
do {
printf(">> ");
io_flush;

View File

@@ -59,6 +59,18 @@ function launch_browser {
log "Browser (or helper) exited"
}
# Wait until the server stops (the interface closed, so this process has nothing
# left to represent) or the browser exits, whichever comes first. An old browser
# only ever signals by exiting.
function wait_for_session {
local browserpid=$1 sessionpid=$2
while test -n "${sessionpid}${browserpid}"; do
test -n "${sessionpid}" && ! kill -0 "${sessionpid}" 2>/dev/null && break
test -n "${browserpid}" && ! kill -0 "${browserpid}" 2>/dev/null && break
sleep 1
done
}
# First ensure that we can launch the server
BINPATH=
for i in "${SRCHPATH[@]}"; do
@@ -147,8 +159,14 @@ function cleanup {
# Cleanup in case of emergency
trap "cleanup now; exit" HUP INT QUIT PIPE TERM
# Got SRVURL, launch browser
launch_browser "${BROWSEREXE}" "${SRVURL}"
# Got SRVURL, launch browser. Backgrounded so the wait below can watch the
# server too, rather than only the browser.
launch_browser "${BROWSEREXE}" "${SRVURL}" &
BROWSERPID=$!
# Deliberately not SRVPID, which cleanup would then kill, taking a running mirror
# with it.
SESSIONPID=$(grep -E PID= "${TMPSRVFILE}" | cut -f2- -d=)
wait_for_session "${BROWSERPID}" "${SESSIONPID}"
# That's all, folks!
trap "" HUP INT QUIT PIPE TERM

View File

@@ -0,0 +1,104 @@
#!/bin/bash
#
# A -%F footer whose expansion exactly filled the on-page buffer aborted the
# crawl (SIGABRT): the emitter appends its closing newline with strcatbuff,
# which aborts rather than clips. #670 covered the overflow, not the exact fit.
set -eu
# shellcheck source=tests/testlib.sh
. "$(dirname "$0")/testlib.sh"
# The emitter's buffer, 1024 + 2 * HTS_URLMAXSIZE. The sweep window is derived
# from it; a wrong value makes the crossing check below fail loudly.
bufsize=3072
# A {path} of this length divides the buffer with room left for the literal
# padding that tunes the last few bytes, and keeps the mirror under MAX_PATH.
pathlen=120
mark=ZFOOTERZ
dir=$(mktemp -d)
cleanup_push rm -rf "$dir"
mir="$dir/mir"
page=
body=
make_page() { # make_page SEGLEN, leaving an LF page at $page
local seg
seg=$(printf 'a%.0s' $(seq 1 "$1"))
mkdir -p "$dir/$seg"
page="$dir/$seg/index.html"
write_page '<html><body>hi</body></html>'
}
# The emitter follows the page's own line ending, so the CR here is what picks
# "\r\n" and moves the boundary two bytes.
write_page() { printf '%s' "$1" >"$page"; }
crawl() { # crawl FOOTER [LABEL], leaving the mirrored page in $body
rm -rf "$mir"
httrack "file://$page" -O "$mir" -%F "$1" -q -s0 -%v0 >/dev/null 2>&1 ||
fail "crawl died (exit $?) on ${2:-a ${#1}-char footer}"
# Under file/, not $mir: the makeindex top index carries no -%F footer.
local found
found=$(find "$mir/file" -name index.html 2>/dev/null || true)
test -n "$found" || fail "page not mirrored; the footer path never ran"
body=$(cat "$found")
}
# Which line the footer lands on follows the page's own newlines, so find it by
# its marker rather than by position.
footer_line() { firstline "$(tr -d '\r' <<<"$body" | grep -F "$mark" || true)"; }
path_expansion() { # the length {path} expands to
local line
crawl "${mark}{path}"
line=$(footer_line)
test -n "$line" || fail "no footer emitted while measuring {path}"
printf '%s\n' "$((${#line} - ${#mark}))"
}
# -%F caps at 253 chars, so reaching a ~3 KB expansion takes repeats of the
# longest field a file:// crawl offers. Size the directory to make {path}
# exactly pathlen, rather than inherit whatever length mktemp handed out.
make_page 8
fixed=$(($(path_expansion) - 8))
# Loudly, not a skip: the Windows leg compares the skip set exactly, so a skip
# reds it there anyway and quietly drops the coverage everywhere else.
test "$fixed" -lt "$pathlen" ||
fail "temp path is ${fixed} chars, leaving no room for a ${pathlen}-char one"
make_page $((pathlen - fixed))
got=$(path_expansion)
test "$got" -eq "$pathlen" || fail "{path} is ${got} chars, wanted ${pathlen}"
reps=$(((bufsize - 72) / pathlen))
sweep() { # sweep LABEL: walk the expansion across the point where footers drop
local total pad footer line emitted=0 dropped=0
for total in $(seq $((bufsize - 6)) "$bufsize"); do
pad=$((total - reps * pathlen))
test "$pad" -gt ${#mark} || fail "padding is down to ${pad} chars"
footer="${mark}$(printf 'A%.0s' $(seq 1 $((pad - ${#mark}))))"
footer="${footer}$(printf '{path}%.0s' $(seq 1 "$reps"))"
test ${#footer} -lt 254 || fail "footer template is ${#footer} chars"
crawl "$footer" "a $1 page at expansion ${total}"
if grep -q "$mark" <<<"$body"; then
line=$(footer_line)
# Whole or not at all: a clipped footer runs into the page below it.
test "${#line}" -eq "$total" ||
fail "$1: ${total}-char footer emitted as ${#line} chars"
emitted=$((emitted + 1))
else
dropped=$((dropped + 1))
fi
done
# Both outcomes prove the window straddles the drop point, where the abort
# was. All-emitted or all-dropped means the window missed it.
test "$emitted" -gt 0 || fail "$1: no length in the sweep emitted a footer"
test "$dropped" -gt 0 || fail "$1: no length in the sweep was dropped"
}
sweep LF
# CRLF costs two bytes at each end, so the boundary sits two lengths lower: a
# fix reserving one byte rather than strlen(eol) still aborts here.
write_page $'<html>\r\n<body>hi</body></html>'
sweep CRLF

View File

@@ -36,11 +36,43 @@ echo "marker: the wedged test started"
sleep 300
EOF
start=$SECONDS
# Time the guard to its DUMP announcement, not to the driver's exit: the dump that
# follows runs for minutes on an emulated host, and that is not what is under test.
rc=0
HTTRACK_PROGRESS_LOG="$tmp/progress" HTTRACK_TEST_TIMEOUT=5 \
bash "$driver" "$tmp/90_wedged.test" >"$out" 2>&1 || rc=$?
elapsed=$((SECONDS - start))
fired=
marked=
total=
run_wedged() { # run_wedged <budget> [VAR=VAL...]
local budget=$1 start=$SECONDS pid
shift
: >"$tmp/progress"
rc=0
fired=
marked=
env "$@" HTTRACK_PROGRESS_LOG="$tmp/progress" HTTRACK_TEST_TIMEOUT="$budget" \
bash "$driver" "$tmp/90_wedged.test" >"$out" 2>&1 &
pid=$!
while kill -0 "$pid" 2>/dev/null; do
# Read with the shell: a fork per poll would blur the latency being measured.
if read -r line <"$tmp/progress" 2>/dev/null && test "$line" = "DUMP 90_wedged.test"; then
fired=$((SECONDS - start))
marked=1
break
fi
poll_wait 0.1 || sleep 1
done
wait "$pid" || rc=$?
total=$((SECONDS - start))
# Missed between two polls: the whole run then bounds the latency from above.
test -n "$fired" || fired=$total
}
# Retried once, because a dump short enough to fall between two polls is a race, not a
# regression; announcing after the dump loses both attempts.
run_wedged 5
test -n "$marked" || run_wedged 5
test -n "$marked" ||
fail "the announcement was never seen while the guard ran, so its latency is unknown"
# Announced when the dump starts, which runs for minutes: a suite watchdog
# reading that log would take the silence for a wedge and kill the step.
@@ -49,8 +81,8 @@ grep -qx 'DUMP 90_wedged.test' "$tmp/progress" ||
test "$rc" -eq 124 || fail "wedged test reported $rc, want 124"
# Never before the budget, or a slow-but-healthy test would be killed too.
test "$elapsed" -ge 5 || fail "the guard fired early (${elapsed}s of a 5s budget)"
test "$elapsed" -lt 30 || fail "the guard fired late (${elapsed}s)"
test "$fired" -ge 5 || fail "the guard fired early (${fired}s of a 5s budget)"
test "$fired" -lt 30 || fail "the guard fired late (${fired}s)"
grep -q 'marker: the wedged test started' "$out" || fail "the test's own output was lost"
# The header, not a bare name: the process list quotes the test's path too, so a
# wrapper that named nothing would still match that.
@@ -58,6 +90,20 @@ grep -q '^===== TIMEOUT: 90_wedged.test exceeded' "$out" ||
fail "the diagnostics do not name the test"
grep -q "own process tree" "$out" || fail "no process list in the diagnostics"
# Announced BEFORE the dump, not merely at some point during it: the watchdog reading
# that log takes the silence of a dump for a wedge. Only a slow dump tells the two
# orderings apart, and the dump's own ps is what this makes slow.
if ! is_windows; then
printf '#!/bin/sh\nsleep 3\nexec %s "$@"\n' "$(command -v ps)" >"$shim/ps"
chmod +x "$shim/ps"
run_wedged 5 "PATH=$shim:$PATH"
rm -f "$shim/ps" # before the starve shim shares this directory
test "$rc" -eq 124 || fail "the guard reported $rc under a slow dump, want 124"
test -n "$marked" || fail "no announcement under a slow dump"
test "$((total - fired))" -ge 2 ||
fail "announced with the dump (${fired}s of ${total}s), not before it"
fi
# The budget is read, not hard-coded: well under it, the same shape survives.
printf 'sleep 3\necho "slow but healthy"\n' >"$tmp/92_slow.test"
rc=0
@@ -78,15 +124,11 @@ sleep 1
# the loop it stretches is the same one poll_wait's fd tick drives.
(
starve_sleep "$shim" 4 || fail "could not install the slow sleep"
start=$SECONDS
rc=0
HTTRACK_POLL_SLEEP=1 HTTRACK_TEST_TIMEOUT=1 \
bash "$driver" "$tmp/90_wedged.test" >"$out" 2>&1 || rc=$?
elapsed=$((SECONDS - start))
run_wedged 1 HTTRACK_POLL_SLEEP=1
test "$rc" -eq 124 || fail "starved guard reported $rc, want 124"
# Generous: the diagnostics dump runs inside this window too.
test "$elapsed" -lt 25 ||
fail "budget counted polls, not seconds: ${elapsed}s for a 1s budget"
# 10 stretched polls would be 40s; a handful of them is the whole margin here.
test "$fired" -lt 25 ||
fail "budget counted polls, not seconds: ${fired}s for a 1s budget"
)
# --- exit status and output of a healthy test pass straight through ----------
@@ -127,6 +169,84 @@ saw_budget 45 "an explicit budget"
HTTRACK_TEST_TIMEOUT=0 bash "$driver" "$tmp/95_budget.test" >"$out" 2>&1
saw_budget 0 "a disabled guard"
# --- a test may raise the budget, never lower it ----------------------------
# 269's header sweep is n^2 compiles, real work that outlasts the wedge budget on an
# emulated host; anything else asking would be disarming the guard.
raiser() { # raiser <asked for>
# shellcheck disable=SC2016 # the fixture has to read the variable, not us
printf '# TEST_TIMEOUT_AT_LEAST: %s\necho "budget=${HTTRACK_TEST_TIMEOUT-unset}"\n' \
"$1" >"$tmp/97_raise.test"
}
# 0900 must not read as octal, and a value past intmax must not reach `test`, which
# errors on it rather than comparing and would leave the guard unarmed.
for want in 900:900 5:600 garbage:600 0900:900 99999999999999999999:600 ' 900':600; do
raiser "${want%%:*}"
HTTRACK_TEST_TIMEOUT=600 bash "$driver" "$tmp/97_raise.test" >"$out" 2>&1
saw_budget "${want##*:}" "a test asking for '${want%%:*}'"
done
raiser 900
HTTRACK_TEST_TIMEOUT=0 bash "$driver" "$tmp/97_raise.test" >"$out" 2>&1
saw_budget 0 "a raise under a disabled guard"
# Read from the header only, or a test's own data would be one: 151 writes this line.
window() { # window <lines of padding> <budget wanted>
{
i=0
while test "$i" -lt "$1"; do
i=$((i + 1))
echo "# pad $i"
done
cat "$tmp/97_raise.test"
} >"$tmp/97_deep.test"
HTTRACK_TEST_TIMEOUT=600 bash "$driver" "$tmp/97_deep.test" >"$out" 2>&1
saw_budget "$2" "a raise on line $(($1 + 1))"
}
raiser 900
window 39 900 # the last line the header reaches
window 40 600
# Enforced, not merely exported: the number the guard uses is the one it kills on.
printf '# TEST_TIMEOUT_AT_LEAST: 900\nsleep 4\necho "outlived the default"\n' \
>"$tmp/97_raise.test"
rc=0
HTTRACK_TEST_TIMEOUT=2 bash "$driver" "$tmp/97_raise.test" >"$out" 2>&1 || rc=$?
test "$rc" -eq 0 || fail "a 4s test that raised the budget to 900 reported $rc"
grep -q 'outlived the default' "$out" || fail "the raised budget killed the test anyway"
printf '# TEST_TIMEOUT_AT_LEAST: 1\nsleep 3\necho "not shrunk"\n' >"$tmp/97_raise.test"
rc=0
HTTRACK_TEST_TIMEOUT=600 bash "$driver" "$tmp/97_raise.test" >"$out" 2>&1 || rc=$?
test "$rc" -eq 0 || fail "a 3s test asking for a 1s budget reported $rc"
grep -q 'not shrunk' "$out" || fail "the header shrank the budget and killed the test"
# --- one budget parser, and what is left of it ------------------------------
secs() { # secs <value in the environment> <seconds it must read as>
local got
got=$(HTTRACK_TEST_TIMEOUT=$1 bash -c '. "$1"; budget_secs' _ "${testdir}/testlib.sh" 2>&1)
test "$got" = "$2" || fail "budget_secs read '$1' as '$got', want $2"
}
secs 45 45
secs 0900 900 # decimal, or $((...)) and test disagree on the same string
secs garbage 600
secs 99999999999999999999 600 # past intmax, where test errors instead of comparing
secs 0 0
# budget_left hands a child what is left of it, and keeps 0 meaning "no guard".
# shellcheck disable=SC2016 # the fixture has to call the helper, not us
printf '. "%s"\nsleep 2\necho "left=$(budget_left) at=$SECONDS"\n' "${testdir}/testlib.sh" \
>"$tmp/98_left.test"
# Exact, against the clock the child itself read: a tolerance would pass a wrong epoch.
HTTRACK_TEST_TIMEOUT=60 bash "$driver" "$tmp/98_left.test" >"$out" 2>&1
got=$(sed -n 's/^left=\([0-9][0-9]*\) .*/\1/p' "$out")
at=$(sed -n 's/^left=[0-9][0-9]* at=\([0-9][0-9]*\)$/\1/p' "$out")
case "$got$at" in '' | *[!0-9]*) fail "a 60s budget printed '$(cat "$out")'" ;; esac
test "$got" -eq "$((60 - at))" ||
fail "a 60s budget left $got with $at gone, want $((60 - at))"
HTTRACK_TEST_TIMEOUT=0 bash "$driver" "$tmp/98_left.test" >"$out" 2>&1
grep -q '^left=0 ' "$out" || fail "a disabled guard left '$(cat "$out")', want 0"
# Never 0 on an exhausted budget: a child would read that as the guard being off.
HTTRACK_TEST_TIMEOUT=1 bash -c '. "$1"; sleep 2; echo "left=$(budget_left)"' \
_ "${testdir}/testlib.sh" >"$out" 2>&1
grep -qx 'left=1' "$out" || fail "an exhausted budget left '$(cat "$out")', want 1"
# --- a test too slow to finish skips instead of being killed ----------------
# hppa spends ~150s on one configure run, and 124 takes the build down where 77
# does not.

View File

@@ -8,6 +8,8 @@ set -euo pipefail
# shellcheck source=tests/testlib.sh
. "$(dirname "$0")/testlib.sh"
# shellcheck source=tests/proclib.sh
. "$(dirname "$0")/proclib.sh"
sh=${BASH_SHELL:-}
test -n "$sh" || {
@@ -51,11 +53,21 @@ chmod 755 "$tmp/fakebin/bash"
mkfifo "$tmp/fifo"
chmod 755 "$tmp/fifo"
# Sampled rather than polled per second: the size read is a fork, and an emulated
# host pays for it. SILENCE clears the slowest single configure probe there.
SAMPLE=5
SILENCE=${HTTRACK_CONFIGURE_SILENCE:-120}
RESERVE=15 # what killing the run and skipping still needs of the budget
n=0
cases=16 # reject/accept calls below; pinned again once they have all run
status=0
log=
rundir=
took=0
# What run() launches, so the checks below can hand it a child that hangs or one that
# only crawls; nothing else may override it.
configure_cmd=(bash "$tmp/src/configure" --disable-https)
run() { # run <label> <env argument>...
local label=$1 began=$SECONDS
shift
@@ -65,23 +77,56 @@ run() { # run <label> <env argument>...
status=0
# Capped: configure executes the candidate, and a hang wedges "make check" with no output
# at all. Polled, not a backgrounded "sleep" watchdog, which outlives the run it guards.
(cd "$rundir" && env "$@" bash "$tmp/src/configure" --disable-https) \
local had_m=
case "$-" in *m*) had_m=1 ;; esac
# Own process group, so the kills below reach what configure spawned: bash 3.2 keeps
# the subshell it runs in, and killing that alone leaves the child running (macOS).
set -m
(cd "$rundir" && env "$@" "${configure_cmd[@]}") \
>"$rundir/log" 2>&1 &
local pid=$! waited=0
while test "$waited" -lt 300 && kill -0 "$pid" 2>/dev/null; do
local pid=$! waited=0 quiet=0 size=0 now left
test -n "$had_m" || set +m
# A hang is silence, not slowness: configure writes a line per probe,
# but hppa's emulated run can take longer overall than a runner's whole budget (#1146).
while kill -0 "$pid" 2>/dev/null; do
sleep 1
waited=$((waited + 1))
test "$((waited % SAMPLE))" -eq 0 || continue
now=$(wc -c <"$rundir/log")
if test "$now" -gt "$size"; then
size=$now
quiet=0
else
quiet=$((quiet + SAMPLE))
fi
test "$quiet" -lt "$SILENCE" || {
kill_tree "$pid"
echo "configure wrote nothing for ${quiet}s of ${waited}s for $label" >&2
tail -5 "$rundir/log" >&2
exit 1
}
# Still writing but out of time: skip, where the harness would kill the whole test
# and take the build down with it. Only while writing, and only with a full silence
# window still affordable, or a hang would reach this before the check above fires
# and a wedge would report a skip. 0 is the guard off.
left=$(budget_left)
if test "$quiet" -eq 0 && test "$left" -ne 0 &&
test "$left" -le "$((RESERVE + SILENCE))"; then
kill_tree "$pid"
echo "$label was still configuring ${waited}s in and the budget is out; skipping" >&2
exit 77
fi
done
if kill -0 "$pid" 2>/dev/null; then
kill -9 "$pid" 2>/dev/null
echo "configure did not return within ${waited}s for $label" >&2
tail -5 "$rundir/log" >&2
exit 1
fi
wait "$pid" || status=$?
log=$(cat "$rundir/log")
took=$((SECONDS - began))
echo "run $n ($label): exit $status"
skip_if_out_of_budget "$((cases - n))" "$((SECONDS - began))"
}
# Pace here rather than in run(), which returns with the answer still unjudged: a
# skip between the two would bury a configure that answered wrongly.
paced() {
skip_if_out_of_budget "$((cases - n))" "$took"
}
reject() { # reject <label> <expected message> <env argument>...
@@ -97,6 +142,7 @@ reject() { # reject <label> <expected message> <env argument>...
tail -5 <<<"$log" >&2
exit 1
}
paced
}
# accept <label> <expected $(BASH_SHELL), "" for any> <expected message, "" for none> <env argument>...
@@ -125,8 +171,69 @@ accept() {
exit 1
}
fi
paced
}
# --- what run() does to a child that hangs, and to one that is merely slow -------
# Driven through configure_cmd, since the real configure can do neither on demand.
probe() { # probe <run number> <seconds of budget left> <command>...
local want_n=$1 left=$2 rc=0
shift 2
(
# shellcheck disable=SC2030 # the isolation is the point: the real count is next door
n=$want_n SAMPLE=1 SILENCE=2
# shellcheck disable=SC2030,SC2031 # likewise: the budget here is the probe's own
export HTTRACK_TEST_TIMEOUT=$((SECONDS + left))
configure_cmd=("$@")
run probe
) >"$tmp/probe.log" 2>&1 || rc=$?
echo "$rc"
}
# A wedge must fail even with the budget gone, or #922 comes back as a skip.
rc=$(probe 90 6 sleep 999)
test "$rc" -eq 1 || fail "a silent configure with 6s of budget reported $rc, want 1"
grep -q 'wrote nothing' "$tmp/probe.log" || fail "the hang was not named: $(cat "$tmp/probe.log")"
# Slow but talking is the emulated buildd, and a skip there beats the harness kill.
rc=$(probe 91 6 bash -c 'while :; do echo tick; sleep 1; done')
test "$rc" -eq 77 || fail "a slow but writing configure with 6s of budget reported $rc, want 77"
# The kill has to reach what configure spawned. bash 3.2 keeps the subshell around the
# child, so killing that alone leaves a live configure behind: it outlives "make check"
# and holds the CI step open to its own timeout, with the suite reporting no failure.
rc=$(probe 92 6 bash -c 'sleep 987 & wait')
test "$rc" -eq 1 || fail "a silent configure with a child of its own reported $rc, want 1"
sleep 1
! ps_snapshot | grep -q '[s]leep 987' || fail "the killed run left its child running"
# The pacer must not fire before the case is judged: run() returns with the verdict
# still unread, and a skip there would bury a configure that answered wrongly. Through
# the real run(), since a stub cannot see a pacer left inside the one it replaced.
verdict() { # verdict <accept|reject> <run number> <status the child exits with>
local rc=0
(
# shellcheck disable=SC2030,SC2031 # the isolation is the point: the real run is next door
# Cases still to come, or the pacer this is looking for would decline to fire.
n=$2 cases=$(($2 + 5)) SAMPLE=1
# Spent by the time the run ends, so a pacer anywhere after it would fire.
# shellcheck disable=SC2030,SC2031 # likewise: the budget here is the probe's own
export HTTRACK_TEST_TIMEOUT=$((SECONDS + 4))
configure_cmd=(bash -c "sleep 3; exit $3")
# Their arities differ, and an extra argument would reach run() as an env
# assignment: the child would then fail to exec and answer the wrong question.
case "$1" in
accept) accept probe-verdict '' '' ;;
*) reject probe-verdict '' ;;
esac
) >/dev/null 2>&1 || rc=$?
test "$rc" -eq 1 || fail "$1 of a wrong answer with the budget spent reported $rc, want 1"
}
verdict accept 80 1 # configure rejected what it must accept
verdict reject 81 0 # configure accepted what it must reject
# The probes ran in subshells, so the real cases below start from a clean count.
cases=16
n=0
status=0
log=
took=0
# The four that configure to completion run first. A reject stops at the
# BASH_SHELL check and costs a fraction of one, and the pacer projects the step it
# just timed: behind the cheap ones it read far too low and 151 met the harness

View File

@@ -188,20 +188,29 @@ rc=0
kill_pid() { echo "DIRECT $1" >>"$rec"; }
# shellcheck disable=SC2317
kill_tree() {
echo "TREE $1" >>"$rec"
echo "TREE $*" >>"$rec"
exit 9
}
# The suite's own pid has no /proc entry here, so the capture answers what a
# POSIX box answers: empty, and the kill goes on unguarded.
# shellcheck disable=SC2317
win_capture() {
echo "CAPTURE $1" >>"$rec"
WIN_PID=4242 WIN_IMAGE=bash.exe
}
hb_depth=$BASH_SUBSHELL
ci_suite_heartbeat 960 360 "$progress" 900 4242 >"$tmp/hedge" 2>&1
) || rc=$?
test "$rc" -eq 9 || fail "the tree kill never fired: watchdog returned $rc"
test "$(sed -n 1p "$rec")" = "DIRECT 777" ||
fail "the reporter was not killed ahead of the suite: $(tr '\n' '/' <"$rec")"
test "$(sed -n 2p "$rec")" = "DIRECT 4242" ||
test "$(sed -n 2p "$rec")" = "CAPTURE 4242" ||
fail "the winpid was not read before the target was signalled: $(tr '\n' '/' <"$rec")"
test "$(sed -n 3p "$rec")" = "DIRECT 4242" ||
fail "the target was not signalled directly ahead of the tree walk: $(tr '\n' '/' <"$rec")"
test "$(sed -n 3p "$rec")" = "TREE 4242" ||
fail "the tree was not killed after the direct signal: $(tr '\n' '/' <"$rec")"
test "$(sed -n '$=' "$rec")" -eq 3 || fail "extra kills: $(tr '\n' '/' <"$rec")"
test "$(sed -n 4p "$rec")" = "TREE 4242 4242 bash.exe" ||
fail "the tree kill did not carry what was captured: $(tr '\n' '/' <"$rec")"
test "$(sed -n '$=' "$rec")" -eq 4 || fail "extra kills: $(tr '\n' '/' <"$rec")"
test ! -e "$tmp/forked" || fail "the clock was read through a subshell, a fork a starved box cannot spare"

View File

@@ -34,7 +34,11 @@ cleanup_push rm -rf "$tmp"
"$nm" --defined-only "$lib" >"$tmp/all.raw" 2>/dev/null ||
skip "$nm cannot read the symbol table of $lib"
awk 'NF >= 3 { print $3 }' "$tmp/exported.raw" | sort -u >"$tmp/exported"
awk 'NF >= 3 { print $3 }' "$tmp/all.raw" | sort -u >"$tmp/all"
# LTO and the optimizer's clones rename a local to "name.lto_priv.0", which
# stops matching the header identifier and empties the candidate list.
clone_alt='lto_priv|constprop|isra|part|cold|llvm'
strip_clones="s/(\\.($clone_alt)(\\.[0-9]+)*)+\$//"
awk 'NF >= 3 { print $3 }' "$tmp/all.raw" | sed -E "$strip_clones" | sort -u >"$tmp/all"
comm -23 "$tmp/all" "$tmp/exported" >"$tmp/hidden"
n_exported=$(wc -l <"$tmp/exported")
n_hidden=$(wc -l <"$tmp/hidden")
@@ -89,7 +93,26 @@ env MAKEFLAGS= MFLAGS= "$make" -C "$abs_top_builddir/src" install-DevIncludesDAT
# A synthetic leak the loop must report: every real candidate is a static defined
# in the header, which links from the probe's own copy and so proves nothing.
canary=zzz-canary.h
printf 'extern void %s(void);\n' "$neg" >"$tmp/include/httrack/$canary"
canaries=("$neg")
# One name per clone suffix, taken from the raw table: a strip that drops a
# suffix fails here instead of quietly shedding the candidates carrying it.
# Listed again on purpose; sharing $clone_alt would let one edit disarm both.
awk 'NF >= 3 && $3 !~ /\./ { print $3 }' "$tmp/all.raw" | sort -u >"$tmp/plain"
for suffix in lto_priv constprop isra part cold llvm; do
awk -v s="$suffix" 'NF >= 3 && $3 ~ "\\." s "([.$]|$)" { print $3 }' "$tmp/all.raw" |
sed -E 's/(\.[A-Za-z_][A-Za-z0-9_]*(\.[0-9]+)*)+$//' | sort -u >"$tmp/cloned"
sym=$(comm -23 <(comm -23 "$tmp/cloned" "$tmp/plain") "$tmp/exported" |
awk '/^[A-Za-z_][A-Za-z0-9_]*$/ { print; exit }')
if [ -n "$sym" ]; then
canaries+=("$sym")
fi
done
mapfile -t canaries < <(printf '%s\n' "${canaries[@]}" | sort -u)
for sym in "${canaries[@]}"; do
printf 'extern void %s(void);\n' "$sym"
done >"$tmp/include/httrack/$canary"
headers=("$tmp/include/httrack"/*.h)
[ "${#headers[@]}" -ge 10 ] || fail "only ${#headers[@]} headers installed, the list cannot be right"
@@ -113,7 +136,7 @@ ours {
EOF
probed=0
leaks=""
leaks=()
for h in "${headers[@]}"; do
b=$(basename "$h")
# config.h first, as a consumer must: it is what turns HTS_USEOPENSSL on, and
@@ -136,16 +159,26 @@ for h in "${headers[@]}"; do
"$tmp/probe.c" 2>/dev/null || continue
probed=$((probed + 1))
"${cc_argv[@]}" -w -o "$tmp/probe" "$tmp/probe.o" "$lib" 2>/dev/null ||
leaks="$leaks $b:$sym"
leaks+=("$b:$sym")
done < <(comm -12 "$tmp/hidden" "$tmp/ids")
done
echo "linked $probed reachable symbol(s) from ${#headers[@]} installed headers" \
"($n_hidden hidden, $n_exported exported)"
[ "$probed" -ge 5 ] || fail "only $probed symbols reached the link probe, the candidate list is broken"
[ "${leaks#* "$canary":"$neg"}" != "$leaks" ] ||
fail "the synthetic $canary:$neg leak went unreported, the candidate list is broken"
leaks=${leaks/ "$canary":"$neg"/}
[ -z "$leaks" ] || fail "installed headers declare symbols $lib does not export:$leaks"
for sym in "${canaries[@]}"; do
case " ${leaks[*]} " in
*" $canary:$sym "*) ;;
*) fail "the synthetic $canary:$sym leak went unreported, the candidate list is broken" ;;
esac
done
real=()
for leak in "${leaks[@]}"; do
case $leak in
"$canary":*) ;;
*) real+=("$leak") ;;
esac
done
[ "${#real[@]}" -eq 0 ] || fail "installed headers declare symbols $lib does not export: ${real[*]}"
exit 0

View File

@@ -214,8 +214,9 @@ def perms_of(wf, job):
WANT_ENV = {
"WATCHDOG_TOKEN": "${{ secrets.GITHUB_TOKEN }}",
"WATCHDOG_REPO": "${{ github.repository }}",
# The merge commit, which no PR checks UI reads.
"WATCHDOG_SHA": "${{ github.sha }}",
# The PR head: statuses on the merge commit are GC'd, and they are the only
# trace a lost runner leaves (#1228).
"WATCHDOG_SHA": "${{ github.event.pull_request.head.sha || github.sha }}",
}
def audit(wf):
@@ -264,7 +265,7 @@ def mutate(wf, kind):
elif kind == "token":
suite_steps(wf)[0]["env"]["WATCHDOG_TOKEN"] = "${{ secrets.WATCHDOG_PAT }}"
elif kind == "sha":
suite_steps(wf)[0]["env"]["WATCHDOG_SHA"] = "${{ github.event.pull_request.head.sha }}"
suite_steps(wf)[0]["env"]["WATCHDOG_SHA"] = "${{ github.sha }}"
elif kind == "context":
suite_steps(wf)[0]["env"]["WATCHDOG_CONTEXT"] = "windows-suite"
elif kind == "url":
@@ -555,6 +556,12 @@ backoff_leg() {
echo "$calls calls and $lines log lines against an API rejecting every one: nothing backs off"
return 1
fi
# Every attempt here is refused, so each one after the first must say how many
# went missing: x= is what separates a stopped box from a network that healed.
grep -q ' x=[1-9]' "$posts" || {
echo "no posted status counted the failures before it: $(cat "$posts")"
return 1
}
}
fullstdout_leg() {
@@ -609,9 +616,18 @@ for psrun in "${psruns[@]}"; do
# shellcheck disable=SC2016
mutate tail-always-ok 's/\$r = @{ Ok = \$false;/$r = @{ Ok = $true;/' 'reads as one that was read'
mutate counters-empty "s/return (\$f -join ' ')/return ''/" 'counters are not key=value'
# shellcheck disable=SC2016
mutate lag-not-measured "s/'l={0}' -f \$LagMs/'l={0}' -f 0/" 'loop lag is not what the caller measured'
# shellcheck disable=SC2016
mutate failed-not-reported "s/'x={0}' -f \$Failed/'x={0}' -f 0/" 'failed-post count is not what the caller'
# shellcheck disable=SC2016
mutate tcp-total-not-delta 's/(\$Cur\[0\] - \$Prev\[0\])/($Cur[0])/' 'total was reported where the delta'
# shellcheck disable=SC2016
mutate lag-not-a-peak 's/if (\$lag -gt \$Peak) { return \$lag }/if ($false) { return $lag }/' \
'overshot by 200ms'
# The production cadence: no leg below runs without a schedule of its own.
# shellcheck disable=SC2016
mutate default-cadence 's/\[int\]\$IntervalSeconds = 30/[int]$IntervalSeconds = 3000/' \
mutate default-cadence 's/\[int\]\$IntervalSeconds = 15/[int]$IntervalSeconds = 1500/' \
'default status cadence'
# shellcheck disable=SC2016
mutate default-poll 's/\[int\]\$PollSeconds = 5/[int]$PollSeconds = 50/' 'default poll'
@@ -714,6 +730,10 @@ for psrun in "${psruns[@]}"; do
throttle 'a landed post throttles the next'
# shellcheck disable=SC2016
mutate_leg backoff-never-skips 's/if (\$skip -gt 0)/if ($false)/' backoff 'nothing backs off'
# shellcheck disable=SC2016
mutate_leg failures-not-counted \
's/if (\$ok) { \$failed = 0; \$lagMax = 0 } else { \$failed++ }/$failed = 0/' \
backoff 'counted the failures before it'
if test "$devfull" -eq 1; then
mutate_leg log-write-fatal 's/try { \(Write-Host .*\) } catch { }/\1/' \
fullstdout 'took the loop with it'

View File

@@ -72,6 +72,45 @@ kill_tree 99
test "$(cat "$tmp/killed")" = '/F /T /PID 4242' || fail "kill_tree with a winpid ran: $(cat "$tmp/killed")"
win_pid() { :; }
# The image read while the target was alive: a freed winpid can already be a
# stranger's, and /T would take its children too (#1228).
: >"$tmp/killed"
kill_tree 99 4242 PROXYTRACK.EXE
test "$(cat "$tmp/killed")" = '/F /T /PID 4242' ||
fail "a verified tree kill did not run: $(cat "$tmp/killed")"
: >"$tmp/killed"
out=$(kill_tree 99 4242 python.exe)
test ! -s "$tmp/killed" || fail "pid 4242 was killed as a python.exe: $(cat "$tmp/killed")"
grep -q '::warning::pid 4242 no longer runs python.exe' <<<"$out" ||
fail "the skipped kill was not reported: $out"
# Gone from the table entirely, which is what a freed winpid usually looks like.
: >"$tmp/killed"
kill_tree 99 4343 proxytrack.exe >/dev/null
test ! -s "$tmp/killed" || fail "a pid tasklist does not list was killed: $(cat "$tmp/killed")"
# A stranger's pid leaves the caller with no target, so the serial runner's last
# resort still applies: skipping it too would leave the engines running.
: >"$tmp/killed"
HTTRACK_EXCLUSIVE_HOST=1 kill_tree 99 4242 python.exe >/dev/null
got=$(sort "$tmp/killed")
test "$got" = "$want" || fail "an unverified pid skipped the last-resort sweep: $got"
# Graded on the order, since only a winpid read while the target lived names it.
: >"$tmp/killed"
: >"$tmp/order"
kill() { echo "kill $*" >>"$tmp/order"; }
win_capture() {
echo "capture $*" >>"$tmp/order"
WIN_PID=4242 WIN_IMAGE=proxytrack.exe
}
# The pid is fictional, and reap_bounded polls it through the kill stub above.
reap_bounded() { :; }
stop_server 99
got=$(tr '\n' ' ' <"$tmp/order")
test "$got" = 'capture 99 kill 99 ' || fail "stop_server did not capture before signalling: $got"
test "$(cat "$tmp/killed")" = '/F /T /PID 4242' ||
fail "stop_server did not tree-kill what it captured: $(cat "$tmp/killed")"
unset -f kill win_capture reap_bounded
: >"$tmp/killed"
out=$(reap_leftover_processes 99_probe.test)
grep -q '99_probe.test left processes behind' <<<"$out" || fail "the leak was not attributed: $out"

View File

@@ -6,6 +6,10 @@
# a break needing three headers, or a macro the consumer defined first, is out
# of reach here. The sweep is shared with the MSVC job, which has no automake to
# install with and so stages the same list out of DevIncludes_DATA (#1153).
#
# n^2 compiles is real work, not a wedge: emulated, it needs more than the suite's
# default budget, and the sweep paces itself against whatever is left of this one.
# TEST_TIMEOUT_AT_LEAST: 900
set -euo pipefail
@@ -69,7 +73,10 @@ done
sweep_argv=(--headers-dir "$tmp/include/httrack" --cc "${CC:-cc}" --cxx "$cxx")
[ "${#cpp_argv[@]}" -eq 0 ] || sweep_argv+=(-- "${cpp_argv[@]}")
bash "$testdir/install-headers-sweep.sh" "${sweep_argv[@]}" ||
fail "installed headers do not survive every include order"
rc=0
bash "$testdir/install-headers-sweep.sh" --budget "$(budget_left)" "${sweep_argv[@]}" || rc=$?
# 77 is the sweep giving up on a host too slow to finish it, not a broken header.
[ "$rc" -ne 77 ] || exit 77
[ "$rc" -eq 0 ] || fail "installed headers do not survive every include order"
exit 0

View File

@@ -0,0 +1,243 @@
#!/bin/bash
#
# An abandoned htsserver has to stop on its own: it holds its payload open, and
# on macOS that payload is a disk image the user then cannot eject.
set -euo pipefail
# shellcheck source=tests/webhttracklib.sh
. "$(dirname "$0")/webhttracklib.sh"
htsserver_require
# Short enough to sit in the suite, long enough that a loaded parallel run
# cannot starve a ping past it. The server derives the leave grace from it.
timeout=10
grace=2
work=$(mktemp -d "${TMPDIR:-/tmp}/webhttrack_life.XXXXXX") || fail "no tmpdir"
pinger=
csrv=
sleeper=
cleanup() {
htsserver_cleanup
for p in "${pinger}" "${csrv}" "${sleeper}"; do
test -z "${p}" || kill -9 "${p}" 2>/dev/null || true
done
wait "${pinger}" "${csrv}" "${sleeper}" 2>/dev/null || true
rm -rf "${work}"
}
cleanup_push cleanup
export HOME="${work}"
# Each case owns a server, so save its handle: the library keeps only the last.
# $1, when given, is the pid to name as the launcher instead of this shell.
start_case() {
htsserver_start --home "${work}" -- \
--ppid "${1:-$$}" --ping-timeout "${timeout}"
test -n "${HTS_PID}" || skip "this platform announces no server pid"
}
get() { "${HTS_PYTHON}" "${testdir}/httpclient.py" --port "$1" --path "$2"; }
ping() { get "$1" "/ping?w=$2&t=${RANDOM}${3:+&$3}" >/dev/null; }
# The session id every UI page carries, which the server demands of a farewell.
page_sid() {
firstline "$(get "$1" /server/index.html |
sed -n 's/.*name="sid" value="\([0-9a-f]*\)".*/\1/p')"
}
# A farewell as the page sends one: a POST holding that id.
bye() {
"${HTS_PYTHON}" "${testdir}/httpclient.py" --port "$1" \
--path "/ping?w=$2&t=${RANDOM}&e=bye" --field "sid=$3" >/dev/null
}
# Wait up to $2 seconds for $1 to go away.
died_within() {
local pid=$1 limit=$2 start=$SECONDS
while kill -0 "${pid}" 2>/dev/null; do
test "$((SECONDS - start))" -lt "${limit}" || return 1
poll_wait 0.2
done
}
printf '[an abandoned WebHTTrack server stops on its own] ..\t'
# The page and the server agree on one wire format, and only the page half runs
# in a browser: a client that stopped naming its window, or spelled the farewell
# differently, would leave every case below testing the server against itself.
js="${HTS_DISTDIR}/html/server/ping.js"
grep -qF '"/ping?w=" + PING_WINDOW' "${js}" || fail "ping.js stopped naming its window"
grep -qF 'ping_url("e=bye")' "${js}" || fail "ping.js stopped saying goodbye"
grep -qF '"sid=" + encodeURIComponent(sid)' "${js}" ||
fail "ping.js stopped signing its goodbye, which the server then ignores"
# 1. The heartbeat must never be answered from a cache: a reply the browser
# reuses is one the server never sees, and silence is what kills it.
start_case
bye_pid=${HTS_PID}
bye_port=${HTS_PORT}
sid=$(page_sid "${bye_port}")
test "${#sid}" -eq 32 || fail "no session id on the wizard's first page"
reply=$(get "${bye_port}" '/ping?w=w1')
grep -q '^HTTP/1\.0 200 ' <<<"${reply}" || fail "no pong: $(head -1 <<<"${reply}")"
grep -qi '^Cache-Control:.*no-cache' <<<"${reply}" ||
fail "the heartbeat is cacheable: ${reply}"
# 2. Closing one of two windows must not end a session the other is still using.
# A hidden tab has its timers throttled to as little as one wake-up a minute, so
# this cannot rest on the survivor answering inside the grace.
ping "${bye_port}" w2
bye "${bye_port}" w1 "${sid}"
sleep $((grace + 2))
kill -0 "${bye_pid}" 2>/dev/null ||
fail "closing one window ended a session another window still had open"
# 3. An unsigned farewell is not one: /ping is a GET, so it clears neither the
# session-id nor the Origin gate, and any local process or visited page can send
# it. Only a heartbeat may go unproven, and a heartbeat only extends a life.
ping "${bye_port}" w2 e=bye
sleep $((grace + 2))
kill -0 "${bye_pid}" 2>/dev/null ||
fail "an unauthenticated goodbye ended the session"
# 4. A flood of window ids must not push a real window out of the table: with
# the last real one evicted, saying goodbye to the flood would end the session.
# One more than the table holds, so the refusal itself is exercised.
for i in $(seq 0 16); do
ping "${bye_port}" "f${i}"
done
for i in $(seq 0 16); do
bye "${bye_port}" "f${i}" "${sid}"
done
sleep $((grace + 2))
kill -0 "${bye_pid}" 2>/dev/null ||
fail "a flood of window ids evicted the real window and ended the session"
# 5. The last window leaving takes the server with it, well inside the idle
# timeout that would otherwise apply.
bye "${bye_port}" w2 "${sid}"
bye_at=${SECONDS}
died_within "${bye_pid}" $((timeout - 1)) ||
fail "the server outlived its last window by $((SECONDS - bye_at))s"
test "$((SECONDS - bye_at))" -le $((grace + 3)) ||
fail "the last window took $((SECONDS - bye_at))s, past the ${grace}s grace"
# The four cases below share one wait, so they cost one timeout, not four.
# 6. A window that stops pinging without a goodbye has crashed with its browser.
start_case
lost_pid=${HTS_PID}
ping "${HTS_PORT}" w1
lost_at=${SECONDS}
# 7. A window that keeps pinging keeps its server, past that same deadline.
start_case
live_pid=${HTS_PID}
live_port=${HTS_PORT}
live_at=${SECONDS}
pingfail="${work}/pingfail"
(
while :; do
ping "${live_port}" w1 || echo failed >>"${pingfail}"
poll_wait 0.5
done
) &
pinger=$!
# 8. A client that never pings at all is a browser too old for the heartbeat, or
# one with scripting off, and may be a user reading the page: only the
# launcher's death may end that session, and nothing here kills this shell.
start_case
quiet_pid=${HTS_PID}
quiet_at=${SECONDS}
get "${HTS_PORT}" /server/index.html >/dev/null
# 9. ..and when that launcher does die, the same session ends: it is the only
# signal such a browser produces. A process of our own stands in for it.
sleep 600 &
sleeper=$!
start_case "${sleeper}"
legacy_pid=${HTS_PID}
get "${HTS_PORT}" /server/index.html >/dev/null
legacy_at=${SECONDS}
kill "${sleeper}" 2>/dev/null || true
wait "${sleeper}" 2>/dev/null || true # absorb bash's async "Terminated" notice
sleeper=
# The crashed window must outlive its setup, or the floor asserted below could
# be met by the time these four servers took to start.
kill -0 "${lost_pid}" 2>/dev/null ||
fail "the idle timeout fired during setup, $((SECONDS - lost_at))s in"
died_within "${lost_pid}" $((timeout * 3)) ||
fail "a server whose window stopped pinging survived $((SECONDS - lost_at))s"
test "$((SECONDS - lost_at))" -ge $((timeout - 2)) ||
fail "the idle timeout fired after $((SECONDS - lost_at))s, under ${timeout}s"
died_within "${legacy_pid}" $((timeout * 3)) ||
fail "a server outlived its launcher by $((SECONDS - legacy_at))s"
# Both survivors started after the crashed one, so its death is too early to
# judge them by. Two timeouts, not one: a window whose refresh stopped working
# would still be inside its first.
while test "$((SECONDS - live_at))" -le $((timeout * 2)) ||
test "$((SECONDS - quiet_at))" -le $((timeout * 2)); do
poll_wait 0.5
done
# Aliveness first: a server that dies also fails the pings aimed at it, and the
# probe's own failure would then be the only thing reported.
kill -0 "${live_pid}" 2>/dev/null || fail "a pinged server was killed anyway"
kill -0 "${quiet_pid}" 2>/dev/null ||
fail "a server whose client never pings was killed under a live launcher"
! test -f "${pingfail}" || fail "the probe's own pings failed; nothing was proven"
kill "${pinger}" 2>/dev/null || true
pinger=
# 10. Closing the window while a mirror runs must not take the mirror down: it
# may have hours of crawling behind it. The veto has to lift when the crawl
# ends, or the server it saved becomes immortal.
start_case
crawl_pid=${HTS_PID}
crawl_port=${HTS_PORT}
clog="${work}/content.log"
"${HTS_PYTHON}" "${testdir}/local-server.py" --root "${work}" >"${clog}" 2>&1 &
csrv=$!
cport=$(discover_server_port "${clog}" "${csrv}") || fail "no content server"
sid=$(page_sid "${crawl_port}")
test "${#sid}" -eq 32 || fail "no session id to start a mirror with"
# Its index links one page that sleeps 5s, so the crawl outlasts the grace and
# still ends inside the test.
"${HTS_PYTHON}" "${testdir}/httpclient.py" --port "${crawl_port}" \
--path /step4.html --field "sid=${sid}" --field "path=${work}" \
--field projname=crawl --field winprofile=x --field command_do=start \
--field "command=httrack --quiet --robots=0 http://127.0.0.1:${cport}/abortpurge/index.html -O ${work}/crawl" \
>/dev/null
ping "${crawl_port}" w1
bye "${crawl_port}" w1 "${sid}"
sleep $((grace + 2))
kill -0 "${crawl_pid}" 2>/dev/null ||
fail "the crawling server quit, taking its mirror with it"
# A crawl that never started would leave the veto unexercised, and the survival
# above would prove nothing.
test -d "${work}/crawl/hts-cache" || fail "no mirror ever started: $(cat "${clog}")"
died_within "${crawl_pid}" $((timeout * 3)) ||
fail "the server never left once its mirror had finished"
# 11. The wizard's own Quit button is the other way out, and the only one that
# leaves through smallserver(), which has to report the server it did create.
start_case
quit_pid=${HTS_PID}
sid=$(page_sid "${HTS_PORT}")
test "${#sid}" -eq 32 || fail "no session id to quit with"
"${HTS_PYTHON}" "${testdir}/httpclient.py" --port "${HTS_PORT}" \
--path /server/exit.html --field "sid=${sid}" --field command=quit >/dev/null
died_within "${quit_pid}" "${grace}" || fail "Quit did not stop the server"
! grep -q 'Unable to create the server' "${HTS_LOG}" ||
fail "a clean quit reported a server it could not create: $(cat "${HTS_LOG}")"
htsserver_stop
htsserver_assert_reaped
echo OK

View File

@@ -0,0 +1,16 @@
#!/bin/bash
#
# The default footer carries named fields, so a mirrored page must show the
# crawled URL and date, not a literal "{url}" (what the legacy positional model
# emits for a template it does not understand).
set -euo pipefail
: "${top_srcdir:=..}"
bash "$top_srcdir/tests/local-crawl.sh" \
--files 5 --errors 0 \
--file-matches 'simple/basic.html' \
"<!-- Mirrored from http://127\.0\.0\.1:[0-9]+/simple/basic\.html by HTTrack Website Copier/[^ ]+ \[XR&CO'[0-9]{4}\], [A-Z][a-z]{2}, [0-9]{2} [A-Z][a-z]{2} [0-9]{4} [0-9]{2}:[0-9]{2}:[0-9]{2} GMT -->" \
--file-not-matches 'simple/basic.html' '\{(url|date)\}' \
httrack 'BASEURL/simple/basic.html'

View File

@@ -0,0 +1,39 @@
#!/bin/bash
#
# WebHTTrack prefills the engine's default footer, and a new project must get
# the named fields (see HTS_DEFAULT_FOOTER on why a stray "%s" is fatal).
set -euo pipefail
# shellcheck source=tests/webhttracklib.sh
. "$(dirname "$0")/webhttracklib.sh"
htsserver_require
work=$(mktemp -d "${TMPDIR:-/tmp}/webhttrack_footer.XXXXXX") || fail "no tmpdir"
cleanup() {
htsserver_cleanup
rm -rf "${work}"
}
cleanup_push cleanup
printf '[a fresh profile prefills the named-field footer] ..\t'
# An isolated HOME: a stored ~/.httrack.ini would answer with its own footer.
mkdir -p "${work}/websites"
htsserver_start --home "${work}"
reply=$(htsserver_get /server/option6.html)
grep -q '^HTTP/1.[01] 200' <<<"${reply}" || fail "option6.html: ${reply}"
value=$(firstline "$(sed -n 's/.*name="footer" value="\([^"]*\)".*/\1/p' <<<"${reply}")")
test -n "${value}" || fail "option6.html serves no footer field: ${reply}"
case ${value} in
*'%s'*) fail "the prefilled footer is still positional: ${value}" ;;
esac
grep -q '{url}.*{date}' <<<"${value}" || fail "prefilled footer: ${value}"
htsserver_stop
# A leaked server wedges the parallel harness behind a green log.
htsserver_assert_reaped
echo OK

View File

@@ -0,0 +1,50 @@
#!/bin/bash
#
set -euo pipefail
# shellcheck source=tests/testlib.sh
. "$(dirname "$0")/testlib.sh"
# #1117: the self-test asserts the scopes and the patterns; this feeds the pair
# a scope answer emits back through the matcher that authorizes a link.
# The front end enumerates by looping until the engine stops answering. Strip
# the CRs: MSYS hands the engine a text-mode stdout and $(...) eats only the
# trailing newline, so an assertion spanning several lines keeps the interior
# ones. Every other assert_selftest here compares a single line and cannot see
# this.
want="0 download.example.co.uk
1 example.co.uk
2 co.uk"
got=$(httrack -O /dev/null -#test=wizardscope download.example.co.uk/x | tr -d '\r')
test "$got" = "$want" || fail "wizardscope enumeration: got [$got]"
# widening stops before a bare TLD, and an IP literal offers nothing at all
assert_selftest "0 example.com" wizardscope example.com/x
assert_selftest "" wizardscope 192.168.1.1/x
# answer 1000+1 is "example.co.uk and every host below it": two filters, since
# the starred one misses the apex
sub=$(httrack -O /dev/null -#test=wizardfilter 1001 www.example.co.uk /x)
apex=$(httrack -O /dev/null -#test=wizardfilter 1001 www.example.co.uk /x 0 1)
test "$sub" = "+*.example.co.uk/*" || fail "subdomain filter: got [$sub]"
test "$apex" = "+example.co.uk/*" || fail "apex filter: got [$apex]"
for host in www.example.co.uk a.b.example.co.uk; do
assert_selftest "$host/x does match ${sub#+}" filter "${sub#+}" "$host/x"
done
assert_selftest "example.co.uk/x does NOT match ${sub#+}" filter "${sub#+}" example.co.uk/x
assert_selftest "example.co.uk/x does match ${apex#+}" filter "${apex#+}" example.co.uk/x
# neither half may leak past the domain boundary
for bad in notexample.co.uk/x example.co.uk.evil.com/x; do
assert_selftest "$bad does NOT match ${sub#+}" filter "${sub#+}" "$bad"
assert_selftest "$bad does NOT match ${apex#+}" filter "${apex#+}" "$bad"
done
# the exclude range emits the same pair negated
assert_selftest "-*.example.co.uk/*" wizardfilter 2001 www.example.co.uk /x
assert_selftest "wizardscope self-test OK" wizardscope
assert_selftest "wizardfilter self-test OK" wizardfilter

View File

@@ -0,0 +1,15 @@
#!/bin/bash
#
set -euo pipefail
# shellcheck source=tests/testlib.sh
. "$(dirname "$0")/testlib.sh"
# The verdict half of a wizard answer, driven without the interactive callback.
assert_selftest "forbidden=1 stop=1 prio=42" wizardverdict -1
assert_selftest "forbidden=0 stop=0 prio=42" wizardverdict 6
# every answer, both scope ranges, and a link the crawl had already refused
assert_selftest "wizardverdict self-test OK" wizardverdict

View File

@@ -39,15 +39,8 @@ test "$((SECONDS - start))" -lt 15 || fail "watchdog fired late"
rc=0
if is_windows; then
# Existence by exact Windows PID, not a global ping.exe count: the timing
# sub-test above leaves a still-dying ping that a count would race. Plain
# tasklist, no switches (the workflow's MSYS2_ARG_CONV_EXCL='*' mangles a
# //FI filter arg into a silent no-match); $2 is the PID, and $1 must be
# ping.exe too, since Windows hands a freed PID straight back out. Folded
# case, as the tasklist matchers in proclib.sh already are.
alive() {
tasklist 2>/dev/null |
awk -v p="$1" 'tolower($1) == "ping.exe" && $2 == p {f = 1} END {exit !f}'
}
# sub-test above leaves a still-dying ping that a count would race.
alive() { win_pid_runs "$1" ping.exe; }
# alive() is a conjunction now, and one that never matches would call every
# survivor reaped. Prove it fires on a live ping, reached the same way.
ping -n 20 127.0.0.1 >/dev/null 2>&1 &
@@ -55,6 +48,12 @@ if is_windows; then
cw=$(cat "/proc/$cpid/winpid" 2>/dev/null)
test -n "$cw" || fail "could not read a live ping's Windows PID"
alive "$cw" || fail "alive() cannot see a running ping.exe (pid $cw)"
# What kill_tree checks before firing (#1228), on a live process: /proc must
# name the image tasklist answers with, or the check passes nothing on.
win_capture "$cpid"
test "$WIN_PID" = "$cw" || fail "win_capture read winpid '$WIN_PID', /proc says $cw"
win_pid_runs "$cw" "$WIN_IMAGE" || fail "tasklist does not call pid $cw a '$WIN_IMAGE'"
! win_pid_runs "$cw" no-such-image.exe || fail "win_pid_runs accepts any image at all"
kill_tree "$cpid" "$cw"
wait "$cpid" 2>/dev/null || true
# The grandchild ping records its own Windows PID: non-empty proves it ran

View File

@@ -73,14 +73,18 @@ ci_start_native_watchdog() {
# End the step, announcing $2 first: the kill runs no EXIT trap, so an unexplained
# death is all the log would otherwise hold.
ci_heartbeat_kill() {
local main=$1
local main=$1 winpid winimage
ci_annotate error "suite watchdog" "$2"
# Ahead of the kill, which runs no EXIT trap: an orphan would outlive the
# step and overwrite its last status with a frozen tail.
test -z "${watchdog:-}" || kill_pid "$watchdog"
# Read before the two kills below, which would leave the winpid naming
# whoever Windows hands the number to next (#1228).
win_capture "$main"
winpid=$WIN_PID winimage=$WIN_IMAGE
# Direct first: kill_tree may reap this watchdog before its own root (#953).
kill_pid "$main"
kill_tree "$main"
kill_tree "$main" "$winpid" "$winimage"
}
ci_suite_heartbeat() {

View File

@@ -3,7 +3,8 @@
# channel that outlives a dead runner. Every status carries the same state.
param(
[string]$ProgressLog = '',
[int]$IntervalSeconds = 30,
# 15s, not 30: a lost runner dies inside a single status period (#1228).
[int]$IntervalSeconds = 15,
[int]$PollSeconds = 5,
# Cannot outlive the step, whatever the caller forgets to kill.
[int]$MaxSeconds = 2700,
@@ -48,19 +49,40 @@ function Format-WatchdogStatus {
$q = '?'
if ($Static -ge 0) { $q = [string]$Static }
$t = ($InFlight -replace '\s+', ' ').Trim()
if ($t.Length -gt 46) { $t = $t.Substring(0, 46) }
if ($t.Length -gt 30) { $t = $t.Substring(0, 30) }
$s = 't={0}s q={1}s {2} | {3}' -f $Elapsed, $q, $t, $Counters
if ($s.Length -gt 140) { $s = $s.Substring(0, 140) }
return $s
}
# The worse of the running peak and how much longer the last iteration took than
# the poll it asked for. Peak, not last: a status covers several iterations.
function Get-MaxLag {
param([int]$Peak, [double]$Elapsed, [double]$Since, [int]$Poll)
$lag = [int]($Elapsed - $Since - $Poll * 1000)
if ($lag -gt $Peak) { return $lag }
return $Peak
}
# The TCP counters are cumulative since boot, so only the change over a status
# period says what the suite did; $Prev is $null on the first sample.
function Get-TcpDelta {
param($Prev, $Cur)
if ($null -eq $Prev) { return 'n=? f=?' }
return 'n={0} f={1}' -f ($Cur[0] - $Prev[0]), ($Cur[1] - $Prev[1])
}
# --- probes ------------------------------------------------------------------
$script:LastTcp = $null
# One try/catch per counter: a probe that fails costs its own field, not the loop.
# In-process only. A CIM query is richer, but its connect to a wedged WMI service
# is unbounded, and would hang the one reporter still standing.
function Get-WatchdogCounters {
param([int]$LagMs = 0, [int]$Failed = 0)
$f = New-Object System.Collections.ArrayList
$ps = @()
try {
$ps = @(Get-Process)
[void]$f.Add('p={0}' -f $ps.Count)
@@ -68,8 +90,31 @@ function Get-WatchdogCounters {
} catch { [void]$f.Add('p=? h=?') }
try {
$drive = New-Object System.IO.DriveInfo($env:SystemDrive + '\')
[void]$f.Add('d={0}' -f [int]($drive.AvailableFreeSpace / 1MB))
[void]$f.Add('d={0}' -f [int]($drive.AvailableFreeSpace / 1GB))
} catch { [void]$f.Add('d=?') }
# The ramp detector: starvation is what makes a poll overshoot.
[void]$f.Add('l={0}' -f $LagMs)
# Box-stop against network-break: the status that lands after an outage says
# how many it swallowed, and a box that stopped never lands one.
[void]$f.Add('x={0}' -f $Failed)
try {
# One GetTcpStatisticsEx; GetActiveTcpConnections() would allocate per socket.
$t = [System.Net.NetworkInformation.IPGlobalProperties]::GetIPGlobalProperties().GetTcpIPv4Statistics()
$cur = @($t.ConnectionsInitiated, ($t.FailedConnectionAttempts + $t.ResetConnections))
[void]$f.Add((Get-TcpDelta $script:LastTcp $cur))
$script:LastTcp = $cur
[void]$f.Add('e={0}' -f $t.CurrentConnections)
} catch { [void]$f.Add('n=? f=? e=?') }
try {
if ($ps.Count -lt 1) { throw 'no process list' }
[void]$f.Add('m={0}' -f [int]((($ps | Measure-Object -Property WorkingSet64 -Sum).Sum) / 1MB))
[void]$f.Add('c={0}' -f [int]((($ps | Measure-Object -Property PagedMemorySize64 -Sum).Sum) / 1MB))
# Its own field: the agent is what stops reporting, and the box total hides it.
$agent = @($ps | Where-Object { $_.Name -eq 'Runner.Worker' })
$ws = 0
if ($agent.Count -gt 0) { $ws = [int]((($agent | Measure-Object -Property WorkingSet64 -Sum).Sum) / 1MB) }
[void]$f.Add('a={0}' -f $ws)
} catch { [void]$f.Add('m=? c=? a=?') }
return ($f -join ' ')
}
@@ -156,14 +201,15 @@ function Invoke-WatchdogSelfTest {
Assert-That ($ko[0] -eq 8 -and $ko[1] -eq 8) 'a repeat rejection does not widen the gap'
$long = '43_local-update-truncate-with-a-very-long-name-indeed.test'
$line = Format-WatchdogStatus 812 41 $long 'p=118 h=41230 d=13210'
# The widest real counter line, so a status that fits here fits on the runner.
$line = Format-WatchdogStatus 2700 2700 $long 'p=201 h=54598 d=85 l=120 x=0 n=412 f=0 e=180 m=3100 c=4200 a=210'
Assert-That ($line.Length -le 140) ('status description is {0} characters' -f $line.Length)
Assert-That ($line -like 't=812s q=41s 43_local-update-truncate*') ('status leads with the wrong fields: {0}' -f $line)
Assert-That ($line -like '*d=13210') 'the counters did not survive a long test name'
Assert-That ($line -like 't=2700s q=2700s 43_local-update-truncate*') ('status leads with the wrong fields: {0}' -f $line)
Assert-That ($line -like '*a=210') 'the counters did not survive a long test name'
# -match, not -like: '?' is a wildcard there, so q=0s would satisfy it too.
Assert-That ((Format-WatchdogStatus 8 -1 'x' 'y') -match '^t=8s q=\?s x \| y$') 'an unknown staticness reads as a number'
$clip = Format-WatchdogStatus 1 2 ('x' * 80) 'c'
Assert-That ($clip -match '^t=1s q=2s x{46} \| c$') ('the in-flight name was not clipped to 46: {0}' -f $clip)
Assert-That ($clip -match '^t=1s q=2s x{30} \| c$') ('the in-flight name was not clipped to 30: {0}' -f $clip)
$wide = Format-WatchdogStatus 1 2 ('x' * 300) ('y' * 300)
Assert-That ($wide.Length -le 140) ('an oversized status was not clipped: {0}' -f $wide.Length)
# Cut from the tail: the head carries the fields a wedge is read for.
@@ -181,15 +227,23 @@ function Invoke-WatchdogSelfTest {
# Space-separated key=value: the counters share the 140-char description with
# the fields a wedge is read for, and '?' from a failed probe is a value.
$c = Get-WatchdogCounters
$c = Get-WatchdogCounters 120 3
Assert-That ($c -match '^[a-z]+=\S+( [a-z]+=\S+)*$') ('the counters are not key=value pairs: {0}' -f $c)
foreach ($k in 'p', 'h', 'd') {
foreach ($k in 'p', 'h', 'd', 'l', 'x', 'n', 'f', 'e', 'm', 'c', 'a') {
Assert-That ($c -match ('(^| ){0}=' -f $k)) ('the counters dropped {0}=: {1}' -f $k, $c)
}
Assert-That ($c.Length -le 60) ('the counters take {0} of the 140 characters' -f $c.Length)
Assert-That ($c -match '(^| )l=120( |$)') ('the loop lag is not what the caller measured: {0}' -f $c)
Assert-That ($c -match '(^| )x=3( |$)') ('the failed-post count is not what the caller passed: {0}' -f $c)
Assert-That ((Get-MaxLag 0 6200 1000 5) -eq 200) 'a poll that overshot by 200ms was not measured'
Assert-That ((Get-MaxLag 500 6200 1000 5) -eq 500) 'a smaller lag replaced the peak'
Assert-That ((Get-MaxLag 0 5900 1000 5) -eq 0) 'a poll that returned early reported a lag'
Assert-That ((Get-TcpDelta $null @(70, 9)) -eq 'n=? f=?') 'a first sample with no predecessor reported a delta'
Assert-That ((Get-TcpDelta @(64, 7) @(70, 9)) -eq 'n=6 f=2') 'a total was reported where the delta was asked for'
# 140 less the 16 of t=/q= and the 33 a clipped test name and its separator take.
Assert-That ($c.Length -le 91) ('the counters take {0} of the 140 characters' -f $c.Length)
# Nothing else reads these: every other leg passes its own schedule.
Assert-That ($IntervalSeconds -eq 30) ('the default status cadence is {0}s' -f $IntervalSeconds)
Assert-That ($IntervalSeconds -eq 15) ('the default status cadence is {0}s' -f $IntervalSeconds)
Assert-That ($PollSeconds -eq 5) ('the default poll is {0}s' -f $PollSeconds)
Assert-That (-not (Send-WatchdogStatus 'self-test')) 'the self-test can reach the API'
@@ -216,6 +270,9 @@ $movedAt = 0
$postedAt = -$IntervalSeconds
$backoff = 0
$skip = 0
$lagMax = 0
$failed = 0
$tickAt = $sw.Elapsed.TotalMilliseconds
# Guarded like the rest; the launcher waits for this exact line.
try { Write-Host 'watchdog ready' } catch { }
@@ -223,7 +280,12 @@ Write-WatchdogLog ('watching {0} every {1}s' -f $ProgressLog, $IntervalSeconds)
while ($sw.Elapsed.TotalSeconds -lt $MaxSeconds) {
# Measured, never accumulated: starvation is what makes a sleep overshoot.
$now = [int]$sw.Elapsed.TotalSeconds
$ms = $sw.Elapsed.TotalMilliseconds
$now = [int]($ms / 1000)
# Measured at the top, so the lag covers the probes and the post as well as
# the sleep: starvation stretches all three.
$lagMax = Get-MaxLag $lagMax $ms $tickAt $PollSeconds
$tickAt = $ms
try {
$tail = Get-ProgressTail -Path $ProgressLog
if ($tail.Ok -and $tail.Signature -ne $lastSig) {
@@ -234,14 +296,18 @@ while ($sw.Elapsed.TotalSeconds -lt $MaxSeconds) {
$postedAt = $now
$static = -1
if ($tail.Ok) { $static = $now - $movedAt }
$desc = Format-WatchdogStatus $now $static $tail.Line (Get-WatchdogCounters)
$desc = Format-WatchdogStatus $now $static $tail.Line (Get-WatchdogCounters $lagMax $failed)
# Logged whatever the backoff decides: it throttles the API, not the
# artifact, which is all a run whose token cannot post will leave.
Write-WatchdogLog $desc
if ($skip -gt 0) {
$skip--
} else {
$next = Get-NextThrottle (Send-WatchdogStatus $desc) $backoff
$ok = Send-WatchdogStatus $desc
# Cleared together, and only by a status that landed: a peak reached
# while nothing was getting through is what the next one has to carry.
if ($ok) { $failed = 0; $lagMax = 0 } else { $failed++ }
$next = Get-NextThrottle $ok $backoff
$skip = $next[0]
$backoff = $next[1]
}

View File

@@ -14,7 +14,8 @@ set -euo pipefail
usage() {
echo "usage: ${0##*/} {--srcdir DIR [--builddir DIR] | --headers-dir DIR}" \
"[--backend cl|cc] [--cc CMD] [--cxx CMD] [--self-test] [-- CPPFLAGS...]" >&2
"[--backend cl|cc] [--cc CMD] [--cxx CMD] [--budget SECONDS] [--self-test]" \
"[-- CPPFLAGS...]" >&2
exit 1
}
@@ -26,6 +27,7 @@ cc_cmd=""
cxx_cmd=""
cxx_set=0
selftest=0
budget=
extra=()
while [ $# -gt 0 ]; do
case $1 in
@@ -33,6 +35,10 @@ while [ $# -gt 0 ]; do
selftest=1
shift
;;
--budget)
budget=${2-}
shift 2 || usage
;;
--srcdir)
srcdir=${2-}
shift 2 || usage
@@ -237,15 +243,45 @@ fi
began=$SECONDS
bad=0
# Sliced only for a caller that gave a budget: one call per batch cannot be given up on,
# and an emulated compiler needs more time for it than the harness allows a test (#1146).
# Unsliced elsewhere, so the Windows job keeps paying one compiler spawn per batch.
if [ -n "$budget" ] && [ "$budget" -gt 0 ]; then
export HTTRACK_TEST_TIMEOUT=$budget
slices=8
else
slices=1
fi
slice=$(((${#units[@]} + slices - 1) / slices))
# From the slice size, not from $slices: they differ whenever the units do not divide
# evenly, and a step count that outlives the loop leaves the pacer projecting forever.
per=$(((${#units[@]} + slice - 1) / slice))
left=$((${#langs[@]} * ${#modes[@]} * per))
swept=0
for lang in "${langs[@]}"; do
for mode in "${modes[@]}"; do
compile "$lang" "$mode" "${units[@]}" || {
head -40 "$sweep_log" >&2
echo "the headers do not compile as $lang standalone and pairwise ($mode)" >&2
bad=1
}
i=0
while [ "$i" -lt "${#units[@]}" ]; do
step=$SECONDS
chunk=("${units[@]:i:slice}")
swept=$((swept + ${#chunk[@]}))
compile "$lang" "$mode" "${chunk[@]}" || {
head -40 "$sweep_log" >&2
echo "the headers do not compile as $lang standalone and pairwise ($mode)" >&2
bad=1
}
i=$((i + slice))
left=$((left - 1))
# Only while nothing has failed: a skip past a real break would bury it.
[ "$bad" -ne 0 ] || [ -z "$budget" ] ||
skip_if_out_of_budget "$left" "$((SECONDS - step))"
done
done
done
# What reached the compiler, not what was generated: a slice loop that steps past a unit
# would otherwise report the full set and pass.
want=$((${#langs[@]} * ${#modes[@]} * ${#units[@]}))
[ "$swept" -eq "$want" ] || fail "compiled $swept units of $want, the slicing lost some"
echo "swept $n headers standalone and pairwise x ${#modes[@]} bytecode modes x ${langs[*]}" \
"= $((${#modes[@]} * ${#langs[@]} * ${#units[@]})) units in $((SECONDS - began))s with $backend"
[ "$bad" -eq 0 ] || exit 1

View File

@@ -22,19 +22,39 @@ testdir=$(cd "$(dirname "$0")" && pwd)
# (CRAWL_DEADLINE, 180s a pass) -- budget below that and a slow-but-legitimate
# run would be killed. The slowest healthy test measures 39s. A non-numeric or
# absurd value falls back; 0 disables the guard, for use under a debugger.
budget=${HTTRACK_TEST_TIMEOUT:-600}
case "$budget" in
'' | *[!0-9]*) budget=600 ;;
esac
budget=$(budget_secs)
# The test script is the last argument; automake passes no others today.
for path in "$@"; do :; done
name=$(basename "$path")
# A test whose work legitimately outlasts the wedge budget says so in its header
# (269 sweeps n^2 compiles and paces itself inside it). The name carries the rule the
# reader cannot see: it raises the budget, so no test can disarm the guard. Read with
# the shell to keep it off the per-test fork bill, and bounded, since bash's `test`
# errors rather than compares past intmax and would leave the guard unarmed.
if test "$budget" -gt 0 && test -r "$path"; then
read_lines=0
while test "$read_lines" -lt 40 && IFS= read -r line; do
read_lines=$((read_lines + 1))
case "$line" in
'# TEST_TIMEOUT_AT_LEAST: '*)
want=${line#'# TEST_TIMEOUT_AT_LEAST: '}
case "$want" in
'' | *[!0-9]* | ???????*) ;;
*) test "$((10#$want))" -le "$budget" || budget=$((10#$want)) ;;
esac
break
;;
esac
done <"$path"
fi
# Exported so a test can pace itself against the same number (skip_if_out_of_budget)
# instead of being killed halfway.
export HTTRACK_TEST_TIMEOUT="$budget"
test "$budget" -gt 0 || exec "$BASH" "$@"
# The test script is the last argument; automake passes no others today.
for name in "$@"; do :; done
name=$(basename "$name")
# Give the test its own TMPDIR, so the hang dump can salvage exactly this test's
# crawl logs instead of racing (and deleting) a sibling's under "make check -j".
tmproot=${TMPDIR:-/tmp}

View File

@@ -286,8 +286,12 @@ poll_wait() {
# trap, where a survivor would turn a passing test into a harness timeout.
stop_server() {
test -n "${1:-}" || return 0
local winpid winimage
# Before the signal: a winpid read after it can already name a stranger.
win_capture "$1"
winpid=$WIN_PID winimage=$WIN_IMAGE
kill "$1" 2>/dev/null || true
if is_windows; then kill_tree "$1"; fi
if is_windows; then kill_tree "$1" "$winpid" "$winimage"; fi
reap_bounded "$1" || true
return 0
}
@@ -454,6 +458,29 @@ win_pid() {
fi
}
# WIN_PID and WIN_IMAGE for MSYS pid $1, read while it is alive: /proc keeps the
# entry once the process is gone and Windows reissues the number at once, so a
# later read can name a stranger (#1228). Not for a job just backgrounded: until
# its exec lands, tens of milliseconds later, both still name the forking shell.
# Assigned rather than echoed, a command substitution being a fork (#795).
win_capture() { # win_capture <pid>
WIN_PID='' WIN_IMAGE=''
is_windows || return 0
# Unguarded reads: a missing file leaves the empty value set above, and read
# reports EOF on an unterminated line having already assigned it.
{ read -r WIN_PID <"/proc/$1/winpid"; } 2>/dev/null || true
{ read -r WIN_IMAGE <"/proc/$1/winexename"; } 2>/dev/null || true
WIN_IMAGE=${WIN_IMAGE##*[\\/]}
return 0
}
# Whether Windows PID $1 runs image $2. Both columns at once, since either alone
# answers for a recycled PID, and case-folded as the proclib.sh matchers are.
win_pid_runs() { # win_pid_runs <winpid> <image>
tasklist 2>/dev/null |
awk -v p="$1" -v i="$2" 'tolower($1) == tolower(i) && $2 == p { f = 1 } END { exit !f }'
}
# Signal one process, never its descendants: a caller inside the target's own
# tree cannot rely on kill_tree, whose taskkill is then a grandchild of it (#953).
kill_pid() {
@@ -477,11 +504,17 @@ kill_pid() {
# so args pass verbatim and a //T would reach taskkill unfolded and be rejected.
# $2 is that Windows PID when the caller read it while the job was certainly
# alive: /proc/<pid>/winpid is already gone for a job that has just died, and
# without it the only route left is the host-wide sweep below.
# without it the only route left is the host-wide sweep below. $3 is the image it
# ran then: a number that no longer runs it was reissued while we were not
# looking, and naming a stranger is as good as naming nobody (#1228).
kill_tree() {
local pid=$1 winpid=${2:-}
local pid=$1 winpid=${2:-} image=${3:-}
if is_windows; then
test -n "$winpid" || winpid=$(win_pid "$pid")
if test -n "$winpid" && test -n "$image" && ! win_pid_runs "$winpid" "$image"; then
printf '::warning::pid %s no longer runs %s, not killing it\n' "$winpid" "$image"
winpid=
fi
if test -n "$winpid"; then
taskkill /F /T /PID "$winpid" >/dev/null 2>&1 || true
# Last resort, so it is opt-in: it kills every engine and every python on
@@ -539,16 +572,39 @@ EOF
# one step is slower than its neighbours. It asks an ordering of the callers
# instead, expensive steps first, so no step left can outrun the reserve the one
# before it set (#1146).
skip_if_out_of_budget() { # skip_if_out_of_budget <steps left> <seconds the last took>
local budget=${HTTRACK_TEST_TIMEOUT:-600} need=$(($2 + $2 / 2))
# The budget test-timeout.sh enforces, in seconds, 0 being the guard off. The one
# parser: a value bash arithmetic or test would choke on falls back to the default,
# and a leading zero would otherwise read as octal in one place and decimal in the next.
budget_secs() {
local budget=${HTTRACK_TEST_TIMEOUT:-600}
case "$budget" in '' | *[!0-9]* | ???????*) budget=600 ;; esac
echo "$((10#$budget))"
}
case "$budget" in '' | *[!0-9]*) budget=600 ;; esac
skip_if_out_of_budget() { # skip_if_out_of_budget <steps left> <seconds the last took>
local budget need=$(($2 + $2 / 2))
budget=$(budget_secs)
test "$1" -gt 0 && test "$budget" -gt 0 || return 0
test "$((SECONDS + need))" -ge "$budget" || return 0
echo "$1 steps left, the last took ${2}s and the budget is ${budget}s; skipping" >&2
exit 77
}
# Seconds left of the budget, for a child pacing itself against it (269 hands it to
# the sweep). Never below 1 unless the guard is off, when it stays 0.
budget_left() {
local budget left
budget=$(budget_secs)
test "$budget" -gt 0 || {
echo 0
return 0
}
left=$((budget - SECONDS))
test "$left" -ge 1 || left=1
echo "$left"
}
# Collect a killed job, giving up after REAP_GRACE seconds. kill_tree can fail to
# reap a native Windows descendant -- the very case these watchdogs exist for --
# and a bare `wait` then blocks the watchdog itself forever, so the timeout it was

View File

@@ -48,7 +48,12 @@ cd /bld
bash "${GITHUB_WORKSPACE:-/src}/configure"
make -j"$(nproc)"
# The buildd's own invocation, so a failure here is the one it would report.
make check -j"$(nproc)"
rc=0
make check -j"$(nproc)" || rc=$?
# Always, not only where automake prints it: this leg exists to say what an emulated
# host does. A paced-out skip must not read as coverage with no reason given.
cat tests/test-suite.log || true
test "$rc" -eq 0 || exit "$rc"
# make check exits 0 for an all-SKIP run, and this leg skips a lot by design, so
# a container that quietly lost a dependency would report a green covering

View File

@@ -22,7 +22,8 @@
# -o, --outdir DIR output directory (default: <repo>/dist)
# --orig FILE reuse this upstream orig tarball instead of
# regenerating it (required for a Debian revision
# >= 2, whose orig is frozen in the archive)
# >= 2, whose orig is frozen in the archive, and
# whenever debian/patches carries a patch)
# -s, --source-only build only the source package
# -u, --unsigned do not sign anything (implies no release sigs)
# --no-release-artifacts skip the orig tarball .asc/.md5/.sha1
@@ -39,7 +40,9 @@
#
# The Debian revision in debian/changelog decides the orig: revision 1 builds a
# fresh upstream tarball; revision >= 2 must reuse the orig frozen at revision 1
# (the .dsc references it by checksum), so pass it with --orig.
# (the .dsc references it by checksum), so pass it with --orig. debian/patches
# needs the same tarball for a different reason: a patch backported from upstream
# no longer applies to a tree that has the fix, which is what HEAD would give.
#
# SOURCE_DATE_EPOCH is honored for reproducible output.
@@ -127,6 +130,10 @@ main() {
if [[ $unsigned -eq 0 ]]; then
need gpg
[[ -n $key ]] || die "no signing key (pass --key or set DEBSIGN_KEYID, or use --unsigned)"
# Here rather than at debsign, which runs once the tarball is built: a key id gpg
# cannot resolve to a secret key would otherwise cost the whole build first.
gpg --list-secret-keys -- "$key" >/dev/null 2>&1 ||
die "gpg has no secret key for '$key' (an 0x-prefixed full fingerprint is unambiguous)"
fi
local repo
@@ -168,6 +175,13 @@ main() {
die "Debian revision $rev needs --orig FILE (the orig is frozen from revision 1)"
fi
# A quilt patch is written against the orig it is applied to. Once the fix is
# upstream, HEAD already carries it, so a regenerated orig makes the patch fail
# or, worse, apply with fuzz. Unsigned too: this one breaks the build, not policy.
if [[ -z $orig_in && -s $export_dir/debian/patches/series ]]; then
die "debian/patches is not empty, so --orig FILE is required: the orig built from HEAD already carries the patches"
fi
if [[ -n $orig_in ]]; then
info "reusing upstream tarball $orig_in"
cp -- "$orig_in" "$scratch/$orig"