primenet.py 42 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938
  1. #!/usr/bin/env python
  2. # -*- coding: utf-8 -*-
  3. # Automatic assignment handler for Mlucas.
  4. # This handles LL and PRP testing (first-time and double-check), i.e. all the worktypes supported by the program.
  5. # EWM: adapted from https://github.com/MarkRose/primetools/blob/master/mfloop.py by teknohog and Mark Rose, with help rom Gord Palameta.
  6. # 2020: support for computer registration and assignment-progress via direct Primenet-v5-API calls by Loïc Le Loarer <loic@le-loarer.org>.
  7. # 2021: support for p-1 assignment fetch (Pfactor= and Pminus1= formats) and results reporting (worktyp = PM1) added by EWM for Mlucas v20 release.
  8. # This script is intended to be run alongside Mlucas - use it to register your computer (if you've not previously done so)
  9. # and then reinvoke in periodic-update mode to automatically fetch work from the Primenet server, report latest results and
  10. # report the status of currently-in-progress assignments to the server, which you can view in a convenient dashboard form via
  11. # login to the server and clicking Account/Team Info --> My Account --> CPUs. (Or directly via URL: https://www.mersenne.org/cpus/)
  12. ################################################################################
  13. # #
  14. # (C) 2017-2020 by Ernst W. Mayer. #
  15. # #
  16. # This program is free software; you can redistribute it and/or modify it #
  17. # under the terms of the GNU General Public License as published by the #
  18. # Free Software Foundation; either version 2 of the License, or (at your #
  19. # option) any later version. #
  20. # #
  21. # This program is distributed in the hope that it will be useful, but WITHOUT #
  22. # ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or #
  23. # FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for #
  24. # more details. #
  25. # #
  26. # You should have received a copy of the GNU General Public License along #
  27. # with this program; see the file GPL.txt. If not, you may view one at #
  28. # http://www.fsf.org/licenses/licenses.html, or obtain one by writing to the #
  29. # Free Software Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA #
  30. # 02111-1307, USA. #
  31. # #
  32. ################################################################################
  33. from __future__ import division, print_function
  34. import sys
  35. import os.path
  36. import re
  37. from time import sleep
  38. from optparse import OptionParser, OptionGroup
  39. from hashlib import sha256
  40. import json
  41. import platform
  42. # More python3-backward-incompatibility-breakage-related foo - thanks to Gord Palameta for the workaround:
  43. try:
  44. # Python3
  45. import http.cookiejar as cookiejar
  46. from urllib.error import URLError, HTTPError
  47. from urllib.parse import urlencode
  48. from urllib.request import build_opener, install_opener, urlopen
  49. from urllib.request import HTTPCookieProcessor
  50. except ImportError:
  51. # Python2
  52. import cookielib as cookiejar
  53. from urllib2 import URLError, HTTPError
  54. from urllib import urlencode
  55. from urllib2 import build_opener, install_opener, urlopen
  56. from urllib2 import HTTPCookieProcessor
  57. try:
  58. from configparser import ConfigParser, Error as ConfigParserError
  59. except ImportError:
  60. from ConfigParser import ConfigParser, Error as ConfigParserError # ver. < 3.0
  61. from collections import namedtuple
  62. if sys.version_info[:2] >= (3,7):
  63. # If is OK to use dict in 3.7+ because insertion order is garantied to be preserved
  64. # Since it is also faster, it is better to use raw dict()
  65. OrderedDict = dict
  66. else:
  67. try:
  68. from collections import OrderedDict
  69. except ImportError:
  70. # For python2.6 and before which don't have OrderedDict
  71. try:
  72. from ordereddict import OrderedDict
  73. except ImportError:
  74. # Tests will not work correctly but it doesn't affect the functionnality
  75. OrderedDict = dict
  76. primenet_v5_burl = "http://v5.mersenne.org/v5server/?"
  77. primenet_v5_bargs = OrderedDict((("px", "GIMPS"), ("v", 0.95)))
  78. primenet_baseurl = "https://www.mersenne.org/"
  79. primenet_login = False
  80. class primenet_api:
  81. ERROR_OK = 0
  82. ERROR_SERVER_BUSY = 3
  83. ERROR_INVALID_VERSION = 4
  84. ERROR_INVALID_TRANSACTION = 5
  85. ERROR_INVALID_PARAMETER = 7 # Returned for length, type, or character invalidations.
  86. ERROR_ACCESS_DENIED = 9
  87. ERROR_DATABASE_FULL_OR_BROKEN = 13
  88. # Account related errors:
  89. ERROR_INVALID_USER = 21
  90. # Computer cpu/software info related errors:
  91. ERROR_OBSOLETE_CLIENT = 31
  92. ERROR_UNREGISTERED_CPU = 30
  93. ERROR_STALE_CPU_INFO = 32
  94. ERROR_CPU_IDENTITY_MISMATCH = 33
  95. ERROR_CPU_CONFIGURATION_MISMATCH = 34
  96. # Work assignment related errors:
  97. ERROR_NO_ASSIGNMENT = 40
  98. ERROR_INVALID_ASSIGNMENT_KEY = 43
  99. ERROR_INVALID_ASSIGNMENT_TYPE = 44
  100. ERROR_INVALID_RESULT_TYPE = 45
  101. ERROR_INVALID_WORK_TYPE = 46
  102. ERROR_WORK_NO_LONGER_NEEDED = 47
  103. PRIMENET_AR_NO_RESULT = 0 # No result, just sending done msg
  104. PRIMENET_AR_TF_FACTOR = 1 # Trial factoring, factor found
  105. PRIMENET_AR_P1_FACTOR = 2 # P-1, factor found
  106. PRIMENET_AR_ECM_FACTOR = 3 # ECM, factor found
  107. PRIMENET_AR_TF_NOFACTOR = 4 # Trial Factoring no factor found
  108. PRIMENET_AR_P1_NOFACTOR = 5 # P-1 Factoring no factor found
  109. PRIMENET_AR_ECM_NOFACTOR = 6 # ECM Factoring no factor found
  110. PRIMENET_AR_LL_RESULT = 100 # LL result, not prime
  111. PRIMENET_AR_LL_PRIME = 101 # LL result, Mersenne prime
  112. PRIMENET_AR_PRP_RESULT = 150 # PRP result, not prime
  113. PRIMENET_AR_PRP_PRIME = 151 # PRP result, probably prime
  114. def debug_print(text, file=sys.stdout):
  115. if options.debug or file == sys.stderr:
  116. caller_name = sys._getframe(1).f_code.co_name
  117. if caller_name == '<module>':
  118. caller_name = 'main loop'
  119. caller_string = caller_name + ": "
  120. print(progname + ": " + caller_string + str(text), file=file)
  121. file.flush()
  122. def greplike(pattern, l):
  123. output = []
  124. for line in l:
  125. s = pattern.search(line)
  126. if s:
  127. output.append(s.group(0))
  128. return output
  129. def num_to_fetch(l, targetsize):
  130. num_existing = len(l)
  131. num_needed = targetsize - num_existing
  132. return max(num_needed, 0)
  133. def readonly_list_file(filename, mode="r"):
  134. # Used when there is no intention to write the file back, so don't
  135. # check or write lockfiles. Also returns a single string, no list.
  136. try:
  137. with open(filename, mode=mode) as File:
  138. contents = File.readlines()
  139. File.close()
  140. return [ x.rstrip() for x in contents ]
  141. except (IOError,OSError):
  142. return []
  143. def read_list_file(filename, mode="r"):
  144. return readonly_list_file(filename, mode=mode)
  145. def write_list_file(filename, l, mode="w"):
  146. # A "null append" is meaningful, as we can call this to clear the
  147. # lockfile. In this case the main file need not be touched.
  148. if not ( "a" in mode and len(l) == 0):
  149. newline = b'\n' if 'b' in mode else '\n'
  150. content = newline.join(l) + newline
  151. File = open(filename, mode)
  152. File.write(content)
  153. File.close()
  154. def primenet_fetch(num_to_get):
  155. if not primenet_login:
  156. return []
  157. # As of early 2018, here is the full list of assignment-type codes supported by the Primenet server; Mlucas
  158. # v20 (and thus this script) supports only the subset of these indicated by an asterisk in the left column.
  159. # The Pminus1 worktype is supported only as split from an LL|PRP assignment needing p-1 done, hence the **.
  160. # Supported assignment types may be specified via either their PrimeNet number code or the listed Mnemonic:
  161. # Worktype:
  162. # Code Mnemonic Description
  163. # ---- ----------------- -----------------------
  164. # 0 Whatever makes the most sense
  165. # 1 Trial factoring to low limits
  166. # 2 Trial factoring
  167. # 3 Pfactor P-1 factoring
  168. # ** 4 Pminus1 P-1 factoring
  169. # 5 ECM for first factor on Mersenne numbers
  170. # 6 ECM on Fermat numbers
  171. # 8 ECM on mersenne cofactors
  172. # *100 SmallestAvail Smallest available first-time tests
  173. # *101 DoubleCheck Double-checking
  174. # *102 WorldRecord World record primality tests
  175. # *104 100Mdigit 100M digit number to LL test (not recommended)
  176. # *150 SmallestAvailPRP First time PRP tests (Gerbicz)
  177. # *151 DoubleCheckPRP Doublecheck PRP tests (Gerbicz)
  178. # *152 WorldRecordPRP World record sized numbers to PRP test (Gerbicz)
  179. # *153 100MdigitPRP 100M digit number to PRP test (Gerbicz)
  180. # 160 PRP on Mersenne cofactors
  181. # 161 PRP double-checks on Mersenne cofactors
  182. # Convert mnemonic-form worktypes to corresponding numeric value, check worktype value vs supported ones:
  183. if options.worktype == "SmallestAvail":
  184. options.worktype = "100"
  185. elif options.worktype == "DoubleCheck":
  186. options.worktype = "101"
  187. elif options.worktype == "WorldRecord":
  188. options.worktype = "102"
  189. elif options.worktype == "100Mdigit":
  190. options.worktype = "104"
  191. if options.worktype == "SmallestAvailPRP":
  192. options.worktype = "150"
  193. elif options.worktype == "DoubleCheckPRP":
  194. options.worktype = "151"
  195. elif options.worktype == "WorldRecordPRP":
  196. options.worktype = "152"
  197. elif options.worktype == "100MdigitPRP":
  198. options.worktype = "153"
  199. supported = set(['100','101','102','104','150','151','152','153'])
  200. if not options.worktype in supported:
  201. debug_print("Unsupported/unrecognized worktype = " + options.worktype)
  202. return []
  203. assignment = OrderedDict((
  204. ("cores","1"),
  205. ("num_to_get", num_to_get),
  206. ("pref", options.worktype),
  207. ("exp_lo", ""),
  208. ("exp_hi", ""),
  209. ("B1", "Get Assignments")
  210. ))
  211. try:
  212. openurl = primenet_baseurl + "manual_assignment/?" + urlencode(assignment)
  213. debug_print("Fetching work via URL = "+openurl)
  214. r = primenet.open(openurl)
  215. return greplike(workpattern, [ line.decode('utf-8','replace') for line in r.readlines() ] )
  216. except URLError:
  217. debug_print("URL open error at primenet_fetch")
  218. return []
  219. def get_assignment(progress):
  220. w = read_list_file(workfile)
  221. tasks = greplike(workpattern, w)
  222. (percent, time_left) = None, None
  223. if progress is not None and type(progress) == tuple and len(progress) == 2:
  224. (percent, time_left) = progress # unpack update_progress output
  225. num_cache = int(options.num_cache)
  226. if percent is not None and percent >= int(options.percent_limit):
  227. num_cache += 1
  228. debug_print("Progress of current assignment is {0:.2f} and bigger than limit ({1}), so num_cache is increased by one to {2}".format(percent, options.percent_limit, num_cache))
  229. elif time_left is not None and time_left <= max(3*options.timeout, 24*3600):
  230. # use else if here is important,
  231. # time_left and percent increase are exclusive (don't want to do += 2)
  232. num_cache += 1
  233. debug_print("Time_left is {0} and smaller than limit ({1}), so num_cache is increased by one to {2}".format(time_left, max(3*options.timeout, 24*3600), num_cache))
  234. num_to_get = num_to_fetch(tasks, num_cache)
  235. if num_to_get < 1:
  236. debug_print(workfile + " already has " + str(len(tasks)) + " >= " + str(num_cache) + " entries, not getting new work")
  237. return 0
  238. debug_print("Fetching " + str(num_to_get) + " assignments")
  239. new_tasks = primenet_fetch(num_to_get)
  240. num_fetched = len(new_tasks)
  241. if num_fetched > 0:
  242. debug_print("Fetched {0} assignments:".format(num_fetched))
  243. for new_task in new_tasks:
  244. debug_print("{0}".format(new_task))
  245. write_list_file(workfile, new_tasks, "a")
  246. if num_fetched < num_to_get:
  247. debug_print("Error: Failed to obtain requested number of new assignments, " + str(num_to_get) + " requested, " + str(num_fetched) + " successfully retrieved")
  248. return num_fetched
  249. def mersenne_find(line, complete=True):
  250. # Pre-v19 old-style HRF-formatted result used "Program:..."; starting w/v19 JSON-formatted result uses "program",
  251. return re.search("[Pp]rogram", line)
  252. try:
  253. from statistics import median_low
  254. except ImportError:
  255. def median_low(mylist):
  256. sorts = sorted(mylist)
  257. length = len(sorts)
  258. return sorts[(length-1)//2]
  259. def parse_stat_file(p):
  260. statfile = 'p' + str(p) + '.stat'
  261. w = readonly_list_file(statfile) # appended line by line, no lock needed
  262. found = 0
  263. regex = re.compile("Iter# = (.+?) .*?(\d+\.\d+) (m?sec)/iter")
  264. list_usec_per_iter = []
  265. # get the 5 most recent Iter line
  266. for line in reversed(w):
  267. res = regex.search(line)
  268. if res:
  269. found += 1
  270. # keep the last iteration to compute the percent of progress
  271. if found == 1:
  272. iteration = int(res.group(1))
  273. usec_per_iter = float(res.group(2))
  274. unit = res.group(3)
  275. if unit == "sec":
  276. usec_per_iter *= 1000
  277. list_usec_per_iter.append(usec_per_iter)
  278. if found == 5: break
  279. if found == 0: return 0, None # iteration is 0, but don't know the estimated speed yet
  280. # take the media of the last grepped lines
  281. usec_per_iter = median_low(list_usec_per_iter)
  282. return iteration, usec_per_iter
  283. def parse_v5_resp(r):
  284. ans = dict()
  285. for line in r.splitlines():
  286. if line == "==END==": break
  287. option,_,value = line.partition("=")
  288. ans[option]=value
  289. return ans
  290. def send_request(guid, args):
  291. args["g"] = guid
  292. # to mimic mprime, it is necessary to add safe='"{}:,' argument to urlencode, in
  293. # particular to encode JSON in result submission. But safe is not supported by python2...
  294. url_args = urlencode(args)
  295. # Only really usefull for t = "uc", not for "ap", is it for "ar" ?
  296. url_args += "&ss=19191919&sh=ABCDABCDABCDABCDABCDABCDABCDABCD"
  297. try:
  298. # don't need to use primenet opener because this API doesn't have cookies
  299. r = urlopen(primenet_v5_burl+url_args)
  300. except HTTPError as e:
  301. debug_print("ERROR receiving answer to request: "+str(primenet_v5_burl+url_args), file=sys.stderr)
  302. debug_print(e, file=sys.stderr)
  303. return None
  304. except URLError as e:
  305. debug_print("ERROR connecting to server for request: "+str(primenet_v5_burl+url_args), file=sys.stderr)
  306. debug_print(e, file=sys.stderr)
  307. return None
  308. return parse_v5_resp(r.read().decode("utf-8","replace"))
  309. from random import getrandbits
  310. def create_new_guid():
  311. guid = hex(getrandbits(128))
  312. if guid[:2] == '0x': guid = guid[2:] # remove the 0x prefix
  313. if guid[-1] == 'L': guid = guid[:-1] # remove trailling 'L' in python2
  314. # add missing 0 to the beginning"
  315. guid = (32-len(guid))*"0" + guid
  316. return guid
  317. def register_instance(guid):
  318. # register the instance to server, guid is the instance identifier
  319. if options.username is None or options.hostname is None:
  320. parser.error("To register the instance, --username and --hostname are required")
  321. hardware_id = sha256(options.cpu_model.encode("utf-8")).hexdigest()[:32] # similar as mprime
  322. args = primenet_v5_bargs.copy()
  323. args["t"] = "uc" # update compute command
  324. args["a"] = "Linux64,Mlucas,v19" #
  325. if config.has_option("primenet", "sw_version"):
  326. args["a"] = config.get("primenet", "sw_version")
  327. args["wg"] = "" # only filled on Windows by mprime
  328. args["hd"] = hardware_id # 32 hex char (128 bits)
  329. args["c"] = options.cpu_model[:64] # CPU model (len between 8 and 64)
  330. args["f"] = options.features[:64] # CPU option (like asimd, max len 64)
  331. args["L1"] = options.L1 # L1 cache size in KBytes
  332. args["L2"] = options.L2 # L2 cache size in KBytes
  333. # if smaller or equal to 256,
  334. # server refuses to gives LL assignment
  335. args["np"] = options.np # number of cores
  336. args["hp"] = options.hp # number of hyperthreading cores
  337. args["m"] = options.memory # number of megabytes of physical memory
  338. args["s"] = options.frequency # CPU frequency
  339. args["h"] = 24 # pretend to run 24h/day
  340. args["r"] = 1000 # pretend to run at 100%
  341. args["u"] = options.username #
  342. args["cn"] = options.hostname[:20] # truncate to 20 char max
  343. if guid is None:
  344. guid = create_new_guid()
  345. result = send_request(guid, args)
  346. if result is None:
  347. parser.error("Error while registering on mersenne.org")
  348. elif int(result["pnErrorResult"]) != 0:
  349. parser.error("Error while registering on mersenne.org\nReason: "+result["pnErrorDetail"])
  350. config_write(config, guid=guid)
  351. print("GUID {guid} correctly registered with the following features:".format(guid=guid))
  352. print("Username: {0}".format(options.username))
  353. print("Hostname: {0}".format(options.hostname))
  354. print("CPU model: {0}".format(options.cpu_model))
  355. print("CPU features: {0}".format(options.features))
  356. print("CPU L1 cache size: {0}kB".format(options.L1))
  357. print("CPU L2 cache size: {0}kB".format(options.L2))
  358. print("CPU cores: {0}".format(options.np))
  359. print("CPU thread per core: {0}".format(options.hp))
  360. print("CPU frequency: {0}MHz".format(options.frequency))
  361. print("Memory size: {0}MB".format(options.memory))
  362. print("If you want to change the value, please rerun with the corresponding options or edit the local.ini file and rerun with --register option")
  363. print("You can see the result in this page:")
  364. print("https://www.mersenne.org/editcpu/?g={guid}".format(guid=guid))
  365. return
  366. def config_read():
  367. config = ConfigParser(dict_type=OrderedDict)
  368. try:
  369. config.read([localfile])
  370. except ConfigParserError as e:
  371. debug_print("ERROR reading {0} file:".format(localfile), file=sys.stderr)
  372. debug_print(e, file=sys.stderr)
  373. if not config.has_section("primenet"):
  374. # Create the section to avoid having to test for it later
  375. config.add_section("primenet")
  376. return config
  377. def get_guid(config):
  378. try:
  379. return config.get("primenet", "guid")
  380. except ConfigParserError:
  381. return None
  382. def config_write(config, guid=None):
  383. # generate a new local.ini file
  384. if guid is not None: # update the guid if necessary
  385. config.set("primenet", "guid", guid)
  386. with open(localfile, "w") as configfile:
  387. config.write(configfile)
  388. def merge_config_and_options(config, options):
  389. # getattr and setattr allow access to the options.xxxx values by name
  390. # which allow to copy all of them programmatically instead of having
  391. # one line per attribute. Only the attr_to_copy list need to be updated
  392. # when adding an option you want to copy from argument options to local.ini config.
  393. attr_to_copy = ["username", "password", "worktype", "num_cache", "percent_limit",
  394. "hostname", "cpu_model", "features", "frequency", "memory", "L1", "L2", "np", "hp"]
  395. updated = False
  396. for attr in attr_to_copy:
  397. # if "attr" has its default value in options, copy it from config
  398. attr_val = getattr(options, attr)
  399. if attr_val == parser.defaults[attr] \
  400. and config.has_option("primenet", attr):
  401. # If no option is given and the option exists in local.ini, take it from local.ini
  402. new_val = config.get("primenet", attr)
  403. # config file values are always str()
  404. # they need to be converted to the expected type from options
  405. if attr_val is not None:
  406. new_val = type(attr_val)(new_val)
  407. setattr(options, attr, new_val)
  408. elif attr_val is not None and (not config.has_option("primenet", attr) \
  409. or config.get("primenet", attr) != str(attr_val)):
  410. # If an option is given (even default value) and it is not already
  411. # identical in local.ini, update local.ini
  412. debug_print("update local.ini with {0}={1}".format(attr, attr_val))
  413. config.set("primenet", attr, str(attr_val))
  414. updated = True
  415. return updated
  416. Assignment = namedtuple('Assignment', "id p is_prp iteration usec_per_iter")
  417. def update_progress():
  418. w = readonly_list_file(workfile)
  419. tasks = greplike(workpattern, w)
  420. if not len(tasks): return # don't update if no worktodo
  421. config_updated = False
  422. # Treat the first assignment. Only this one is used to save the usec_per_iter
  423. # The idea is that the first assignment is having a .stat file with correct values
  424. # Most of the time, a later assignment would not have a .stat file to obtain information,
  425. # but if it has, it may come from an other computer if the user moved the files, and so
  426. # it doesn't have revelant values for speed estimation.
  427. # Using usec_per_iter from one p to another is a good estimation if both p are close enougth
  428. # if there is big gap, it will be other or under estimated.
  429. # Any idea for a better estimation of assignment duration when only p and type (LL or PRP) is known ?
  430. assignment = get_progress_assignment(tasks[0])
  431. usec_per_iter = assignment.usec_per_iter
  432. if usec_per_iter is not None:
  433. config.set("primenet", "usec_per_iter", "{0:.2f}".format(usec_per_iter))
  434. config_updated = True
  435. elif config.has_option("primenet", "usec_per_iter"):
  436. # If not speed available, get it from the local.ini file
  437. usec_per_iter = float(config.get("primenet", "usec_per_iter"))
  438. percent, time_left = compute_progress(assignment.p, assignment.iteration, usec_per_iter)
  439. debug_print("p:{0} is {1:.2f}% done".format(assignment.p, percent))
  440. if time_left is None:
  441. debug_print("Finish cannot be estimated")
  442. else:
  443. debug_print("Finish estimated in {0:.1f} days (used {1:.1f} msec/iter estimation)".format(time_left/3600/24, usec_per_iter))
  444. send_progress(assignment.id, assignment.is_prp, percent, time_left)
  445. # Do the other assignment accumulating the time_lefts
  446. cur_time_left = time_left
  447. for task in tasks[1:]:
  448. assignment = get_progress_assignment(task)
  449. percent, time_left = compute_progress(assignment.p, assignment.iteration, usec_per_iter)
  450. debug_print("p:{0} is {1:.2f}% done".format(assignment.p, percent))
  451. if time_left is None:
  452. debug_print("Finish cannot be estimated")
  453. else:
  454. cur_time_left += time_left
  455. debug_print("Finish estimated in {0:.1f} days (used {1:.1f} msec/iter estimation)".format(cur_time_left/3600/24, usec_per_iter))
  456. send_progress(assignment.id, assignment.is_prp, percent, cur_time_left)
  457. config_write(config)
  458. return percent, cur_time_left
  459. def get_progress_assignment(task):
  460. found = workpattern.search(task)
  461. if not found:
  462. # TODO: test this error
  463. debug_print("ERROR: Unable to extract valid Primenet assignment ID from entry in " + workfile + ": " + str(tasks[0]), file=sys.stderr)
  464. return
  465. assignment_id = found.group(2)
  466. is_prp = found.group(1) == "PRP"
  467. debug_print("type = {0}, assignment_id = {1}".format(found.group(1), assignment_id))
  468. found = task.split(",")
  469. idx = 3 if is_prp else 1
  470. if len(found) <= idx:
  471. debug_print("Unable to extract valid exponent substring from entry in " + workfile + ": " + str(task))
  472. return None, None
  473. # Extract the subfield containing the exponent, whose position depends on the assignment type:
  474. p = int(found[idx])
  475. iteration, usec_per_iter = parse_stat_file(p)
  476. return Assignment(assignment_id, p, is_prp, iteration, usec_per_iter)
  477. def compute_progress(p, iteration, usec_per_iter):
  478. percent = 100*float(iteration)/float(p)
  479. if usec_per_iter is None:
  480. return percent, None
  481. iteration_left = p - iteration
  482. time_left = int(usec_per_iter * iteration_left / 1000)
  483. return percent, time_left
  484. def send_progress(assignment_id, is_prp, percent, time_left, retry_count=0):
  485. guid = get_guid(config)
  486. if guid is None:
  487. debug_print("Cannot update, the registration is not done", file=sys.stderr)
  488. debug_print("Call primenet.py with --register option", file=sys.stderr)
  489. return
  490. if retry_count > 5: return
  491. # Assignment Progress fields:
  492. # g= the machine's GUID (32 chars, assigned by Primenet on 1st-contact from a given machine, stored in 'guid=' entry of local.ini file of rundir)
  493. #
  494. args=primenet_v5_bargs.copy()
  495. args["t"] = "ap" # update compute command
  496. # k= the assignment ID (32 chars, follows '=' in Primenet-geerated workfile entries)
  497. args["k"] = assignment_id
  498. # p= progress in %-done, 4-char format = xy.z
  499. args["p"] = "{0:.1f}".format(percent)
  500. # d= when the client is expected to check in again (in seconds ... )
  501. args["d"] = options.timeout if options.timeout else 24*3600
  502. # e= the ETA of completion in seconds, if unknown, just put 1 week
  503. args["e"] = time_left if time_left is not None else 7*24*3600
  504. # c= the worker thread of the machine ... always sets = 0 for now, elaborate later if desired
  505. args["c"] = 0
  506. # stage= LL in this case, although an LL test may be doing TF or P-1 work first so it's possible to be something besides LL
  507. if not is_prp:
  508. args["stage"] = "LL"
  509. retry = False
  510. result = send_request(guid, args)
  511. if result is None:
  512. debug_print("ERROR while updating on mersenne.org", file=sys.stderr)
  513. # Try again
  514. retry = True
  515. else:
  516. rc = int(result["pnErrorResult"])
  517. if rc == primenet_api.ERROR_OK:
  518. debug_print("Update correctly send to server")
  519. elif rc == primenet_api.ERROR_STALE_CPU_INFO:
  520. debug_print("STALE CPU INFO ERROR: re-send computer update")
  521. # rerun --register
  522. register_instance(guid)
  523. retry = True
  524. elif rc == primenet_api.ERROR_UNREGISTERED_CPU:
  525. debug_print("UNREGISTERED CPU ERROR: pick a new GUID and register again")
  526. # corrupted GUI: change GUID, and rerun --register
  527. register_instance(None)
  528. retry = True
  529. elif rc == primenet_api.ERROR_SERVER_BUSY:
  530. retry = True
  531. else:
  532. # TODO: treat more errors correctly in all send_request callers
  533. # primenet_api.ERROR_INVALID_ASSIGNMENT_KEY
  534. # primenet_api.ERROR_WORK_NO_LONGER_NEEDED
  535. # drop the assignment
  536. debug_print("ERROR while updating on mersenne.org", file=sys.stderr)
  537. debug_print("Code: "+str(rc), file=sys.stderr)
  538. debug_print("Reason: "+result["pnErrorDetail"], file=sys.stderr)
  539. if retry:
  540. return send_progress(assignment_id, is_prp, percent, time_left, retry_count+1)
  541. return
  542. def submit_one_line(sendline):
  543. """Submit one line"""
  544. try:
  545. ar = json.loads(sendline)
  546. is_json = True
  547. except json.decoder.JSONDecodeError:
  548. is_json = False
  549. guid = get_guid(config)
  550. if guid is not None and is_json:
  551. # If registered and the line is a JSON, submit using the v API
  552. # The result will be attributed to the registered computer
  553. sent = submit_one_line_v5(sendline, guid, ar)
  554. else:
  555. # The result will be attributed to "Manual testing"
  556. sent = submit_one_line_manually(sendline)
  557. return sent
  558. def get_result_type(ar):
  559. """Extract result type from JSON result"""
  560. # Cf. The Primenet API forum thread [https://mersenneforum.org/showthread.php?t=23992] for lists of codes:
  561. if ar['worktype'] == 'LL':
  562. if ar['status'] == 'P':
  563. return primenet_api.PRIMENET_AR_LL_PRIME
  564. else:
  565. return primenet_api.PRIMENET_AR_LL_RESULT
  566. elif ar['worktype'].startswith('PRP'):
  567. if ar['status'] == 'P':
  568. return primenet_api.PRIMENET_AR_PRP_PRIME
  569. else:
  570. return primenet_api.PRIMENET_AR_PRP_RESULT
  571. elif ar['worktype'] == 'PM1':
  572. if ar['status'] == 'F':
  573. return primenet_api.PRIMENET_AR_P1_FACTOR
  574. else:
  575. return primenet_api.PRIMENET_AR_P1_NOFACTOR
  576. else:
  577. raise ValueError("This is a bug in primenet.py, Unsupported worktype {0}".format(ar['worktype']))
  578. def submit_one_line_v5(sendline, guid, ar):
  579. """Submit one result line using V5 API, will be attributed to the computed identified by guid"""
  580. """Return False if the submission should be retried"""
  581. # JSON is required because assignment_id is necessary in that case
  582. # and it is not present in old output format.
  583. debug_print("Submitting using V5 API\n" + sendline)
  584. aid = ar['aid']
  585. result_type = get_result_type(ar)
  586. args = primenet_v5_bargs.copy()
  587. args["t"] = "ar" # assignment result
  588. args["k"] = ar['aid'] if 'aid' in ar else 0 # assignment id
  589. args["m"] = sendline # message is the complete JSON string
  590. args["r"] = result_type # result type
  591. args["d"] = 1 # done: 0 for no closing is used for partial results
  592. args["n"] = ar['exponent']
  593. if result_type in (primenet_api.PRIMENET_AR_LL_RESULT, primenet_api.PRIMENET_AR_LL_PRIME):
  594. if result_type == primenet_api.PRIMENET_AR_LL_RESULT:
  595. args["rd"] = ar['res64']
  596. if 'shift-count' in ar:
  597. args['sc'] = ar['shift-count']
  598. if 'error-code' in ar:
  599. args["ec"] = ar['error-code']
  600. elif result_type in (primenet_api.PRIMENET_AR_PRP_RESULT, primenet_api.PRIMENET_AR_PRP_PRIME):
  601. args.update((("A", 1), ("b", 2), ("c", -1)))
  602. if result_type == primenet_api.PRIMENET_AR_PRP_RESULT:
  603. args["rd"] = ar['res64']
  604. if 'error-code' in ar:
  605. args["ec"] = ar['error-code']
  606. if 'known-factors' in ar:
  607. args['nkf'] = len(ar['known-factors'])
  608. args["base"] = ar['worktype'][4:] # worktype == PRP-base
  609. if 'residue-type' in ar:
  610. args["rt"] = ar['residue-type']
  611. if 'shift-count' in ar:
  612. args['sc'] = ar['shift-count']
  613. if 'errors' in ar:
  614. args['gbz'] = 1
  615. elif result_type in frozenset([primenet_api.PRIMENET_AR_P1_FACTOR, primenet_api.PRIMENET_AR_P1_NOFACTOR]):
  616. tasks = readonly_list_file(workfile)
  617. if result_type == primenet_api.PRIMENET_AR_P1_NOFACTOR:
  618. args["d"] = 0
  619. args.update((("A", 1), ("b", 2), ("c", -1)))
  620. args['B1'] = ar['B1']
  621. if 'B2' in ar:
  622. args['B2'] = ar['B2']
  623. if result_type == primenet_api.PRIMENET_AR_P1_FACTOR:
  624. args["f"] = ar['factors'][0]
  625. args['fftlen'] = ar['fft-length']
  626. result = send_request(guid, args)
  627. if result is None:
  628. debug_print("ERROR while submitting result on mersenne.org: assignment_id={0}".format(aid), file=sys.stderr)
  629. # if this happens, the submission can be retried
  630. # since no answer has been received from the server
  631. return False
  632. elif int(result["pnErrorResult"]) == primenet_api.ERROR_OK:
  633. debug_print("Result correctly send to server: assignment_id={0}".format(aid))
  634. if result["pnErrorDetail"] != "SUCCESS":
  635. debug_print("server message: "+result["pnErrorDetail"])
  636. else: # non zero ERROR code
  637. debug_print("ERROR while submitting result on mersenne.org: assignment_id={0}".format(aid), file=sys.stderr)
  638. if int(result["pnErrorResult"]) is primenet_api.ERROR_UNREGISTERED_CPU:
  639. # should register again and retry
  640. debug_print("ERROR UNREGISTERED CPU: Please remove guid line from local.ini, run with --register and retry", file=sys.stderr)
  641. return False
  642. elif int(result["pnErrorResult"]) is primenet_api.ERROR_INVALID_PARAMETER:
  643. debug_print("INVALID PARAMETER: this is a bug in primenet.py, please notify the author", file=sys.stderr)
  644. debug_print("Reason: "+result["pnErrorDetail"], file=sys.stderr)
  645. return False
  646. else:
  647. # In all other error case, the submission must not be retried
  648. debug_print("Reason: "+result["pnErrorDetail"], file=sys.stderr)
  649. return True
  650. return True
  651. def submit_one_line_manually(sendline):
  652. """Submit results using manual testing, will be attributed to "Manual Testing" in mersenne.org"""
  653. debug_print("Submitting using manual results\n" + sendline)
  654. try:
  655. post_data = urlencode({"data": sendline}).encode('utf-8')
  656. r = primenet.open(primenet_baseurl + "manual_result/default.php", post_data)
  657. res = r.read()
  658. if b"Error" in res:
  659. res_str = res.decode("utf-8", "replace")
  660. ibeg = res_str.find("Error")
  661. iend = res_str.find("</div>", ibeg)
  662. print("Submission failed: '{0}'".format(res_str[ibeg:iend]))
  663. elif b"Accepted" in res:
  664. pass
  665. else:
  666. print("submit_work: Submission of results line '" + sendline + "' failed for reasons unknown - please try manual resubmission.")
  667. except URLError:
  668. debug_print("URL open ERROR")
  669. return True # EWM: Append entire results_send rather than just sent to avoid resubmitting
  670. # bad results (e.g. previously-submitted duplicates) every time the script executes.
  671. def submit_work():
  672. results_send = read_list_file(sentfile)
  673. # Only submit completed work, i.e. the exponent must not exist in worktodo file any more
  674. results = readonly_list_file(resultsfile) # appended line by line, no lock needed
  675. # EWM: Note that read_list_file does not need the file(s) to exist - nonexistent files simply yield 0-length rs-array entries.
  676. results = filter(mersenne_find, results) # remove nonsubmittable lines from list of possibles
  677. results_send = [line for line in results if line not in results_send] # if a line was previously submitted, discard
  678. # Only for new results, to be appended to results_sent
  679. sent = []
  680. if len(results_send) == 0:
  681. debug_print("No complete results found to send.")
  682. return
  683. # EWM: Switch to one-result-line-at-a-time submission to support error-message-on-submit handling:
  684. for sendline in results_send:
  685. is_sent = submit_one_line(sendline)
  686. if is_sent:
  687. sent.append(sendline)
  688. write_list_file(sentfile, sent, "a")
  689. #######################################################################################################
  690. #
  691. # Start main program here
  692. #
  693. #######################################################################################################
  694. parser = OptionParser(version="primenet.py 19.1", description=\
  695. """This program is used to fill worktodo.ini with assignments and send the results for Mlucas
  696. program. It also saves its configuration to local.ini file, so it is necessary to gives the arguments only the first time you call it. Arguments are recovered for local.ini if not given.
  697. If --register is given, it registers the current Mlucas instance to mersenne.org (see all the options identify your CPU correctly). Registering is optionnal, but if registered, the progress can be sent and your CPU monitored on your account on the website.
  698. Then, without --register, it fetches assignment and send results to mersenne.org using manual assignment process on a "timeout" basic, or only once if timeout=0.
  699. """
  700. )
  701. # options not saved to local.ini
  702. parser.add_option("-d", "--debug", action="count", dest="debug", default=False, help="Display debugging info")
  703. parser.add_option("-w", "--workdir", dest="workdir", default=".", help="Working directory with worktodo.ini and results.txt from mlucas, and local.ini created by this program. Default current directory")
  704. # all other options are saved to local.ini (except --register)
  705. parser.add_option("-u", "--username", dest="username", help="Primenet user name")
  706. parser.add_option("-p", "--password", dest="password", help="Primenet password")
  707. # -t is reserved for timeout, instead use -T for assignment-type preference:
  708. parser.add_option("-T", "--worktype", dest="worktype", default="101", help="Worktype code, default is 101 for double-check LL, alternatively 100 (smallest available first-time LL), 102 (world-record-sized first-time LL), 104 (100M digit number to LL test - not recommended), 150 (smallest available first-time PRP), 151 (double-check PRP), 152 (world-record-sized first-time PRP), 153 (100M digit number to PRP test - not recommended)")
  709. parser.add_option("-n", "--num_cache", dest="num_cache", type="int", default=2, help="Number of assignments to cache, default: %default")
  710. parser.add_option("-L", "--percent_limit", dest="percent_limit", type="int", default=90, help="Add one to num_cache when current assignment is already done at this percentage, default: %default")
  711. parser.add_option("-t", "--timeout", dest="timeout", type="int", default=60*60*6, help="Seconds to wait between network updates, default %default [6 hours]. Use 0 for a single update without looping.")
  712. group = OptionGroup(parser, "Registering Options: send to mersenne.org when registering, visible in CPUs in the website.")
  713. group.add_option("-r", "--register", action="store_true", dest="register", default=False, help="Register to mersenne.org, this allows sending regular updates and follow the progress on the website.")
  714. group.add_option("-H", "--hostname", dest="hostname", default=platform.node()[:20], help="Hostname name for mersenne.org, default: %default")
  715. # TODO: add detection for most parameter, including automatic change of the hardware
  716. group.add_option("-c", "--cpu_model", dest="cpu_model", default="cpu.unknown", help="CPU model, defautl: %default")
  717. group.add_option("--features", dest="features", default="", help="CPU features, default '%default'")
  718. group.add_option("--frequency", dest="frequency", type="int", default=100, help="CPU frequency in MHz, default: %default")
  719. group.add_option("-m", "--memory", dest="memory", type="int", default=0, help="memory size in MB, default: %default")
  720. group.add_option("--L1", dest="L1", type="int", default=8, help="L1 cache size, default: %default")
  721. group.add_option("--L2", dest="L2", type="int", default=512, help="L2 cache size, default: %default")
  722. group.add_option("--np", dest="np", type="int", default=1, help="number of processors, default: %default")
  723. group.add_option("--hp", dest="hp", type="int", default=0, help="number of hyperthreading cores (0 is unknown), default: %default")
  724. parser.add_option_group(group)
  725. (options, args) = parser.parse_args()
  726. progname = os.path.basename(sys.argv[0])
  727. workdir = os.path.expanduser(options.workdir)
  728. localfile = os.path.join(workdir, "local.ini")
  729. workfile = os.path.join(workdir, "worktodo.ini")
  730. resultsfile = os.path.join(workdir, "results.txt")
  731. # A cumulative backup
  732. sentfile = os.path.join(workdir, "results_sent.txt")
  733. # Good refs re. Python regexp: https://www.geeksforgeeks.org/pattern-matching-python-regex/, https://www.python-course.eu/re.php
  734. # pre-v19 only handled LL-test assignments starting with either DoubleCheck or Test, followed by =, and ending with 3 ,number pairs:
  735. #
  736. # workpattern = r"(DoubleCheck|Test)=.*(,[0-9]+){3}"
  737. #
  738. # v19 we add PRP-test support - both first-time and DC of these start with PRP=, the DCs tack on 2 more ,number pairs representing
  739. # the PRP base to use and the PRP test-type (the latter is a bit complex to explain here). Sample of the 4 worktypes supported by v19:
  740. #
  741. # Test=7A30B8B6C0FC79C534A271D9561F7DCC,89459323,76,1
  742. # DoubleCheck=92458E009609BD9E10577F83C2E9639C,50549549,73,1
  743. # PRP=BC914675C81023F252E92CF034BEFF6C,1,2,96364649,-1,76,0
  744. # PRP=51D650F0A3566D6C256B1679C178163E,1,2,81348457,-1,75,0,3,1
  745. #
  746. # and the obvious regexp pattern-modification is
  747. #
  748. # workpattern = r"(DoubleCheck|Test|PRP)=.*(,[0-9]+){3}"
  749. #
  750. # Here is where we get to the kind of complication the late baseball-philosopher Yogi Berra captured via his aphorism,
  751. # "In theory, theory and practice are the same. In practice, they're different". Namely, while the above regexp pattern
  752. # should work on all 4 assignment patterns, since each has a string of at least 3 comma-separated nonnegative ints somewhere
  753. # between the 32-hexchar assignment ID and end of the line, said pattern failed on the 3rd of the above 4 assignments,
  754. # apparently because when the regexp is done via the 'greplike' below, the (,[0-9]+){3} part of the pattern gets implicitly
  755. # tiled to the end of the input line. Assignment # 3 above happens to have a negative number among the final 3, thus the
  756. # grep fails. This weird behavior is not reproducible running Python in console mode:
  757. #
  758. # >>> import re
  759. # >>> s1 = "DoubleCheck=92458E009609BD9E10577F83C2E9639C,50549549,73,1"
  760. # >>> s2 = "Test=7A30B8B6C0FC79C534A271D9561F7DCC,89459323,76,1"
  761. # >>> s3 = "PRP=BC914675C81023F252E92CF034BEFF6C,1,2,96364649,-1,76,0"
  762. # >>> s4 = "PRP=51D650F0A3566D6C256B1679C178163E,1,2,81348457,-1,75,0,3,1"
  763. # >>> print re.search(r"(DoubleCheck|Test|PRP)=.*(,[0-9]+){3}" , s1)
  764. # <_sre.SRE_Match object at 0x1004bd250>
  765. # >>> print re.search(r"(DoubleCheck|Test|PRP)=.*(,[0-9]+){3}" , s2)
  766. # <_sre.SRE_Match object at 0x1004bd250>
  767. # >>> print re.search(r"(DoubleCheck|Test|PRP)=.*(,[0-9]+){3}" , s3)
  768. # <_sre.SRE_Match object at 0x1004bd250>
  769. # >>> print re.search(r"(DoubleCheck|Test|PRP)=.*(,[0-9]+){3}" , s4)
  770. # <_sre.SRE_Match object at 0x1004bd250>
  771. #
  772. # Anyhow, based on that I modified the grep pattern to work around the weirdness, by appending .* to the pattern, thus
  773. # changing things to "look for 3 comma-separated nonnegative ints somewhere in the assignment, followed by anything",
  774. # also now to specifically look for a 32-hexchar assignment ID preceding such a triplet, and to allow whitespace around
  775. # the =. The latter bit is not needed based on current server assignment format, just a personal aesthetic bias of mine:
  776. #
  777. workpattern = re.compile("(DoubleCheck|Test|PRP)\s*=\s*([0-9A-F]{32})(,[0-9]+){3}.*")
  778. # mersenne.org limit is about 4 KB; stay on the safe side
  779. sendlimit = 3000 # TODO: enforce this limit
  780. # adapted from http://stackoverflow.com/questions/923296/keeping-a-session-in-python-while-making-http-requests
  781. primenet_cj = cookiejar.CookieJar()
  782. primenet = build_opener(HTTPCookieProcessor(primenet_cj))
  783. # If debug is requested
  784. if options.debug > 1:
  785. # if urllib_debug is not present, don't try to activate the debugging
  786. try:
  787. import urllib_debug
  788. except ImportError:
  789. options.debug = 1
  790. if options.debug == 3:
  791. debug_print("Enable testing url request and responses")
  792. from urllib_debug import TestHTTPHandler, TestHTTPSHandler
  793. primenet = build_opener(HTTPCookieProcessor(primenet_cj), TestHTTPHandler, TestHTTPSHandler)
  794. my_opener = build_opener(TestHTTPHandler, TestHTTPSHandler)
  795. install_opener(my_opener)
  796. from random import seed
  797. seed(3)
  798. elif options.debug == 2:
  799. debug_print("Enable spying url request and responses")
  800. from urllib_debug import SpyHTTPHandler, SpyHTTPSHandler
  801. primenet = build_opener(HTTPCookieProcessor(primenet_cj), SpyHTTPHandler, SpyHTTPSHandler)
  802. my_opener = build_opener(SpyHTTPHandler, SpyHTTPSHandler)
  803. install_opener(my_opener)
  804. # load local.ini and update options
  805. config = config_read()
  806. config_updated = merge_config_and_options(config, options)
  807. # check options after merging so that if local.ini file is changed by hand,
  808. # values are also checked
  809. # TODO: check that input char are ascii or at least supported by the server
  810. if not (8 <= len(options.cpu_model) <= 64):
  811. parser.error("cpu_model must be between 8 and 64 characters")
  812. if options.hostname is not None and len(options.hostname) > 20:
  813. parser.error("hostname must be less than 21 characters")
  814. if options.features is not None and len(options.features) > 64:
  815. parser.error("features must be less than 64 characters")
  816. # write back local.ini if necessary
  817. if config_updated:
  818. debug_print("write local.ini")
  819. config_write(config)
  820. if options.register:
  821. # if guid already exist, recover it, this way, one can (re)register to change
  822. # the CPU model (changing instance name can only be done in the website)
  823. guid = get_guid(config)
  824. register_instance(guid)
  825. sys.exit(0)
  826. if options.username is None or options.password is None:
  827. parser.error("Username and password must be given")
  828. while True:
  829. # Log in to primenet
  830. try:
  831. login_data = OrderedDict((
  832. ("user_login", options.username),
  833. ("user_password", options.password),
  834. ))
  835. # TODO: login only if necessary:
  836. # TODO: when configuration has been changed to test the password
  837. # TODO: when getting assignments is necessary
  838. # TODO: on a monthly basis ?
  839. # This makes a POST instead of GET
  840. data = urlencode(login_data).encode('utf-8')
  841. r = primenet.open(primenet_baseurl + "default.php", data)
  842. if not (options.username + "<br>logged in").encode('utf-8') in r.read():
  843. primenet_login = False
  844. debug_print("ERROR: Login failed.")
  845. else:
  846. primenet_login = True
  847. except URLError:
  848. debug_print("Primenet URL open ERROR")
  849. if primenet_login:
  850. submit_work()
  851. progress = update_progress()
  852. got = get_assignment(progress)
  853. if got > 0:
  854. debug_print("Redo progress update to update the just obtained assignment")
  855. # Since assignment are obtain by manual assignment, it is important to update them
  856. # to mark them as belonging to the current computer.
  857. update_progress()
  858. if options.timeout <= 0:
  859. break
  860. try:
  861. sleep(options.timeout)
  862. except KeyboardInterrupt:
  863. break
  864. sys.exit(0)
  865. # vim: noexpandtab ts=4 sts=0 sw=0