summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorDirk Engling <erdgeist@erdgeist.org>2026-11-06 14:24:04 +0100
committerDirk Engling <erdgeist@erdgeist.org>2026-11-06 14:24:04 +0100
commit355c8ae57d582974f4f6a4ba42f635f2253b84a7 (patch)
tree81536ae109b37242217c818ae3a6b2c193fb8ff3
parent367ff65242ca88fd6435d04a577a43cec5de92fe (diff)
Add a wall clock helper so that statedumps can be written and re-readHEADmaster
-rw-r--r--DESIGN9
-rw-r--r--src/opentracker.c10
-rw-r--r--src/ot_fullscrape.c2
-rw-r--r--src/trackerlogic.c2
-rw-r--r--src/trackerlogic.h5
-rw-r--r--tests/integration/test_accesslist.py4
-rw-r--r--tests/integration/test_fullscrape.py14
-rw-r--r--tests/integration/test_statefile.py41
8 files changed, 55 insertions, 32 deletions
diff --git a/DESIGN b/DESIGN
index eb8679e..35928b4 100644
--- a/DESIGN
+++ b/DESIGN
@@ -103,6 +103,15 @@ ordinary announce interval is a constant too and an operator has nothing to
103measure a different value against. Over HTTP a refusal stays a failure 103measure a different value against. Over HTTP a refusal stays a failure
104reason, which is the only place a user is told why. 104reason, which is the only place a user is told why.
105 105
106The state file keeps a wall clock minute, and it is the only thing in the
107tracker that reads that clock. Everything else runs off a monotonic one,
108where a time step cannot evict a swarm, but a file outlives the process that
109wrote it and minutes since this boot mean nothing to the next run. So the
110dump converts on the way out and load_state converts on the way back in, and
111what lands on disk is the format opentracker has written for years. A step
112between writing and reading mis-dates a torrent by the size of the step,
113which costs it part of its last day and nothing else.
114
106Startup and teardown order 115Startup 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? */
116static inline int time_is_past(ot_time deadline) { return time_now_seconds() > deadline; } 116static 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. */
120static 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 */
118extern _Atomic int g_opentracker_running; 123extern _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
8import os 8import os
9import struct 9import struct
10import time
10import unittest 11import unittest
11 12
12from harness import Tracker, TrackerTestCase 13from 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
9import gzip 9import gzip
10import struct 10import struct
11import time
11import unittest 12import unittest
12 13
13from harness import TrackerTestCase, bdecode 14from 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
3Nothing exercised the load path before, and the dump's second column is now 3The second column is the minute a torrent was last touched, by the wall
4how long ago a torrent was last touched rather than when, because the clock 4clock, which is what the file has recorded for years and the only clock two
5counts from boot and an absolute stamp would mean nothing to the process 5runs of the tracker agree on.
6that reads the file.
7""" 6"""
8 7
9import os 8import os
9import time
10import unittest 10import unittest
11 11
12from harness import Tracker, TrackerTestCase 12from harness import Tracker, TrackerTestCase
@@ -54,23 +54,26 @@ class StateFileRoundTrip(TrackerTestCase):
54 restarted._cleanup() 54 restarted._cleanup()
55 55
56 56
57class StateFileWithNonsenseAges(TrackerTestCase): 57class 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)