Skip to content

Commit 0d5531a

Browse files
committed
SedRegex: Add support for Windows
The only multiprocessing method available on Windows is 'spawn', which requires functions to be picklable and they can't access Limnoria's global state. This means that they can't access the message history, so we need to pass messages as a list (though we prefilter it to avoid wasting resources). Additionally, this adds support for calling loadPluginModule in the child process before unpickling so that we can unpickle functions defined in plugins. This also paves the way toward supporting the 'forkserver' method on POSIX systems, which has the same restrictions as the 'spawn' method but is safer than 'fork' as we use threads in the main process.
1 parent 96fc0e7 commit 0d5531a

4 files changed

Lines changed: 164 additions & 14 deletions

File tree

plugins/SedRegex/plugin.py

Lines changed: 49 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
###
22
# Copyright (c) 2015, Michael Daniel Telatynski <postmaster@webdevguru.co.uk>
3-
# Copyright (c) 2015-2020, James Lu <james@overdrivenetworks.com>
4-
# Copyright (c) 2020-2021, Valentin Lorentz
3+
# Copyright (c) 2015-2025, James Lu <james@overdrivenetworks.com>
4+
# Copyright (c) 2020-2026, Valentin Lorentz
55
# All rights reserved.
66
#
77
# Redistribution and use in source and binary forms, with or without
@@ -30,6 +30,7 @@
3030

3131
###
3232

33+
import multiprocessing
3334
from supybot.commands import *
3435
from supybot.commands import ProcessTimeoutError
3536
import supybot.plugins as plugins
@@ -38,6 +39,7 @@
3839
import supybot.ircutils as ircutils
3940
import supybot.ircdb as ircdb
4041
import supybot.utils as utils
42+
import supybot.world as world
4143

4244
import re
4345

@@ -112,6 +114,14 @@ def apply_substitution(pattern, replacement, m, count):
112114

113115
return axe_spaces(subst)
114116

117+
def apply_substitution_to_first_matching_message(pattern, replacement,
118+
messages, count):
119+
m = get_first_matching_message(pattern, messages)
120+
if m:
121+
subst = apply_substitution(pattern, replacement, m, count)
122+
return (m, subst)
123+
124+
115125
class SedRegex(callbacks.Plugin):
116126
"""
117127
Enable SedRegex on the desired channels:
@@ -232,9 +242,21 @@ def doPrivmsg(self, irc, msg):
232242
if self.registryValue('boldReplacementText', msg.channel, irc.network):
233243
replacement = ircutils.bold(replacement)
234244
try:
235-
message = process(self._replacer_process, irc, msg,
236-
target, pattern, replacement, count, iterable, sedRegex,
237-
timeout=regex_timeout, pn=self.name(), cn='replacer')
245+
if isinstance(world.SUPYPROCESS_MULTIPROCESSING_CONTEXT,
246+
multiprocessing.context.ForkContext):
247+
# global state is shared with child processes, so the child
248+
# process has access to history and can lazily filter it
249+
message = process(self._replacer_process, irc, msg,
250+
target, pattern, replacement, count, iterable, sedRegex,
251+
timeout=regex_timeout, pn=self.name(), cn='replacer')
252+
else:
253+
# SpawnContext (or possibly ForkServerContext in future
254+
# versions of Limnoria): global state is not shared with child
255+
# processes, so we have to filter the message list in the main
256+
# process and pass it to the child.
257+
message = self._replacer(irc, msg,
258+
target, pattern, replacement, count, iterable, sedRegex,
259+
timeout=regex_timeout, pn=self.name(), cn='replacer')
238260
except ProcessTimeoutError:
239261
irc.error(_("Search timed out."))
240262
except SearchNotFoundError:
@@ -277,6 +299,28 @@ def _replacer_process(self, irc, msg, target, pattern, replacement, count,
277299
msg.args[1], len(irc.state.history), msg.args[0])
278300
raise SearchNotFoundError()
279301

302+
def _replacer(self, irc, msg, target, pattern, replacement, count,
303+
messages, sedRegex, **kwargs):
304+
ignoreRegex = self.registryValue('ignoreRegex', msg.channel, irc.network)
305+
messages = filter_messages(irc.network, msg, target, messages,
306+
ignoreRegex, sedRegex)
307+
messages = list(messages) # materialize the iterator to pickle it
308+
309+
try:
310+
result = process(apply_substitution_to_first_matching_message,
311+
pattern, replacement, messages, count,
312+
preload_plugins=["SedRegex"], **kwargs)
313+
if result:
314+
(m, subst) = result
315+
return self._format_result(irc, msg, m, subst)
316+
except Exception as e:
317+
self.log.warning(_("SedRegex error: %s"), e, exc_info=True)
318+
raise
319+
320+
self.log.debug(_("SedRegex: Search %r not found in the last %i messages of %s."),
321+
msg.args[1], len(irc.state.history), msg.args[0])
322+
raise SearchNotFoundError()
323+
280324
doNotice = doPrivmsg
281325

282326
Class = SedRegex

plugins/SedRegex/test.py

Lines changed: 33 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
###
2-
# Copyright (c) 2017-2020, James Lu <james@overdrivenetworks.com>
3-
# Copyright (c) 2020-2021, Valentin Lorentz
2+
# Copyright (c) 2017-2025, James Lu <james@overdrivenetworks.com>
3+
# Copyright (c) 2020-2026, Valentin Lorentz
44
# All rights reserved.
55
#
66
# Redistribution and use in source and binary forms, with or without
@@ -31,7 +31,11 @@
3131

3232
from __future__ import print_function
3333
import unittest
34+
import contextlib
35+
import multiprocessing
3436
from supybot.test import *
37+
import supybot.world as world
38+
import supybot.callbacks as callbacks
3539

3640
class SedRegexTestCase(ChannelPluginTestCase):
3741
other = "blah!blah@someone.else"
@@ -324,4 +328,31 @@ def testFmtStringOtherPerson(self):
324328

325329
# TODO: test ignores
326330

331+
332+
@unittest.skipIf(
333+
world.disableMultiprocessing,
334+
"Test requires multiprocessing to be enabled"
335+
)
336+
def testSpawnContext(self):
337+
"""Test that SedRegex works with 'spawn' multiprocessing context
338+
(Windows compatibility)."""
339+
with useSpawnContext():
340+
self.feedMsg('hello world')
341+
self.feedMsg('s/world/everyone/')
342+
m = self.getMsg(' ')
343+
self.assertIn('hello everyone', str(m))
344+
345+
@unittest.skipIf(
346+
world.disableMultiprocessing,
347+
"Test requires multiprocessing to be enabled"
348+
)
349+
def testSpawnReDoSTimeout(self):
350+
"""Test that ReDoS protection works with 'spawn' context."""
351+
with useSpawnContext():
352+
for idx in range(500):
353+
self.feedMsg("ACCCCCCCCCCCCCCCCCCCCCCCCCCCCX")
354+
self.feedMsg(r"s/A(B|C+)+D/this should abort/")
355+
m = self.getMsg(' ', timeout=1)
356+
self.assertIn('timed out', str(m))
357+
327358
# vim:set shiftwidth=4 tabstop=4 expandtab textwidth=79:

src/commands.py

Lines changed: 29 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
###
22
# Copyright (c) 2002-2005, Jeremiah Fincher
33
# Copyright (c) 2009-2010,2015, James McCoy
4-
# Copyright (c) 2010-2021, Valentin Lorentz
4+
# Copyright (c) 2010-2026, Valentin Lorentz
55
# All rights reserved.
66
#
77
# Redistribution and use in source and binary forms, with or without
@@ -103,6 +103,17 @@ def _process_target(f, q, heap_size, *args, **kwargs):
103103
except Exception as e:
104104
q.put([True, e])
105105

106+
def _process_queued_target(f_q, q, heap_size, *args, preload_plugins, **kwargs):
107+
"""Called by :func:`process` on non-``fork`` multiprocessing contexts"""
108+
from .plugin import loadPluginModule
109+
110+
for plugin in preload_plugins:
111+
loadPluginModule(plugin, ignoreDeprecation=True)
112+
113+
f = f_q.get()
114+
_process_target(f, q, heap_size, *args, **kwargs)
115+
116+
106117
def process(f, *args, **kwargs):
107118
"""Runs a function <f> in a subprocess.
108119
@@ -121,13 +132,14 @@ def process(f, *args, **kwargs):
121132
if world.disableMultiprocessing:
122133
pn = kwargs.pop('pn', 'Unknown')
123134
cn = kwargs.pop('cn', 'unknown')
135+
kwargs.pop('preload_plugins')
124136
try:
125137
return f(*args, **kwargs)
126138
except Exception as e:
127139
raise e
128140

