diff options
| author | Dirk Engling <erdgeist@erdgeist.org> | 2026-11-06 14:24:04 +0100 |
|---|---|---|
| committer | Dirk Engling <erdgeist@erdgeist.org> | 2026-11-06 14:24:04 +0100 |
| commit | 355c8ae57d582974f4f6a4ba42f635f2253b84a7 (patch) | |
| tree | 81536ae109b37242217c818ae3a6b2c193fb8ff3 | |
| parent | 367ff65242ca88fd6435d04a577a43cec5de92fe (diff) | |
| -rw-r--r-- | DESIGN | 9 | ||||
| -rw-r--r-- | src/opentracker.c | 10 | ||||
| -rw-r--r-- | src/ot_fullscrape.c | 2 | ||||
| -rw-r--r-- | src/trackerlogic.c | 2 | ||||
| -rw-r--r-- | src/trackerlogic.h | 5 | ||||
| -rw-r--r-- | tests/integration/test_accesslist.py | 4 | ||||
| -rw-r--r-- | tests/integration/test_fullscrape.py | 14 | ||||
| -rw-r--r-- | tests/integration/test_statefile.py | 41 |
8 files changed, 55 insertions, 32 deletions
| @@ -103,6 +103,15 @@ ordinary announce interval is a constant too and an operator has nothing to | |||
| 103 | measure a different value against. Over HTTP a refusal stays a failure | 103 | measure a different value against. Over HTTP a refusal stays a failure |
| 104 | reason, which is the only place a user is told why. | 104 | reason, which is the only place a user is told why. |
| 105 | 105 | ||
| 106 | The state file keeps a wall clock minute, and it is the only thing in the | ||
| 107 | tracker that reads that clock. Everything else runs off a monotonic one, | ||
| 108 | where a time step cannot evict a swarm, but a file outlives the process that | ||
| 109 | wrote it and minutes since this boot mean nothing to the next run. So the | ||
| 110 | dump converts on the way out and load_state converts on the way back in, and | ||
| 111 | what lands on disk is the format opentracker has written for years. A step | ||
| 112 | between writing and reading mis-dates a torrent by the size of the step, | ||
| 113 | which costs it part of its last day and nothing else. | ||
| 114 | |||
| 106 | Startup and teardown order | 115 | Startup and teardown order |
| 107 | -------------------------- | 116 | -------------------------- |
| 108 | 117 | ||
diff --git a/src/opentracker.c b/src/opentracker.c index e467ce6..123f20a 100644 --- a/src/opentracker.c +++ b/src/opentracker.c | |||
| @@ -300,9 +300,13 @@ void load_state(const char *const state_filename) { | |||
| 300 | FILE *state_filehandle; | 300 | FILE *state_filehandle; |
| 301 | char inbuf[512]; | 301 | char inbuf[512]; |
| 302 | ot_hash infohash; | 302 | ot_hash infohash; |
| 303 | uint64_t age, downcount; | 303 | uint64_t touched, downcount; |
| 304 | int consumed; | 304 | int consumed; |
| 305 | 305 | ||
| 306 | /* The file records the minute a torrent was last touched, by the clock that | ||
| 307 | survives a restart */ | ||
| 308 | uint64_t now = (uint64_t)time_wall_minutes(); | ||
| 309 | |||
| 306 | state_filehandle = fopen(state_filename, "r"); | 310 | state_filehandle = fopen(state_filename, "r"); |
| 307 | 311 | ||
| 308 | if (state_filehandle == NULL) { | 312 | if (state_filehandle == NULL) { |
| @@ -318,12 +322,12 @@ void load_state(const char *const state_filename) { | |||
| 318 | continue; | 322 | continue; |
| 319 | i = 2 * sizeof(ot_hash); | 323 | i = 2 * sizeof(ot_hash); |
| 320 | 324 | ||
| 321 | if (inbuf[i++] != ':' || !(consumed = scan_uint(inbuf + i, &age, OT_TORRENT_TIMEOUT))) | 325 | if (inbuf[i++] != ':' || !(consumed = scan_uint(inbuf + i, &touched, now))) |
| 322 | continue; | 326 | continue; |
| 323 | i += consumed; | 327 | i += consumed; |
| 324 | if (inbuf[i++] != ':' || !(consumed = scan_uint(inbuf + i, &downcount, SIZE_MAX))) | 328 | if (inbuf[i++] != ':' || !(consumed = scan_uint(inbuf + i, &downcount, SIZE_MAX))) |
| 325 | continue; | 329 | continue; |
| 326 | add_torrent_from_saved_state(infohash, (ot_time)age, (size_t)downcount); | 330 | add_torrent_from_saved_state(infohash, (ot_time)(now - touched), (size_t)downcount); |
| 327 | } | 331 | } |
| 328 | 332 | ||
| 329 | fclose(state_filehandle); | 333 | fclose(state_filehandle); |
diff --git a/src/ot_fullscrape.c b/src/ot_fullscrape.c index 85be65d..ef39a17 100644 --- a/src/ot_fullscrape.c +++ b/src/ot_fullscrape.c | |||
| @@ -131,7 +131,7 @@ static void fullscrape_write_one( ot_tasktype mode, char **cursor, ot_torrent *t | |||
| 131 | case TASK_FULLSCRAPE_TRACKERSTATE: | 131 | case TASK_FULLSCRAPE_TRACKERSTATE: |
| 132 | hex_from_hash(r, *hash); | 132 | hex_from_hash(r, *hash); |
| 133 | r += 2 * sizeof(ot_hash); | 133 | r += 2 * sizeof(ot_hash); |
| 134 | fmt_append(&r, r_end, ":%zd:%zd\n", (ssize_t)time_since_minutes(torrent->peer_list6->base), down_count); | 134 | fmt_append(&r, r_end, ":%zd:%zd\n", (ssize_t)(time_wall_minutes() - time_since_minutes(torrent->peer_list6->base)), down_count); |
| 135 | break; | 135 | break; |
| 136 | } | 136 | } |
| 137 | *cursor = r; | 137 | *cursor = r; |
diff --git a/src/trackerlogic.c b/src/trackerlogic.c index 9fd6114..2313379 100644 --- a/src/trackerlogic.c +++ b/src/trackerlogic.c | |||
| @@ -45,7 +45,7 @@ void add_torrent_from_saved_state(ot_hash const hash, ot_time age, size_t down_c | |||
| 45 | ot_torrent *torrent; | 45 | ot_torrent *torrent; |
| 46 | ot_vector *torrents_list; | 46 | ot_vector *torrents_list; |
| 47 | 47 | ||
| 48 | if (age < 0 || age > OT_TORRENT_TIMEOUT) | 48 | if (age > OT_TORRENT_TIMEOUT) |
| 49 | return; | 49 | return; |
| 50 | 50 | ||
| 51 | torrents_list = mutex_bucket_lock_by_hash(hash); | 51 | torrents_list = mutex_bucket_lock_by_hash(hash); |
diff --git a/src/trackerlogic.h b/src/trackerlogic.h index d03d3b1..241b6a7 100644 --- a/src/trackerlogic.h +++ b/src/trackerlogic.h | |||
| @@ -114,6 +114,11 @@ static inline ot_time time_since_minutes(ot_time then) { | |||
| 114 | 114 | ||
| 115 | /* Has a deadline the tracker set on itself gone by? */ | 115 | /* Has a deadline the tracker set on itself gone by? */ |
| 116 | static inline int time_is_past(ot_time deadline) { return time_now_seconds() > deadline; } | 116 | static inline int time_is_past(ot_time deadline) { return time_now_seconds() > deadline; } |
| 117 | |||
| 118 | /* The wall clock, which the state file uses and nothing else does: it has to | ||
| 119 | survive a restart, and the clock above counts from boot. */ | ||
| 120 | static inline ot_time time_wall_minutes(void) { return time(NULL) / 60; } | ||
| 121 | |||
| 117 | /* Cleared from the interrupt handler and read by every worker */ | 122 | /* Cleared from the interrupt handler and read by every worker */ |
| 118 | extern _Atomic int g_opentracker_running; | 123 | extern _Atomic int g_opentracker_running; |
| 119 | 124 | ||
diff --git a/tests/integration/test_accesslist.py b/tests/integration/test_accesslist.py index 7d01264..3ed8ddf 100644 --- a/tests/integration/test_accesslist.py +++ b/tests/integration/test_accesslist.py | |||
| @@ -7,6 +7,7 @@ produces: an empty file, and a last line with no newline after it. | |||
| 7 | 7 | ||
| 8 | import os | 8 | import os |
| 9 | import struct | 9 | import struct |
| 10 | import time | ||
| 10 | import unittest | 11 | import unittest |
| 11 | 12 | ||
| 12 | from harness import Tracker, TrackerTestCase | 13 | from harness import Tracker, TrackerTestCase |
| @@ -109,9 +110,10 @@ class ListIsInPlaceBeforeTheStateFile(TrackerTestCase): | |||
| 109 | files = {"blacklist": listing(BLOCKED)} | 110 | files = {"blacklist": listing(BLOCKED)} |
| 110 | 111 | ||
| 111 | def test_a_blacklisted_torrent_in_a_state_file_is_not_restored(self): | 112 | def test_a_blacklisted_torrent_in_a_state_file_is_not_restored(self): |
| 113 | touched = int(time.time()) // 60 - 5 | ||
| 112 | path = os.path.join(self.tracker.dir, "state") | 114 | path = os.path.join(self.tracker.dir, "state") |
| 113 | with open(path, "w") as handle: | 115 | with open(path, "w") as handle: |
| 114 | handle.write("%s:5:7\n%s:5:7\n" % (BLOCKED.hex(), ALLOWED.hex())) | 116 | handle.write("%s:%d:7\n%s:%d:7\n" % (BLOCKED.hex(), touched, ALLOWED.hex(), touched)) |
| 115 | 117 | ||
| 116 | restarted = Tracker(self.config, self.files, extra_args=["-l", path]) | 118 | restarted = Tracker(self.config, self.files, extra_args=["-l", path]) |
| 117 | try: | 119 | try: |
diff --git a/tests/integration/test_fullscrape.py b/tests/integration/test_fullscrape.py index 3150612..45b3ffb 100644 --- a/tests/integration/test_fullscrape.py +++ b/tests/integration/test_fullscrape.py | |||
| @@ -8,6 +8,7 @@ so these also exercise the chunked transfer the ordinary replies never take. | |||
| 8 | 8 | ||
| 9 | import gzip | 9 | import gzip |
| 10 | import struct | 10 | import struct |
| 11 | import time | ||
| 11 | import unittest | 12 | import unittest |
| 12 | 13 | ||
| 13 | from harness import TrackerTestCase, bdecode | 14 | from harness import TrackerTestCase, bdecode |
| @@ -78,15 +79,14 @@ class FullScrape(TrackerTestCase): | |||
| 78 | escaped = line.rsplit(b":", 2)[0] | 79 | escaped = line.rsplit(b":", 2)[0] |
| 79 | self.assertIn(b"%", escaped, "these hashes are mostly unprintable bytes") | 80 | self.assertIn(b"%", escaped, "these hashes are mostly unprintable bytes") |
| 80 | 81 | ||
| 81 | def test_the_state_dump_carries_an_age_rather_than_a_peer_count(self): | 82 | def test_the_state_dump_carries_a_stamp_rather_than_a_peer_count(self): |
| 82 | """What a restart reads back. The second column is how many minutes | 83 | """What a restart reads back. The second column is the minute the |
| 83 | ago the torrent was last touched, not a stamp and not a count of | 84 | torrent was last touched and not a count of anybody, by the wall |
| 84 | anybody: the clock counts from boot, so an absolute time would mean | 85 | clock, which is the only one two runs of the tracker agree on.""" |
| 85 | nothing to the process that reads it.""" | ||
| 86 | entries = self.entries_from_lines(self.scrape_all("/stats?mode=statedump")) | 86 | entries = self.entries_from_lines(self.scrape_all("/stats?mode=statedump")) |
| 87 | self.assertIn(self.seeding.hex().upper().encode(), entries) | 87 | self.assertIn(self.seeding.hex().upper().encode(), entries) |
| 88 | age, completed = entries[self.seeding.hex().upper().encode()] | 88 | stamp, completed = entries[self.seeding.hex().upper().encode()] |
| 89 | self.assertEqual(int(age), 0, "announced a moment ago") | 89 | self.assertAlmostEqual(int(stamp), int(time.time()) // 60, delta=1, msg="announced a moment ago") |
| 90 | self.assertEqual(int(completed), 0) | 90 | self.assertEqual(int(completed), 0) |
| 91 | 91 | ||
| 92 | # ------------------------------------------------------------ shape | 92 | # ------------------------------------------------------------ shape |
diff --git a/tests/integration/test_statefile.py b/tests/integration/test_statefile.py index 21eefae..a3c8d59 100644 --- a/tests/integration/test_statefile.py +++ b/tests/integration/test_statefile.py | |||
| @@ -1,12 +1,12 @@ | |||
| 1 | """The state dump and the -l switch that reads it back. | 1 | """The state dump and the -l switch that reads it back. |
| 2 | 2 | ||
| 3 | Nothing exercised the load path before, and the dump's second column is now | 3 | The second column is the minute a torrent was last touched, by the wall |
| 4 | how long ago a torrent was last touched rather than when, because the clock | 4 | clock, which is what the file has recorded for years and the only clock two |
| 5 | counts from boot and an absolute stamp would mean nothing to the process | 5 | runs of the tracker agree on. |
| 6 | that reads the file. | ||
| 7 | """ | 6 | """ |
| 8 | 7 | ||
| 9 | import os | 8 | import os |
| 9 | import time | ||
| 10 | import unittest | 10 | import unittest |
| 11 | 11 | ||
| 12 | from harness import Tracker, TrackerTestCase | 12 | from harness import Tracker, TrackerTestCase |
| @@ -54,23 +54,26 @@ class StateFileRoundTrip(TrackerTestCase): | |||
| 54 | restarted._cleanup() | 54 | restarted._cleanup() |
| 55 | 55 | ||
| 56 | 56 | ||
| 57 | class StateFileWithNonsenseAges(TrackerTestCase): | 57 | class StateFileWithNonsenseStamps(TrackerTestCase): |
| 58 | """The age column is read as unsigned and handed on as a signed ot_time. | 58 | """The column is read as unsigned and handed on as a signed ot_time. A |
| 59 | A value at or above 2^63 arrived negative, cleared the timeout check, and | 59 | value at or above 2^63 arrived negative, cleared the timeout check, and |
| 60 | put the torrent's base in the future, where the cleaner never reaches it. | 60 | put the torrent's base in the future, where the cleaner never reaches it. |
| 61 | At exactly 2^63 the subtraction itself overflowed.""" | 61 | At exactly 2^63 the subtraction itself overflowed.""" |
| 62 | 62 | ||
| 63 | AGES = { | 63 | def stamps(self): |
| 64 | "overflow": 2**63, | 64 | now = int(time.time()) // 60 |
| 65 | "wrapped": 2**63 + 60 * 24, | 65 | return { |
| 66 | "minus-one": 2**64 - 1, | 66 | "overflow": 2**63, |
| 67 | "int64-max": 2**63 - 1, | 67 | "minus-one": 2**64 - 1, |
| 68 | "past-timeout": 60 * 24 + 1, | 68 | "int64-max": 2**63 - 1, |
| 69 | } | 69 | "in-the-future": now + 60, |
| 70 | "past-timeout": now - (24 * 60 + 1), | ||
| 71 | } | ||
| 70 | 72 | ||
| 71 | def test_no_age_outside_the_timeout_builds_a_torrent(self): | 73 | def test_no_stamp_outside_the_timeout_builds_a_torrent(self): |
| 72 | lines = ["%s:%d:7" % (self.hash_for(name).hex(), age) for name, age in self.AGES.items()] | 74 | stamps = self.stamps() |
| 73 | lines.append("%s:5:7" % self.hash_for("honest").hex()) | 75 | lines = ["%s:%d:7" % (self.hash_for(name).hex(), stamp) for name, stamp in stamps.items()] |
| 76 | lines.append("%s:%d:7" % (self.hash_for("honest").hex(), int(time.time()) // 60 - 5)) | ||
| 74 | 77 | ||
| 75 | path = os.path.join(self.tracker.dir, "nonsense") | 78 | path = os.path.join(self.tracker.dir, "nonsense") |
| 76 | with open(path, "w") as handle: | 79 | with open(path, "w") as handle: |
| @@ -78,9 +81,9 @@ class StateFileWithNonsenseAges(TrackerTestCase): | |||
| 78 | 81 | ||
| 79 | restarted = Tracker(extra_args=["-l", path]) | 82 | restarted = Tracker(extra_args=["-l", path]) |
| 80 | try: | 83 | try: |
| 81 | for name in self.AGES: | 84 | for name, stamp in stamps.items(): |
| 82 | self.assertNotIn(self.hash_for(name), restarted.scrape(self.hash_for(name))[b"files"], | 85 | self.assertNotIn(self.hash_for(name), restarted.scrape(self.hash_for(name))[b"files"], |
| 83 | "the line with age %d was loaded" % self.AGES[name]) | 86 | "the line stamped %d was loaded" % stamp) |
| 84 | self.assertIn(self.hash_for("honest"), restarted.scrape(self.hash_for("honest"))[b"files"], | 87 | self.assertIn(self.hash_for("honest"), restarted.scrape(self.hash_for("honest"))[b"files"], |
| 85 | "the state file was not read at all") | 88 | "the state file was not read at all") |
| 86 | self.assertEqual(restarted.torrent_count(), 1) | 89 | self.assertEqual(restarted.torrent_count(), 1) |
