Repository navigation
Expand file tree
/
Copy pathtask.py
More file actions
333 lines (288 loc) · 12.4 KB
/
Copy pathtask.py
File metadata and controls
333 lines (288 loc) · 12.4 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
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
#! /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. Most of these sleeps
end on a signal that terminates the process, which is what makes the kill work. A few
waits in the kernel do not, among them writing back data to a server that is gone when a
file on it is closed, which also happens when a process exits with such a file open
(`nfs_file_flush()` -> `nfs_wb_all()` -> `folio_wait_bit()` in `TASK_UNINTERRUPTIBLE`,
verified against Linux 7.3-rc3). A child stuck there is left behind rather than holding
up the caller, and is collected by init once the caller ends.
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__ = '2026101201'
import json
import os
import select
import signal
import sys
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'
# How long the children are given, together, to actually end before `run_each()` returns
# without them. Long enough for a process that can act on SIGKILL, short enough that one
# which cannot does not extend the caller's deadline in any meaningful way. Same as
# `shell._KILL_GRACE`.
_KILL_GRACE = 1
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.
Examples
--------
>>> run(lambda: os.statvfs('/mnt/data').f_bfree, timeout=5)
(True, 3244913)
"""
return run_each([(None, func)], timeout)[None]
def _flush_std_streams():
"""
Write out what the caller has printed so far, so that no child starts with a copy of
it.
`os.fork()` does not do this itself, and stdout on a pipe is fully buffered, stderr
line-buffered. A job that flushes, or prints enough to fill the buffer, would
otherwise write the caller's pending output a second time. Same as what
`multiprocessing` does before it forks (`multiprocessing.util._flush_std_streams()`,
verified against CPython 3.15). A stream that is missing (`None`), closed or broken
is left alone, because it must not keep the jobs from running.
"""
for stream in (sys.stdout, sys.stderr):
try:
stream.flush()
except (AttributeError, OSError, ValueError):
pass
def _reap(pids):
"""
Collect the given children, waiting `_KILL_GRACE` seconds for all of them together.
A blocking `os.waitpid(pid, 0)` would wait for as long as a child sleeps in the
kernel without reacting to SIGKILL, which is exactly what `run_each()` must not do.
A child that has not ended when the grace period is over is left behind; init
collects it once the caller ends. `WNOHANG` polls the child alone, like
`subprocess.Popen.wait()` with a timeout does.
"""
deadline = time.monotonic() + _KILL_GRACE
pids = list(pids)
while pids:
for pid in list(pids):
try:
done, _ = os.waitpid(pid, os.WNOHANG)
except ChildProcessError:
done = pid
if done:
pids.remove(pid)
if not pids or time.monotonic() >= deadline:
return
time.sleep(0.01)
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.
Examples
--------
>>> 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 = {}
# every child started, to be collected before returning
children = []
# 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 = []
_flush_std_streams()
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)
# The end of the pipe does not mean the child is gone: it may still be
# closing other files on its way out. It is collected below, bounded.
children.append(job['pid'])
results[job['key']] = (
(False, _TOO_LARGE) if job['oversized'] else _decode(job['chunks'])
)
for read_fd, job in pending.items():
try:
os.kill(job['pid'], signal.SIGKILL)
except ProcessLookupError:
pass
children.append(job['pid'])
os.close(read_fd)
results[job['key']] = (False, TIMEOUT)
_reap(children)
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.
`os._exit()` sits in a `finally`, because anything that propagated out of here would
unwind into the caller's code inside the child: its `finally` blocks and exit
handlers would run a second time, and whatever the job left in the output buffers,
such as the message of `base.coe()`, would end up in the caller's output. That
includes `SystemExit`, which `sys.exit()`, `base.coe()` and `base.cu()` raise, and
`KeyboardInterrupt`, none of which is an `Exception`.
"""
try:
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 SystemExit as e:
# what base.coe() or base.cu() printed before is discarded with the rest of
# the child's buffered output, so the answer has to say what happened
answer = {
'ok': False,
'error': f'the job called sys.exit({e.code!r}) instead of returning',
}
except BaseException as e:
answer = {'ok': False, 'error': str(e) or type(e).__name__}
os.write(write_fd, txt.to_bytes(json.dumps(answer)))
finally:
os._exit(0)