mirror of
https://git.savannah.gnu.org/git/guile.git
synced 2025-04-29 19:30:36 +02:00
Add 'spawn'.
* libguile/posix.c: Include spawn.h from Gnulib. (do_spawn, scm_spawn_process): New functions. (kw_environment, hw_input, kw_output, kw_error, kw_search_path): New variables. * doc/ref/posix.texi (Processes): Document it. * test-suite/tests/posix.test ("spawn"): New test prefix. * NEWS: Update. Co-authored-by: Ludovic Courtès <ludo@gnu.org> Signed-off-by: Ludovic Courtès <ludo@gnu.org>
This commit is contained in:
parent
edfca3b7e5
commit
551929e4fb
5 changed files with 303 additions and 10 deletions
15
NEWS
15
NEWS
|
@ -1,5 +1,5 @@
|
||||||
Guile NEWS --- history of user-visible changes.
|
Guile NEWS --- history of user-visible changes.
|
||||||
Copyright (C) 1996-2022 Free Software Foundation, Inc.
|
Copyright (C) 1996-2023 Free Software Foundation, Inc.
|
||||||
See the end for copying conditions.
|
See the end for copying conditions.
|
||||||
|
|
||||||
Please send Guile bug reports to bug-guile@gnu.org.
|
Please send Guile bug reports to bug-guile@gnu.org.
|
||||||
|
@ -11,6 +11,19 @@ Changes in 3.0.9 (since 3.0.8)
|
||||||
|
|
||||||
* New interfaces and functionality
|
* New interfaces and functionality
|
||||||
|
|
||||||
|
** New `spawn' procedure to spawn child processes
|
||||||
|
|
||||||
|
The new `spawn' procedure creates a child processes executing the given
|
||||||
|
program. It lets you control the environment variables of that process
|
||||||
|
and redirect its standard input, standard output, and standard error
|
||||||
|
streams.
|
||||||
|
|
||||||
|
Being implemented in terms of `posix_spawn', it is more portable, more
|
||||||
|
robust, and more efficient than the combination of `primitive-fork' and
|
||||||
|
`execl'. See "Processes" in the manual for details, and see the 2019
|
||||||
|
paper entitled "A fork() in the road" (Andrew Baumann et al.) for
|
||||||
|
background information.
|
||||||
|
|
||||||
** `open-file' now supports an "e" flag for O_CLOEXEC
|
** `open-file' now supports an "e" flag for O_CLOEXEC
|
||||||
|
|
||||||
Until now, the high-level `open-file' facility did not provide a way to
|
Until now, the high-level `open-file' facility did not provide a way to
|
||||||
|
|
|
@ -2045,15 +2045,67 @@ safe to call after a multithreaded fork, which is a very limited set.
|
||||||
Guile issues a warning if it detects a fork from a multi-threaded
|
Guile issues a warning if it detects a fork from a multi-threaded
|
||||||
program.
|
program.
|
||||||
|
|
||||||
If you are going to @code{exec} soon after forking, the procedures in
|
@quotation Note
|
||||||
@code{(ice-9 popen)} may be useful to you, as they fork and exec within
|
If you are looking to spawn a process with some pipes set up, using the
|
||||||
an async-signal-safe function carefully written to ensure robust program
|
@code{spawn} procedure described below will be more robust (in
|
||||||
behavior, even in the presence of threads. @xref{Pipes}, for more.
|
particular in multi-threaded contexts), more portable, and usually more
|
||||||
|
efficient than the combination of @code{primitive-fork} and
|
||||||
|
@code{execl}.
|
||||||
|
|
||||||
|
@c Recommended reading: ``A fork() in the road'', HotOS 2019,
|
||||||
|
@c <https://dx.doi.org/10.1145/3317550.3321435> (paywalled :-/).
|
||||||
|
@end quotation
|
||||||
|
|
||||||
This procedure has been renamed from @code{fork} to avoid a naming conflict
|
This procedure has been renamed from @code{fork} to avoid a naming conflict
|
||||||
with the scsh fork.
|
with the scsh fork.
|
||||||
@end deffn
|
@end deffn
|
||||||
|
|
||||||
|
@deffn {Scheme Procedure} spawn @var{program} @var{arguments} @
|
||||||
|
[#:environment=(environ)] @
|
||||||
|
[#:input=(current-input-port)] @
|
||||||
|
[#:output=(current-output-port)] @
|
||||||
|
[#:error=(current-error-port)] @
|
||||||
|
[#:search-path?=#t]
|
||||||
|
Spawn a new child process executing @var{program} with the
|
||||||
|
given @var{arguments}, a list of one or more strings (by
|
||||||
|
convention, the first argument is typically @var{program}),
|
||||||
|
and return its PID. Raise a @code{system-error} exception if
|
||||||
|
@var{program} could not be found or could not be executed.
|
||||||
|
|
||||||
|
If the keyword argument @code{#:search-path?} is true, it
|
||||||
|
selects whether the @env{PATH} environment variable should be
|
||||||
|
inspected to find @var{program}. It is true by default.
|
||||||
|
|
||||||
|
The @code{#:environment} keyword parameter specifies the
|
||||||
|
list of environment variables of the child process. It
|
||||||
|
defaults to @code{(environ)}.
|
||||||
|
|
||||||
|
The keyword arguments @code{#:input}, @code{#:output}, and
|
||||||
|
@code{#:error} specify the port or file descriptor for the
|
||||||
|
child process to use as standard input, standard output, and
|
||||||
|
standard error. No other file descriptors are inherited
|
||||||
|
from the parent process.
|
||||||
|
@end deffn
|
||||||
|
|
||||||
|
The example below shows how to spawn the @command{uname} program with
|
||||||
|
the @option{-o} option (@pxref{uname invocation,,, coreutils, GNU
|
||||||
|
Coreutils}), redirect its standard output to a pipe, and read from it:
|
||||||
|
|
||||||
|
@lisp
|
||||||
|
(use-modules (rnrs io ports))
|
||||||
|
|
||||||
|
(let* ((input+output (pipe))
|
||||||
|
(pid (spawn "uname" '("uname" "-o")
|
||||||
|
#:output (cdr input+output))))
|
||||||
|
(close-port (cdr input+output))
|
||||||
|
(format #t "read ~s~%" (get-string-all (car input+output)))
|
||||||
|
(close-port (car input+output))
|
||||||
|
(waitpid pid))
|
||||||
|
|
||||||
|
@print{} read "GNU/Linux\n"
|
||||||
|
@result{} (1234 . 0)
|
||||||
|
@end lisp
|
||||||
|
|
||||||
@deffn {Scheme Procedure} nice incr
|
@deffn {Scheme Procedure} nice incr
|
||||||
@deffnx {C Function} scm_nice (incr)
|
@deffnx {C Function} scm_nice (incr)
|
||||||
@cindex process priority
|
@cindex process priority
|
||||||
|
|
156
libguile/posix.c
156
libguile/posix.c
|
@ -1,4 +1,4 @@
|
||||||
/* Copyright 1995-2014, 2016-2019, 2021-2022
|
/* Copyright 1995-2014, 2016-2019, 2021-2023
|
||||||
Free Software Foundation, Inc.
|
Free Software Foundation, Inc.
|
||||||
Copyright 2021 Maxime Devos <maximedevos@telenet.be>
|
Copyright 2021 Maxime Devos <maximedevos@telenet.be>
|
||||||
|
|
||||||
|
@ -33,6 +33,7 @@
|
||||||
#include <sys/types.h>
|
#include <sys/types.h>
|
||||||
#include <uniconv.h>
|
#include <uniconv.h>
|
||||||
#include <unistd.h>
|
#include <unistd.h>
|
||||||
|
#include <spawn.h>
|
||||||
|
|
||||||
#ifdef HAVE_SCHED_H
|
#ifdef HAVE_SCHED_H
|
||||||
# include <sched.h>
|
# include <sched.h>
|
||||||
|
@ -63,6 +64,7 @@
|
||||||
#include "fports.h"
|
#include "fports.h"
|
||||||
#include "gettext.h"
|
#include "gettext.h"
|
||||||
#include "gsubr.h"
|
#include "gsubr.h"
|
||||||
|
#include "keywords.h"
|
||||||
#include "list.h"
|
#include "list.h"
|
||||||
#include "modules.h"
|
#include "modules.h"
|
||||||
#include "numbers.h"
|
#include "numbers.h"
|
||||||
|
@ -1426,6 +1428,156 @@ start_child (const char *exec_file, char **exec_argv,
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
|
static pid_t
|
||||||
|
do_spawn (char *exec_file, char **exec_argv, char **exec_env,
|
||||||
|
int in, int out, int err, int spawnp)
|
||||||
|
{
|
||||||
|
pid_t pid = -1;
|
||||||
|
|
||||||
|
posix_spawn_file_actions_t actions;
|
||||||
|
posix_spawnattr_t *attrp = NULL;
|
||||||
|
|
||||||
|
int max_fd = 1024;
|
||||||
|
|
||||||
|
#if defined (HAVE_GETRLIMIT) && defined (RLIMIT_NOFILE)
|
||||||
|
{
|
||||||
|
struct rlimit lim = { 0, 0 };
|
||||||
|
if (getrlimit (RLIMIT_NOFILE, &lim) == 0)
|
||||||
|
max_fd = lim.rlim_cur;
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
|
posix_spawn_file_actions_init (&actions);
|
||||||
|
|
||||||
|
int free_fd_slots = 0;
|
||||||
|
int fd_slot[3];
|
||||||
|
|
||||||
|
for (int fdnum = 3;free_fd_slots < 3 && fdnum < max_fd;fdnum++)
|
||||||
|
{
|
||||||
|
if (fdnum != in && fdnum != out && fdnum != err)
|
||||||
|
{
|
||||||
|
fd_slot[free_fd_slots] = fdnum;
|
||||||
|
free_fd_slots++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Move the fds out of the way, so that duplicate fds or fds equal
|
||||||
|
to 0, 1, 2 don't trample each other */
|
||||||
|
|
||||||
|
posix_spawn_file_actions_adddup2 (&actions, in, fd_slot[0]);
|
||||||
|
posix_spawn_file_actions_adddup2 (&actions, out, fd_slot[1]);
|
||||||
|
posix_spawn_file_actions_adddup2 (&actions, err, fd_slot[2]);
|
||||||
|
posix_spawn_file_actions_adddup2 (&actions, fd_slot[0], 0);
|
||||||
|
posix_spawn_file_actions_adddup2 (&actions, fd_slot[1], 1);
|
||||||
|
posix_spawn_file_actions_adddup2 (&actions, fd_slot[2], 2);
|
||||||
|
|
||||||
|
while (--max_fd > 2)
|
||||||
|
posix_spawn_file_actions_addclose (&actions, max_fd);
|
||||||
|
|
||||||
|
int res = -1;
|
||||||
|
if (spawnp)
|
||||||
|
res = posix_spawnp (&pid, exec_file, &actions, attrp,
|
||||||
|
exec_argv, exec_env);
|
||||||
|
else
|
||||||
|
res = posix_spawn (&pid, exec_file, &actions, attrp,
|
||||||
|
exec_argv, exec_env);
|
||||||
|
if (res != 0)
|
||||||
|
return -1;
|
||||||
|
|
||||||
|
return pid;
|
||||||
|
}
|
||||||
|
|
||||||
|
SCM_KEYWORD (kw_environment, "environment");
|
||||||
|
SCM_KEYWORD (kw_input, "input");
|
||||||
|
SCM_KEYWORD (kw_output, "output");
|
||||||
|
SCM_KEYWORD (kw_error, "error");
|
||||||
|
SCM_KEYWORD (kw_search_path, "search-path?");
|
||||||
|
|
||||||
|
SCM_DEFINE (scm_spawn_process, "spawn", 2, 0, 1,
|
||||||
|
(SCM program, SCM arguments, SCM keyword_args),
|
||||||
|
"Spawn a new child process executing @var{program} with the\n"
|
||||||
|
"given @var{arguments}, a list of one or more strings (by\n"
|
||||||
|
"convention, the first argument is typically @var{program}),\n"
|
||||||
|
"and return its PID. Raise a @code{system-error} exception if\n"
|
||||||
|
"@var{program} could not be found or could not be executed.\n\n"
|
||||||
|
"If the keyword argument @code{#:search-path?} is true, it\n"
|
||||||
|
"selects whether the @env{PATH} environment variable should be\n"
|
||||||
|
"inspected to find @var{program}. It is true by default.\n\n"
|
||||||
|
"The @code{#:environment} keyword parameter specifies the\n"
|
||||||
|
"list of environment variables of the child process. It\n"
|
||||||
|
"defaults to @code{(environ)}.\n\n"
|
||||||
|
"The keyword arguments @code{#:input}, @code{#:output}, and\n"
|
||||||
|
"@code{#:error} specify the port or file descriptor for the\n"
|
||||||
|
"child process to use as standard input, standard output, and\n"
|
||||||
|
"standard error. No other file descriptors are inherited\n"
|
||||||
|
"from the parent process.\n")
|
||||||
|
#define FUNC_NAME s_scm_spawn_process
|
||||||
|
{
|
||||||
|
SCM env, in_scm, out_scm, err_scm, use_path;
|
||||||
|
int pid = -1;
|
||||||
|
char *exec_file, **exec_argv, **exec_env;
|
||||||
|
int in, out, err;
|
||||||
|
|
||||||
|
/* In theory 'exec' accepts zero arguments, but programs are typically
|
||||||
|
not prepared for that and POSIX says: "The value in argv[0] should
|
||||||
|
point to a filename string that is associated with the process
|
||||||
|
image being started" (see
|
||||||
|
<https://pubs.opengroup.org/onlinepubs/9699919799/functions/posix_spawn.html>). */
|
||||||
|
SCM_VALIDATE_NONEMPTYLIST (1, arguments);
|
||||||
|
|
||||||
|
env = SCM_UNDEFINED;
|
||||||
|
in_scm = SCM_UNDEFINED;
|
||||||
|
out_scm = SCM_UNDEFINED;
|
||||||
|
err_scm = SCM_UNDEFINED;
|
||||||
|
use_path = SCM_BOOL_T;
|
||||||
|
|
||||||
|
scm_c_bind_keyword_arguments (FUNC_NAME, keyword_args, 0,
|
||||||
|
kw_environment, &env,
|
||||||
|
kw_input, &in_scm,
|
||||||
|
kw_output, &out_scm,
|
||||||
|
kw_error, &err_scm,
|
||||||
|
kw_search_path, &use_path,
|
||||||
|
SCM_UNDEFINED);
|
||||||
|
|
||||||
|
scm_dynwind_begin (0);
|
||||||
|
|
||||||
|
exec_file = scm_to_locale_string (program);
|
||||||
|
scm_dynwind_free (exec_file);
|
||||||
|
|
||||||
|
exec_argv = scm_i_allocate_string_pointers (arguments);
|
||||||
|
|
||||||
|
if (SCM_UNBNDP (env))
|
||||||
|
exec_env = environ;
|
||||||
|
else
|
||||||
|
exec_env = scm_i_allocate_string_pointers (env);
|
||||||
|
|
||||||
|
if (SCM_UNBNDP (in_scm))
|
||||||
|
in_scm = scm_current_input_port ();
|
||||||
|
if (SCM_UNBNDP (out_scm))
|
||||||
|
out_scm = scm_current_output_port ();
|
||||||
|
if (SCM_UNBNDP (err_scm))
|
||||||
|
err_scm = scm_current_error_port ();
|
||||||
|
|
||||||
|
#define FDES_FROM_PORT_OR_INTEGER(obj) \
|
||||||
|
(scm_is_integer (obj) ? scm_to_int (obj) : SCM_FPORT_FDES (obj))
|
||||||
|
|
||||||
|
in = FDES_FROM_PORT_OR_INTEGER (in_scm);
|
||||||
|
out = FDES_FROM_PORT_OR_INTEGER (out_scm);
|
||||||
|
err = FDES_FROM_PORT_OR_INTEGER (err_scm);
|
||||||
|
|
||||||
|
#undef FDES_FROM_PORT_OR_INTEGER
|
||||||
|
|
||||||
|
pid = do_spawn (exec_file, exec_argv, exec_env,
|
||||||
|
in, out, err, scm_to_bool (use_path));
|
||||||
|
if (pid == -1)
|
||||||
|
SCM_SYSERROR;
|
||||||
|
|
||||||
|
scm_dynwind_end ();
|
||||||
|
|
||||||
|
return scm_from_int (pid);
|
||||||
|
}
|
||||||
|
#undef FUNC_NAME
|
||||||
|
|
||||||
#ifdef HAVE_START_CHILD
|
#ifdef HAVE_START_CHILD
|
||||||
static SCM
|
static SCM
|
||||||
scm_piped_process (SCM prog, SCM args, SCM from, SCM to)
|
scm_piped_process (SCM prog, SCM args, SCM from, SCM to)
|
||||||
|
@ -2547,5 +2699,5 @@ scm_init_posix ()
|
||||||
"scm_init_popen",
|
"scm_init_popen",
|
||||||
(scm_t_extension_init_func) scm_init_popen,
|
(scm_t_extension_init_func) scm_init_popen,
|
||||||
NULL);
|
NULL);
|
||||||
#endif /* HAVE_START_CHILD */
|
#endif /* HAVE_FORK */
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
#ifndef SCM_POSIX_H
|
#ifndef SCM_POSIX_H
|
||||||
#define SCM_POSIX_H
|
#define SCM_POSIX_H
|
||||||
|
|
||||||
/* Copyright 1995-1998,2000-2001,2003,2006,2008-2011,2018,2021,2022
|
/* Copyright 1995-1998, 2000-2001, 2003, 2006, 2008-2011, 2018, 2021-2023
|
||||||
Free Software Foundation, Inc.
|
Free Software Foundation, Inc.
|
||||||
|
|
||||||
This file is part of Guile.
|
This file is part of Guile.
|
||||||
|
@ -69,6 +69,7 @@ SCM_API SCM scm_tmpnam (void);
|
||||||
SCM_API SCM scm_tmpfile (void);
|
SCM_API SCM scm_tmpfile (void);
|
||||||
SCM_API SCM scm_open_pipe (SCM pipestr, SCM modes);
|
SCM_API SCM scm_open_pipe (SCM pipestr, SCM modes);
|
||||||
SCM_API SCM scm_close_pipe (SCM port);
|
SCM_API SCM scm_close_pipe (SCM port);
|
||||||
|
SCM_INTERNAL SCM scm_spawn_process (SCM prog, SCM arguments, SCM keyword_args);
|
||||||
SCM_API SCM scm_system_star (SCM cmds);
|
SCM_API SCM scm_system_star (SCM cmds);
|
||||||
SCM_API SCM scm_utime (SCM object, SCM actime, SCM modtime,
|
SCM_API SCM scm_utime (SCM object, SCM actime, SCM modtime,
|
||||||
SCM actimens, SCM modtimens, SCM flags);
|
SCM actimens, SCM modtimens, SCM flags);
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
;;;; posix.test --- Test suite for Guile POSIX functions. -*- scheme -*-
|
;;;; posix.test --- Test suite for Guile POSIX functions. -*- scheme -*-
|
||||||
;;;;
|
;;;;
|
||||||
;;;; Copyright 2003-2004, 2006-2007, 2010, 2012, 2015, 2017-2019, 2021-2022
|
;;;; Copyright 2003-2004, 2006-2007, 2010, 2012, 2015, 2017-2019, 2021-2023
|
||||||
;;;; Free Software Foundation, Inc.
|
;;;; Free Software Foundation, Inc.
|
||||||
;;;; Copyright 2021 Maxime Devos <maximedevos@telenet.be>
|
;;;; Copyright 2021 Maxime Devos <maximedevos@telenet.be>
|
||||||
;;;;
|
;;;;
|
||||||
|
@ -19,7 +19,8 @@
|
||||||
;;;; Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
|
;;;; Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
|
||||||
|
|
||||||
(define-module (test-suite test-posix)
|
(define-module (test-suite test-posix)
|
||||||
:use-module (test-suite lib))
|
#:use-module (test-suite lib)
|
||||||
|
#:use-module ((rnrs io ports) #:select (get-string-all)))
|
||||||
|
|
||||||
|
|
||||||
;; FIXME: The following exec tests are disabled since on an i386 debian with
|
;; FIXME: The following exec tests are disabled since on an i386 debian with
|
||||||
|
@ -359,6 +360,80 @@
|
||||||
(parameterize ((current-output-port (current-error-port)))
|
(parameterize ((current-output-port (current-error-port)))
|
||||||
(status:exit-val (system* "something-that-does-not-exist")))))
|
(status:exit-val (system* "something-that-does-not-exist")))))
|
||||||
|
|
||||||
|
;;
|
||||||
|
;; spawn
|
||||||
|
;;
|
||||||
|
|
||||||
|
(with-test-prefix "spawn"
|
||||||
|
|
||||||
|
(pass-if-equal "basic"
|
||||||
|
0
|
||||||
|
(cdr (waitpid (spawn "true" '("true")))))
|
||||||
|
|
||||||
|
(pass-if-equal "uname with stdout redirect"
|
||||||
|
(list 0 ;exit value
|
||||||
|
(string-append (utsname:sysname (uname)) " "
|
||||||
|
(utsname:machine (uname)) "\n"))
|
||||||
|
(let* ((input+output (pipe))
|
||||||
|
(pid (spawn "uname" '("uname" "-s" "-m")
|
||||||
|
#:output (cdr input+output))))
|
||||||
|
(close-port (cdr input+output))
|
||||||
|
(let ((str (get-string-all (car input+output))))
|
||||||
|
(close-port (car input+output))
|
||||||
|
(list (cdr (waitpid pid)) str))))
|
||||||
|
|
||||||
|
(pass-if-equal "wc with stdin and stdout redirects"
|
||||||
|
"2\n"
|
||||||
|
(let* ((a+b (pipe))
|
||||||
|
(c+d (pipe))
|
||||||
|
(pid (spawn "wc" '("wc" "-w")
|
||||||
|
#:input (car a+b)
|
||||||
|
#:output (cdr c+d))))
|
||||||
|
(close-port (car a+b))
|
||||||
|
(close-port (cdr c+d))
|
||||||
|
|
||||||
|
(display "Hello world.\n" (cdr a+b))
|
||||||
|
(close-port (cdr a+b))
|
||||||
|
|
||||||
|
(let ((str (get-string-all (car c+d))))
|
||||||
|
(close-port (car c+d))
|
||||||
|
(waitpid pid)
|
||||||
|
str)))
|
||||||
|
|
||||||
|
(pass-if-equal "env with #:environment and #:output"
|
||||||
|
"GNU=guile\n"
|
||||||
|
(let* ((input+output (pipe))
|
||||||
|
(pid (spawn "env" '("env")
|
||||||
|
#:environment '("GNU=guile")
|
||||||
|
#:output (cdr input+output))))
|
||||||
|
(close-port (cdr input+output))
|
||||||
|
(let ((str (get-string-all (car input+output))))
|
||||||
|
(close-port (car input+output))
|
||||||
|
(waitpid pid)
|
||||||
|
str)))
|
||||||
|
|
||||||
|
(pass-if-equal "ls /proc/self/fd"
|
||||||
|
"0\n1\n2\n3\n" ;fourth FD is for /proc/self/fd
|
||||||
|
(if (file-exists? "/proc/self/fd") ;Linux
|
||||||
|
(let* ((input+output (pipe))
|
||||||
|
(pid (spawn "ls" '("ls" "/proc/self/fd")
|
||||||
|
#:output (cdr input+output))))
|
||||||
|
(close-port (cdr input+output))
|
||||||
|
(let ((str (get-string-all (car input+output))))
|
||||||
|
(close-port (car input+output))
|
||||||
|
(waitpid pid)
|
||||||
|
str))
|
||||||
|
(throw 'unresolved)))
|
||||||
|
|
||||||
|
(pass-if-equal "file not found"
|
||||||
|
ENOENT
|
||||||
|
(catch 'system-error
|
||||||
|
(lambda ()
|
||||||
|
(spawn "this-does-not-exist" '("nope")
|
||||||
|
#:search-path? #f))
|
||||||
|
(lambda args
|
||||||
|
(system-error-errno args)))))
|
||||||
|
|
||||||
;;
|
;;
|
||||||
;; crypt
|
;; crypt
|
||||||
;;
|
;;
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue