-
-
Notifications
You must be signed in to change notification settings - Fork 6
Expand file tree
/
Copy pathtask.py
More file actions
244 lines (207 loc) · 8.77 KB
/
Copy pathtask.py
File metadata and controls
244 lines (207 loc) · 8.77 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
#! /usr/bin/env python3
# -*- coding: utf-8; py-indent-offset: 4 -*-
#
# Author: Linuxfabrik GmbH, Zurich, Switzerland
# Contact: info (at) linuxfabrik (dot) ch
# https://www.linuxfabrik.ch/
# License: The Unlicense, see LICENSE file.
"""Runs work under a deadline that holds even when the work cannot be interrupted.
A call that waits on a network filesystem does not fail when the server behind it goes
away, it blocks, and on Linux it blocks in a sleep the kernel does not let a signal
handler, a thread or an alarm cut short. `statvfs()`, `stat()`, `pathconf()` and every
read and write below such a mount point behave that way. Wrapping them in a timeout
inside the same process therefore does nothing: the process is in the kernel and does not
come back to run the timeout.
The way out is to make the call somewhere the caller can dispose of, so the work runs in
a child process and the deadline is enforced by killing that child. The sleep ends on any
signal that would terminate the process anyway, which is what makes the kill work.
Every job is started before the first one is waited for, and they share a single
deadline, so ten unreachable mount points take as long as one instead of ten times as
long. One child process is started per job.
Where `os.fork()` does not exist, the jobs are simply run in the calling process. The
uninterruptible sleep this guards against is a Linux behaviour; elsewhere the guard has
nothing to protect against and would only cost a process per job.
"""
__author__ = 'Linuxfabrik GmbH, Zurich/Switzerland'
__version__ = '2026082601'
import json
import os
import select
import signal
import time
from . import txt
# What a job's result is replaced with when it did not finish in time, as opposed to one
# that finished by raising. Callers that treat the two differently compare against this.
TIMEOUT = 'did not answer in time'
# How much a single job may hand back. A job is expected to return a small summary, and
# a caller cannot be left buffering without limit because a job went wrong.
MAX_RESULT_BYTES = 8 * 1024 * 1024
# What a job is given back when it produced more than MAX_RESULT_BYTES.
_TOO_LARGE = f'the answer was larger than {MAX_RESULT_BYTES} bytes'
# What a job is given back when nothing readable came out of it.
_UNREADABLE = 'the answer could not be read'
def run(func, timeout=8):
"""
Run one callable under a deadline.
Shorthand for `run_each()` with a single job. See there for what a callable may
return and how failures are reported.
### Parameters
- **func** (`callable`): Takes no arguments. Its return value has to be
JSON-serialisable.
- **timeout** (`int` or `float`, optional): Seconds the callable is given. Defaults
to 8.
### Returns
- **tuple**:
- On success: `(True, result)` - whatever `func` returned.
- On failure: `(False, error_message)`. The message is `TIMEOUT` when the deadline
passed, and the exception text when the callable raised.
### Example
>>> run(lambda: os.statvfs('/mnt/data').f_bfree, timeout=5)
(True, 3244913)
"""
return run_each([(None, func)], timeout)[None]
def run_each(jobs, timeout=8):
"""
Run every callable under one shared deadline, each in a process of its own.
All jobs are started before the first one is waited for, so the whole batch takes at
most `timeout` seconds no matter how many of them never come back.
A job's return value travels back as JSON, which is what makes it survive the process
boundary, so it has to consist of the types JSON can carry. Returning `None` is the
right answer for a job that only has to succeed or fail.
A job that raises is reported as a failure with the exception text. A job that misses
the deadline is reported as a failure with `TIMEOUT`, so the two can be told apart:
the first means the work was done and the answer was no, the second means no answer
was reached at all.
### Parameters
- **jobs** (`list` of `tuple`): `(key, callable)` pairs. The key identifies the job in
the result and can be any hashable value. The callable takes no arguments and
returns something JSON-serialisable.
- **timeout** (`int` or `float`, optional): Seconds the whole batch is given.
Defaults to 8. A value of zero or less gives no job any time, so every one of them
is reported as `TIMEOUT`.
### Returns
- **dict**: One entry per job, keyed by its key, each value a
`(True, result)` or `(False, error_message)` tuple.
### Example
>>> jobs = [(mp, lambda mp=mp: os.statvfs(mp).f_bfree) for mp in ('/', '/mnt/data')]
>>> run_each(jobs, timeout=5)
{'/': (True, 3244913), '/mnt/data': (False, 'did not answer in time')}
"""
if not jobs:
return {}
if not hasattr(os, 'fork'):
results = {}
for key, func in jobs:
try:
results[key] = (True, func())
except Exception as e:
results[key] = (False, str(e))
return results
started = time.time()
results = {}
pending = {}
# Read ends the parent already holds. A child inherits them and closes them right
# away, so that no child keeps another job's pipe alive.
inherited = []
for key, func in jobs:
try:
read_fd, write_fd = os.pipe()
except OSError as e:
results[key] = (False, str(e))
continue
try:
pid = os.fork()
except OSError as e:
os.close(read_fd)
os.close(write_fd)
results[key] = (False, str(e))
continue
if pid == 0:
_work(func, read_fd, write_fd, inherited)
os.close(write_fd)
inherited.append(read_fd)
pending[read_fd] = {
'chunks': [],
'key': key,
'oversized': False,
'pid': pid,
'size': 0,
}
poller = select.poll()
for read_fd in pending:
poller.register(read_fd, select.POLLIN)
while pending:
remaining = started + timeout - time.time()
if remaining <= 0:
break
# poll() takes milliseconds, and a value that rounds down to zero would turn the
# wait into a busy loop, so it never goes below one.
for read_fd, _ in poller.poll(max(1, int(remaining * 1000))):
job = pending[read_fd]
chunk = os.read(read_fd, 65536)
if chunk:
# An answer can be larger than a pipe holds, which makes the child wait
# for the parent to read, so a descriptor is read until its end.
if job['oversized']:
continue
job['chunks'].append(chunk)
job['size'] += len(chunk)
if job['size'] > MAX_RESULT_BYTES:
# keep draining so the child can finish instead of waiting for
# a reader that stopped, but throw the answer away
job['chunks'] = []
job['oversized'] = True
continue
del pending[read_fd]
poller.unregister(read_fd)
os.close(read_fd)
os.waitpid(job['pid'], 0)
results[job['key']] = (
(False, _TOO_LARGE) if job['oversized'] else _decode(job['chunks'])
)
for read_fd, job in pending.items():
os.kill(job['pid'], signal.SIGKILL)
os.waitpid(job['pid'], 0)
os.close(read_fd)
results[job['key']] = (False, TIMEOUT)
return results
def _decode(chunks):
"""
Turn what a child wrote back into a `(True, result)` or `(False, error_message)`
tuple.
"""
try:
answer = json.loads(txt.to_text(b''.join(chunks)))
except Exception:
return False, _UNREADABLE
if not isinstance(answer, dict) or 'ok' not in answer:
return False, _UNREADABLE
if not answer['ok']:
return False, str(answer.get('error', _UNREADABLE))
return True, answer.get('value')
def _work(func, read_fd, write_fd, inherited):
"""
Run one job in the child process and leave. Never returns.
The child answers with `{"ok": ...}` either way, so that a job which raised is told
apart from one that never came back. It leaves through `os._exit()`, which skips
cleanup handlers and buffered output, so it can neither emit anything of its own nor
run any of the caller's remaining code.
"""
for fd in inherited:
try:
os.close(fd)
except OSError:
pass
try:
os.close(read_fd)
except OSError:
pass
try:
answer = {'ok': True, 'value': func()}
except Exception as e:
answer = {'ok': False, 'error': str(e)}
try:
os.write(write_fd, txt.to_bytes(json.dumps(answer)))
except Exception:
pass
os._exit(0)