129141
try:
130-
q = multiprocessing.Queue()
142+
q = world.SUPYPROCESS_MULTIPROCESSING_CONTEXT.Queue()
131143
except OSError:
132144
log.error('Using multiprocessing.Queue raised an OSError.\n'
133145
'This is probably caused by your system denying semaphore\n'
@@ -137,9 +149,21 @@ def process(f, *args, **kwargs):
137149
'(See https://github.com/travis-ci/travis-core/issues/187\n'
138150
'for more information about this bug.)\n')
139151
raise
140-
targetArgs = (f, q, heap_size) + args
141-
p = callbacks.CommandProcess(target=_process_target,
142-
args=targetArgs, kwargs=kwargs)
152+
if isinstance(world.SUPYPROCESS_MULTIPROCESSING_CONTEXT,
153+
multiprocessing.context.ForkContext):
154+
# no need to pickle f
155+
targetArgs = (f, q, heap_size) + args
156+
p = callbacks.CommandProcess(target=_process_target,
157+
args=targetArgs, kwargs=kwargs)
158+
else:
159+
# f must be picklable, but it probably comes from a plugin module,
160+
# so we need to load the plugin module first, before unpickling it.
161+
# so we put it on the queue instead of an argument.
162+
f_q = world.SUPYPROCESS_MULTIPROCESSING_CONTEXT.Queue()
163+
f_q.put(f)
164+
targetArgs = (f_q, q, heap_size) + args
165+
p = callbacks.CommandProcess(target=_process_queued_target,
166+
args=targetArgs, kwargs=kwargs)
143167
try:
144168
p.start()
145169
except OSError as e:

src/test.py

Lines changed: 53 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
###
22
# Copyright (c) 2002-2005, Jeremiah Fincher
33
# Copyright (c) 2011, James McCoy
4-
# Copyright (c) 2010-2021, Valentin Lorentz
4+
# Copyright (c) 2010-2026, Valentin Lorentz
55
# All rights reserved.
66
#
77
# Redistribution and use in source and binary forms, with or without
@@ -37,8 +37,11 @@
3737
import shutil
3838
import urllib
3939
import unittest
40+
import unittest.mock
4041
import functools
4142
import threading
43+
import contextlib
44+
import multiprocessing
4245

4346
from . import (callbacks, conf, drivers, httpserver, i18n, ircdb, irclib,
4447
ircmsgs, ircutils, log, plugin, registry, utils, world)
@@ -660,5 +663,53 @@ class ChannelHTTPPluginTestCase(ChannelPluginTestCase, HTTPPluginTestCase):
660663
def setUp(self):
661664
ChannelPluginTestCase.setUp(self, forceSetup=True)
662665

663-
# vim:set shiftwidth=4 softtabstop=4 expandtab textwidth=79:
664666

667+
# allows plugins to test the 'spawn' and 'forkserver' methods when
668+
# the global multiprocessing context is 'fork'.
669+
#
670+
# This has to be here because plugins can't define it in their test.py,
671+
# because Python unpickles the Process before we get a change to load the
672+
# plugin.
673+
try:
674+
_SPAWN_MULTIPROCESSING_CONTEXT = multiprocessing.get_context('spawn')
675+
except ValueError:
676+
@contextlib.contextmanager
677+
def useSpawnContext():
678+
raise unittest.SkipTest("'spawn' start method is not supported")
679+
else:
680+
class _SpawnSupyProcess(_SPAWN_MULTIPROCESSING_CONTEXT.Process):
681+
def __init__(self, *args, **kwargs):
682+
world.processesSpawned += 1
683+
super(world.SupyProcess, self).__init__(*args, **kwargs)
684+
log.debug('Spawning process %q.', self.name)
685+
686+
class _SpawnCommandProcess(_SpawnSupyProcess):
687+
"""Just does some extra logging and error-recovery for commands that need
688+
to run in processes.
689+
"""
690+
def __init__(self, target=None, args=(), kwargs={}):
691+
pn = kwargs.pop('pn', 'Unknown')
692+
cn = kwargs.pop('cn', 'unknown')
693+
procName = 'Process #%s (for %s.%s)' % (world.processesSpawned,
694+
pn,
695+
cn)
696+
log.debug('Spawning process %s (args: %r)', procName, args)
697+
super().__init__(target=target, name=procName,
698+
args=args, kwargs=kwargs)
699+
700+
@contextlib.contextmanager
701+
def useSpawnContext():
702+
"""Temporarily redefine SupyProcess and CommandProcess to use the
703+
'spawn' multiprocessing context, simulating Windows behaviour on POSIX
704+
systems."""
705+
with contextlib.ExitStack() as stack:
706+
stack.enter_context(unittest.mock.patch(
707+
"supybot.world.SUPYPROCESS_MULTIPROCESSING_CONTEXT",
708+
_SPAWN_MULTIPROCESSING_CONTEXT))
709+
stack.enter_context(unittest.mock.patch(
710+
"supybot.world.SupyProcess", _SpawnSupyProcess))
711+
stack.enter_context(unittest.mock.patch(
712+
"supybot.callbacks.CommandProcess", _SpawnCommandProcess))
713+
yield
714+
715+
# vim:set shiftwidth=4 softtabstop=4 expandtab textwidth=79:

0 commit comments

Comments
 (0)