Skip to content

Commit 02b5172

Browse files
JohnoKingMcDutchie
authored andcommitted
Overhaul spawnveg to use clone(2) and fix many spawning bugs (#888)
Alterations: - Add a version of spawnveg that clones processes with clone(2) outright. This method is generally faster than using posix_spawn, and is also more portable (since Linux with musl libc *and* NetBSD both provide this function). The code was initially based on the _real_vfork spawnveg code previously removed in ef9a5c7 (unlike vfork(2), there is no problem with running code prior to execve for clone with CLONE_VM|CLONE_VFORK). - Remove the now dead code implementing tcsetpgrp via posix_spawn_file_actions_addtcsetpgrp_np(). This code was only ever used with glibc 2.35+. (Not only that, but the only other platform that ever considered implementing such a call was NetBSD[^1], where it appears to have been abandoned. This is ironic considering ksh no longer uses posix_spawn on NetBSD as of this commit.) This code may be worth visiting if io_uring_spawn()[^2] is ever actualized (that project appears to be quite inactive though). - Bugfix: Don't set the terminal signals to their defaults in the parent prior to calling spawnveg. This is the primary cause of a lockup in the pty tests which can be reproduced as follows: $ exec ksh $ bin/shtests pty 2>/dev/null ^C ^C ^C (SIGINT doesn't work anymore and may segfault) The correct way to go about dealing with SIGT* is to set those to SIG_DFL in the child process (cf. _sh_fork()). - Added support for tcsetpgrp to the fork fallback in spawnveg. Some form of this appears to have already been attempted in AT&T olden times, but that old version was broken and needed bugfixes desperately. - If the child needs tcsetpgrp, block the terminal signals in the parent process via sigcritical(). The posix_spawn version doesn't need this because posix_spawn will block signals automatically and therefore doesn't need sigcritical. - Now that the fork fallback for spawnveg works correctly in interactive terminals, prefer that to the sh_fork() codepath on operating systems without posix_spawn tcsetpgrp support. Even though the underlying system call is still ultimately fork, the sh_ntfork() codepath is faster than the traditional sh_fork codepath. Benchmark: $ time /tmp/ksh-devbranch -ic "for i in {1..10000}; do $(whence -p true); done" real 0m03.302s user 0m00.988s sys 0m02.320s $ time /tmp/ksh-newspawnveg -ic "for i in {1..10000}; do $(whence -p true); done" real 0m03.160s user 0m01.187s sys 0m01.977s - To that end, split up the spawnveg versions into spawnveg_fast and spawnveg_slow. Choose the appropriate one when spawnveg is called; this removes the need for the xec.c ifdef hackery. - Removed the ntfork_tcpgrp ifdefs from xec.c; spawnveg can handle it by itself now. - Bugfix: With the spawnveg_fast and spawnveg_slow innovation, spawnveg now always has support for setsid. It'll fallback to fork if POSIX_SPAWN_SETSID and clone(2) aren't available. - Bugfix: For the posix_spawn version of spawnveg, the flags should be of the short type pursuant to the POSIX specification. - Optimization: Use pipe2 in the fork fallback for spawnveg when it's available to avoid two fcntl syscalls. - Updated the spawnveg documentation to reflect the new changes. - _spawnveg(): Restore the terminal process group immediately after any failed spawn attempt, rather than only in sh_ntfork(). - Added a regression test for a crash that occurred because of the erroneous tcsetpgrp placement in sh_ntfork(). - path_spawn(): Do not print an error message and longjmp away upon encountering E2BIG or some other error; let the calling functions take care of that. - path_exec(): Added handling for E2BIG. - Fixed a bug that caused sh_ntfork to leak file descriptors upon encountering an error (re: 8f848bc). Reproducer: #!/bin/ksh ls /proc/$$/fd redirect 2>/dev/null touch /tmp/file for i in {1..20}; do command /tmp/file <(echo) done ls /proc/$$/fd - Added regression tests for the leak that use 'command' and 'command -x'. - Fixed an old regression test that was giving false negatives. - Clarified the intent of the sigcrit nesting matches. The ksh2020 devs appeared to have been confused by this line, so some additional clarification explaining the intent should be helpful for posterity. - path.sh: Added tests for the exit status of commands run when forked with the & operator. [^1]: https://blog.netbsd.org/tnf/entry/gsoc_reports_make_system_31 [^2]: https://lwn.net/Articles/908268/
1 parent 81229c9 commit 02b5172

12 files changed

Lines changed: 381 additions & 190 deletions

File tree

NEWS

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,18 @@ This documents significant changes in the 1.0 branch of ksh 93u+m.
22
For full details, see the git log at: https://github.com/ksh93/ksh/tree/1.0
33
Uppercase BUG_* IDs are shell bug IDs as used by the Modernish shell library.
44

5+
2026-02-17:
6+
7+
- Fixed a regression introduced on 02-02-2022 occurring on systems with
8+
glibc 2.35+ that could cause the pty regression tests to crash or lockup.
9+
10+
- Fixed a regression introduced on 02-02-2022 that could cause ksh to crash
11+
after a command fails with E2BIG.
12+
13+
- Fixed a bug that caused a file descriptor leak when a command run
14+
with the 'command' builtin couldn't be executed due to an error
15+
(e.g. ENOENT or E2BIG).
16+
517
2026-02-15:
618

719
- Fixed a bug in 'ksh -n' that caused the shell to overlook many

src/cmd/ksh93/include/version.h

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818
#include <ast_release.h>
1919
#include "git.h"
2020

21-
#define SH_RELEASE_DATE "2026-02-15" /* must be in this format for $((.sh.version)) */
21+
#define SH_RELEASE_DATE "2026-02-17" /* must be in this format for $((.sh.version)) */
2222
/*
2323
* This comment keeps SH_RELEASE_DATE a few lines away from SH_RELEASE_SVER to avoid
2424
* merge conflicts when cherry-picking dev branch commits onto a release branch.

src/cmd/ksh93/sh/path.c

Lines changed: 18 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,8 @@ static pid_t _spawnveg(const char *path, char* const argv[], char* const envp[],
8787
if(pid>=0 || errno!=EAGAIN)
8888
break;
8989
}
90+
if(pid<0 && job.jobcontrol)
91+
tcsetpgrp(job.fd,sh.pid); /* if spawnveg set tcpgrp, we must restore it ourselves */
9092
return pid;
9193
}
9294

@@ -993,15 +995,20 @@ noreturn void path_exec(const char *arg0,char *argv[],struct argnod *local)
993995
if(sh.subshell)
994996
sh_subtmpfile();
995997
spawnpid = path_spawn(opath,argv,envp,libpath,0);
996-
if(spawnpid==-1 && sh.path_err!=ENOENT)
998+
if(spawnpid == -1)
997999
{
998-
/*
999-
* A command was found but it couldn't be executed.
1000-
* POSIX specifies that the shell should continue to search for the
1001-
* command in PATH and return 126 only when it can't find an executable
1002-
* file in other elements of PATH.
1003-
*/
1004-
not_executable = sh.path_err;
1000+
if(sh.path_err == E2BIG)
1001+
break;
1002+
if(sh.path_err != ENOENT)
1003+
{
1004+
/*
1005+
* A command was found but it couldn't be executed.
1006+
* POSIX specifies that the shell should continue to search for the
1007+
* command in PATH and return 126 only when it can't find an executable
1008+
* file in other elements of PATH.
1009+
*/
1010+
not_executable = sh.path_err;
1011+
}
10051012
}
10061013
while(pp && (pp->flags&PATH_FPATH))
10071014
pp = path_nextcomp(pp,arg0,0);
@@ -1258,13 +1265,6 @@ pid_t path_spawn(const char *opath,char **argv, char **envp, Pathcomp_t *libpath
12581265
case EPERM:
12591266
sh.path_err = errno;
12601267
return -1;
1261-
case ENOTDIR:
1262-
case ENOENT:
1263-
case EINTR:
1264-
#ifdef EMLINK
1265-
case EMLINK:
1266-
#endif /* EMLINK */
1267-
return -1;
12681268
case E2BIG:
12691269
if(sh_isstate(SH_XARG))
12701270
{
@@ -1282,10 +1282,10 @@ pid_t path_spawn(const char *opath,char **argv, char **envp, Pathcomp_t *libpath
12821282
}
12831283
/* FALLTHROUGH */
12841284
default:
1285-
errormsg(SH_DICT,ERROR_system(ERROR_NOEXEC),e_exec,path);
1286-
UNREACHABLE();
1285+
sh.path_err = errno;
1286+
return -1;
12871287
}
1288-
return 0;
1288+
UNREACHABLE();
12891289
}
12901290

12911291
/*

src/cmd/ksh93/sh/xec.c

Lines changed: 7 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -44,11 +44,6 @@
4444
# include <sys/resource.h>
4545
#endif
4646

47-
#undef _use_ntfork_tcpgrp
48-
#if SHOPT_SPAWN && _lib_posix_spawn > 1 && _lib_posix_spawn_file_actions_addtcsetpgrp_np
49-
#define _use_ntfork_tcpgrp 1
50-
#endif
51-
5247
#if SHOPT_SPAWN
5348
static pid_t sh_ntfork(const Shnode_t*,char*[],int*,int);
5449
#endif /* SHOPT_SPAWN */
@@ -1390,11 +1385,7 @@ int sh_exec(const Shnode_t *t, int flags)
13901385
fifo_save_ppid = sh.current_pid;
13911386
#endif
13921387
#if SHOPT_SPAWN
1393-
#if _use_ntfork_tcpgrp
13941388
if(com)
1395-
#else
1396-
if(com && !job.jobcontrol)
1397-
#endif /* _use_ntfork_tcpgrp */
13981389
{
13991390
parent = sh_ntfork(t,com,&jobid,topfd);
14001391
if(parent<0)
@@ -3265,9 +3256,12 @@ static void sigreset(int mode)
32653256
}
32663257

32673258
/*
3268-
* A combined fork/exec for systems with slow fork().
3269-
* Incompatible with job control on interactive shells (job.jobcontrol) if
3270-
* the system does not support posix_spawn_file_actions_addtcsetpgrp_np().
3259+
* A combined fork/exec which utilizes libast spawnveg(3) for better performance.
3260+
* The spawnveg function will invoke posix_spawn(3) or an equivalent if possible,
3261+
* and will fallback to fork(2) if absolutely necessary. For simple command
3262+
* execution this codepath is preferred because it's always a bit faster than
3263+
* the sh_fork() codepath, even when the underlying system calls it uses wind up
3264+
* being the same.
32713265
*/
32723266
static pid_t sh_ntfork(const Shnode_t *t,char *argv[],int *jobid,int topfd)
32733267
{
@@ -3278,9 +3272,6 @@ static pid_t sh_ntfork(const Shnode_t *t,char *argv[],int *jobid,int topfd)
32783272
char **arge, *path;
32793273
volatile pid_t grp = 0;
32803274
Pathcomp_t *pp;
3281-
#if _use_ntfork_tcpgrp
3282-
volatile int jobwasset=0;
3283-
#endif /* _use_ntfork_tcpgrp */
32843275
sh_pushcontext(buffp,SH_JMPCMD);
32853276
errorpush(&buffp->err,ERROR_SILENT);
32863277
job_lock(); /* errormsg will unlock */
@@ -3332,22 +3323,13 @@ static pid_t sh_ntfork(const Shnode_t *t,char *argv[],int *jobid,int topfd)
33323323
}
33333324
arge = sh_envgen();
33343325
sh.exitval = 0;
3335-
#if _use_ntfork_tcpgrp
3336-
if(job.jobcontrol)
3337-
{
3338-
signal(SIGTTIN,SIG_DFL);
3339-
signal(SIGTTOU,SIG_DFL);
3340-
signal(SIGTSTP,SIG_DFL);
3341-
jobwasset++;
3342-
}
33433326
if(sh_isstate(SH_MONITOR) && job.jobcontrol)
33443327
{
33453328
if(job.curpgid==0)
33463329
grp = 1;
33473330
else
33483331
grp = job.curpgid;
33493332
}
3350-
#endif /* _use_ntfork_tcpgrp */
33513333

33523334
sfsync(NULL);
33533335
sigreset(0); /* set signals to ignore */
@@ -3362,19 +3344,6 @@ static pid_t sh_ntfork(const Shnode_t *t,char *argv[],int *jobid,int topfd)
33623344
job_fork(-2);
33633345
if(spawnpid == -1)
33643346
{
3365-
#if _use_ntfork_tcpgrp
3366-
if(jobwasset)
3367-
{
3368-
signal(SIGTTIN,SIG_IGN);
3369-
signal(SIGTTOU,SIG_IGN);
3370-
if(sh_isstate(SH_INTERACTIVE))
3371-
signal(SIGTSTP,SIG_IGN);
3372-
else
3373-
signal(SIGTSTP,SIG_DFL);
3374-
}
3375-
if(job.jobcontrol)
3376-
tcsetpgrp(job.fd,sh.pid);
3377-
#endif /* _use_ntfork_tcpgrp */
33783347
switch(errno=sh.path_err)
33793348
{
33803349
case ENOENT:
@@ -3397,17 +3366,6 @@ static pid_t sh_ntfork(const Shnode_t *t,char *argv[],int *jobid,int topfd)
33973366
sh_popcontext(buffp);
33983367
if(buffp->olist)
33993368
free_list(buffp->olist);
3400-
#if _use_ntfork_tcpgrp
3401-
if(jobwasset)
3402-
{
3403-
signal(SIGTTIN,SIG_IGN);
3404-
signal(SIGTTOU,SIG_IGN);
3405-
if(sh_isstate(SH_INTERACTIVE))
3406-
signal(SIGTSTP,SIG_IGN);
3407-
else
3408-
signal(SIGTSTP,SIG_DFL);
3409-
}
3410-
#endif /* _use_ntfork_tcpgrp */
34113369
if(sigwasset)
34123370
sigreset(1); /* restore ignored signals */
34133371
if(scope)
@@ -3417,7 +3375,7 @@ static pid_t sh_ntfork(const Shnode_t *t,char *argv[],int *jobid,int topfd)
34173375
if(jmpval==SH_JMPSCRIPT)
34183376
nv_setlist(t->com.comset,NV_EXPORT|NV_IDENT|NV_ASSIGN,0);
34193377
}
3420-
if(t->com.comio && (jmpval || spawnpid<=0) && sh.topfd > topfd)
3378+
if((t->com.comio || spawnpid < 0) && jmpval && sh.topfd > topfd)
34213379
sh_iorestore(topfd,jmpval);
34223380
if(jmpval>SH_JMPCMD)
34233381
siglongjmp(*sh.jmplist,jmpval);

src/cmd/ksh93/tests/io.sh

Lines changed: 56 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
# #
33
# This software is part of the ast package #
44
# Copyright (c) 1982-2012 AT&T Intellectual Property #
5-
# Copyright (c) 2020-2024 Contributors to ksh 93u+m #
5+
# Copyright (c) 2020-2026 Contributors to ksh 93u+m #
66
# and is licensed under the #
77
# Eclipse Public License, Version 2.0 #
88
# #
@@ -696,16 +696,65 @@ err=$(
696696
"(got $(printf %q "$err"))"
697697
698698
# File descriptor leak after 'command not found' with process substitution as argument
699-
err=$(
700-
ulimit -n 25 || exit 0
699+
exp=127
700+
expout=$(
701701
set +x
702702
PATH=/dev/null
703-
for ((i=1; i<10; i++))
704-
do notfound <(:) >(:) 2> /dev/null
703+
LINENO=1
704+
for ((i=1; i<20; i++))
705+
do notfound
705706
done 2>&1
706707
exit 0
707-
) || err_exit "Process substitution leaks file descriptors when used as argument to nonexistent command" \
708-
"(got $(printf %q "$err"))"
708+
)
709+
gotout=$(
710+
ulimit -n 18 || exit 0
711+
set +x
712+
PATH=/dev/null
713+
LINENO=1
714+
for ((i=1; i<20; i++))
715+
do notfound <(:) >(:)
716+
done 2>&1
717+
exit
718+
)
719+
got=$?
720+
((got==exp)) || err_exit "Process substitution leaks file descriptors when used as argument to nonexistent command" \
721+
"(expected exit status $exp, got status $got)"
722+
[[ $expout == "$gotout" ]] || err_exit "Process substitution leaks file descriptors when used as argument to nonexistent command" \
723+
$'Diff follows:\n'"$(diff -u <(print -r -- "$expout") <(print -r -- "$gotout"))"
724+
725+
# Same as above, but also test commands executed with command(1)
726+
gotout=$(
727+
ulimit -n 18 || exit 0
728+
set +x
729+
PATH=/dev/null
730+
LINENO=1
731+
for ((i=1; i<20; i++))
732+
do command notfound <(:) >(:)
733+
done 2>&1
734+
exit
735+
)
736+
got=$?
737+
((got==exp)) || err_exit "Process substitution leaks file descriptors when used as argument to nonexistent command run with command(1)" \
738+
"(expected exit status $exp, got status $got)"
739+
[[ $expout == "$gotout" ]] || err_exit "Process substitution leaks file descriptors when used as argument to nonexistent command run with command(1)" \
740+
$'Diff follows:\n'"$(diff -u <(print -r -- "$expout") <(print -r -- "$gotout"))"
741+
742+
# Now test with command -x
743+
gotout=$(
744+
ulimit -n 18 || exit 0
745+
set +x
746+
PATH=/dev/null
747+
LINENO=1
748+
for ((i=1; i<20; i++))
749+
do command -x notfound <(:) >(:)
750+
done 2>&1
751+
exit
752+
)
753+
got=$?
754+
((got==exp)) || err_exit "Process substitution leaks file descriptors when used as argument to nonexistent command run with 'command -x'" \
755+
"(expected exit status $exp, got status $got)"
756+
[[ $expout == "$gotout" ]] || err_exit "Process substitution leaks file descriptors when used as argument to nonexistent command run with 'command -x'" \
757+
$'Diff follows:\n'"$(diff -u <(print -r -- "$expout") <(print -r -- "$gotout"))"
709758
710759
got=$(command -x cat <(command -x echo foo) 2>&1) || err_exit "process substitution doesn't work with 'command'" \
711760
"(got $(printf %q "$got"))"

src/cmd/ksh93/tests/path.sh

Lines changed: 32 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -751,10 +751,11 @@ PATH=$savePATH
751751
# POSIX: If a command is found but isn't executable, the exit status should be 126.
752752
# The tests are arranged as follows:
753753
# Test *A runs commands with the -c execve(2) optimization.
754-
# Test *B runs commands with spawnveg (i.e., with posix_spawn(3) where available).
755-
# Test *C runs commands with fork(2) in an interactive shell.
754+
# Test *B runs commands in a non-interactive shell.
755+
# Test *C runs commands in an interactive shell.
756756
# Test *D runs commands with 'command -x'.
757757
# Test *E runs commands with 'exec'.
758+
# Test *F forks commands with '&'.
758759
# https://github.com/att/ast/issues/485
759760
rm -rf noexecute
760761
print 'print cannot execute' > noexecute
@@ -782,6 +783,10 @@ PATH=$PWD $SHELL -c 'exec noexecute' > /dev/null 2>&1
782783
got=$?
783784
[[ $exp == $got ]] || err_exit "Test 1E: exit status of exec'd non-executable command wrong" \
784785
"(expected $exp, got $got)"
786+
PATH=$PWD $SHELL -c 'noexecute & wait $!; exit $?' > /dev/null 2>&1
787+
got=$?
788+
[[ $exp == $got ]] || err_exit "Test 1F: exit status of forked job non-executable command wrong" \
789+
"(expected $exp, got $got)"
785790

786791
# Add an empty directory where the command isn't found.
787792
PATH=$PWD:$PWD/emptydir $SHELL -c 'noexecute' > /dev/null 2>&1
@@ -806,6 +811,10 @@ PATH=$PWD:$PWD/emptydir $SHELL -c 'exec noexecute' > /dev/null 2>&1
806811
got=$?
807812
[[ $exp == $got ]] || err_exit "Test 2E: exit status of exec'd non-executable command wrong" \
808813
"(expected $exp, got $got)"
814+
PATH=$PWD:$PWD/emptydir $SHELL -c 'noexecute & wait $!; exit $?' > /dev/null 2>&1
815+
got=$?
816+
[[ $exp == $got ]] || err_exit "Test 2F: exit status of forked job non-executable command wrong" \
817+
"(expected $exp, got $got)"
809818

810819
# If an executable command is found after a non-executable command, skip the non-executable one.
811820
print 'true' > cmddir/noexecute
@@ -840,6 +849,10 @@ PATH=$PWD:$PWD/cmddir $SHELL -c 'exec noexecute'
840849
got=$?
841850
[[ $exp == $got ]] || err_exit "Test 3E: failed to run exec'd executable command after encountering non-executable command" \
842851
"(expected $exp, got $got)"
852+
PATH=$PWD:$PWD/cmddir $SHELL -c 'noexecute & wait $!; exit $?'
853+
got=$?
854+
[[ $exp == $got ]] || err_exit "Test 3F: failed to run forked job executable command after encountering non-executable command" \
855+
"(expected $exp, got $got)"
843856

844857
# Same test as above, but with a directory of the same name in the PATH.
845858
rm "$PWD/noexecute"
@@ -866,10 +879,14 @@ PATH=$PWD:$PWD/cmddir $SHELL -c 'exec noexecute' > /dev/null 2>&1
866879
got=$?
867880
[[ $exp == $got ]] || err_exit "Test 4E: failed to run exec'd executable command after encountering directory with same name in PATH" \
868881
"(expected $exp, got $got)"
882+
PATH=$PWD:$PWD/cmddir $SHELL -c 'noexecute & wait $!; exit $?' > /dev/null 2>&1
883+
got=$?
884+
[[ $exp == $got ]] || err_exit "Test 4F: failed to run executable command as forked job after encountering directory with same name in PATH" \
885+
"(expected $exp, got $got)"
869886
# Don't treat directories as commands.
870887
# https://github.com/att/ast/issues/757
871888
mkdir cat
872-
PATH=".:$PATH" cat < /dev/null || err_exit "Test 4F: directories should not be treated as executables"
889+
PATH=".:$PATH" cat < /dev/null || err_exit "Test 4G: directories should not be treated as executables"
873890

874891
# Test attempts to run directories located in the PATH.
875892
exp=126
@@ -895,6 +912,10 @@ PATH=$PWD $SHELL -c 'exec noexecute' > /dev/null 2>&1
895912
got=$?
896913
[[ $exp == $got ]] || err_exit "Test 5E: exit status of exec'd non-executable command wrong" \
897914
"(expected $exp, got $got)"
915+
PATH=$PWD $SHELL -c 'noexecute & wait $!; exit $?' > /dev/null 2>&1
916+
got=$?
917+
[[ $exp == $got ]] || err_exit "Test 5F: exit status of forked job non-executable command wrong" \
918+
"(expected $exp, got $got)"
898919

899920
# Tests for attempting to run a non-existent command.
900921
exp=127
@@ -920,6 +941,10 @@ PATH=/dev/null $SHELL -c 'exec nonexist' > /dev/null 2>&1
920941
got=$?
921942
[[ $exp == $got ]] || err_exit "Test 6E: exit status of exec'd non-existent command wrong" \
922943
"(expected $exp, got $got)"
944+
PATH=/dev/null $SHELL -c 'nonexist & wait $!; exit $?' > /dev/null 2>&1
945+
got=$?
946+
[[ $exp == $got ]] || err_exit "Test 6F: exit status of forked job non-existent command wrong" \
947+
"(expected $exp, got $got)"
923948

924949
# Tests for attempting to use a command name that's too long.
925950
name_max=$(builtin getconf 2>/dev/null; getconf NAME_MAX . 2>/dev/null || echo 255)
@@ -947,6 +972,10 @@ PATH=$PWD $SHELL -c "exec $long_cmd" > /dev/null 2>&1
947972
got=$?
948973
[[ $exp == $got ]] || err_exit "Test 7E: exit status or error message for exec'd command with long name wrong" \
949974
"(expected $exp, got $got)"
975+
PATH=$PWD $SHELL -c "$long_cmd & wait \$!; exit \$?" > /dev/null 2>&1
976+
got=$?
977+
[[ $exp == $got ]] || err_exit "Test 7F: exit status or error message for forked job command with long name wrong" \
978+
"(expected $exp, got $got)"
950979

951980
# ======
952981

0 commit comments

Comments
 (0)