Вызов fgets() с перенаправлением получает аномальный поток данных

Я собирался написать оболочку на языке C. Вот исходный код ниже:

#include <unistd.h>
#include <stdio.h>
#include <string.h>
#include <sys/wait.h>
#include <stdlib.h>

int
getcmd(char *buf, int nbuf)
{
  memset(buf, 0, nbuf);
  fgets(buf, nbuf, stdin);
  printf("pid: %d, ppid: %d\n", getpid(), getppid());
  printf("buf: %s", buf);
  if(buf[0] == 0) {// EOF
    printf("end of getcmd\n");
    return -1;
  }
  return 0;
}

int
main(void)
{
  static char buf[100];
  int fd, r, ret;

  // Read and run input commands.
  while((ret = getcmd(buf, sizeof(buf))) >= 0){
    if(fork() == 0)
      exit(0);
    wait(&r);
  }
  exit(0);
}

Когда я запускаю скомпилированный исполняемый файл с перенаправлением стандартного ввода в файл с именем t.sh, содержимое которого равно «1111\n2222\n», например ./myshell ‹ t.sh, вывод:

pid: 2952, ppid: 2374
buf: 1111
pid: 2952, ppid: 2374
buf: 2222
pid: 2952, ppid: 2374
buf: 2222
pid: 2952, ppid: 2374
buf: end of getcmd

Очевидно, функция getcmd() получает 3 строки(1111, 2222, 2222), тогда как в t.sh всего 2 строки. И эта ситуация становится еще хуже, когда в t.sh помещается больше строк.

И главный процесс — это единственный процесс, выполняющий getcmd, о чем мы можем судить по выводу pid.

Кстати, я обнаружил, что если удалить строку кода wait(&r), вывод может стать нормальным.


person sun    schedule 13.08.2017    source источник
comment
На самом деле очень интригующе. Я могу воспроизвести поведение на 4.10.0-19-generic #21-Ubuntu SMP, четверг, 6 апреля, 17:04:57 UTC 2017 x86_64 x86_64 x86_64 GNU/Linux. При перенаправлении из файла создается впечатление, что указатель файла каждый раз сбрасывается.   -  person Antti Haapala    schedule 13.08.2017
comment
Это могло бы объяснить неиспользованный int fd в коде :)   -  person David C. Rankin    schedule 13.08.2017
comment
Я могу воспроизвести проблему на Ubuntu 16.04 LTS; он не воспроизводится в macOS Sierra 10.12.6. Это чем-то напоминает ошибку в библиотеках Linux, но это довольно странно — как и альтернативный вариант ошибки в компиляторе Linux. Мне пришлось пересмотреть код, чтобы обратить внимание на возвращаемое значение fgets() в Linux; Я компилировал с помощью -Werror, и мне сказали ignoring return value of ‘fgets’, declared with attribute warn_unused_result. (Заголовки macOS не используют эту функцию.) Мне также приходилось иметь дело с различными другими предупреждениями (неиспользуемые переменные, переменные установлены, но не используются и т. д.).   -  person Jonathan Leffler    schedule 13.08.2017


Ответы (1)


wait гарантирует, что дочерний процесс получит время для запуска до того, как родительский закончит работу с файлом. Если я strace файл под Linux, я получаю

% strace -f ./a.out
[lots of stuff]
wait4(-1, strace: Process 29317 attached
 <unfinished ...>
[pid 29317] lseek(0, -2, SEEK_CUR)      = 0
[pid 29317] exit_group(0)               = ?
[pid 29317] +++ exited with 0 +++
<... wait4 resumed> [{WIFEXITED(s) && WEXITSTATUS(s) == 0}], 0, NULL) = 29317
--- SIGCHLD {si_signo=SIGCHLD, si_code=CLD_EXITED, si_pid=29317, si_uid=1000, si_status=0
    _utime=0, si_stime=0} ---
[lots of stuff]

Дочерний процесс перематывает стандартный ввод в качестве одной из первых операций после fork, после чего он немедленно завершает работу. В частности, он перематывает назад столько байтов из потока, сколько было прочитано в него fgets в буфер, но все еще неиспользовано. libc делает это автоматически после форка. Я также видел, как дочерний процесс сбрасывал файл stdout.

Я не знаю, что об этом думать... но ясно, что если вы хотите написать оболочку, вы не должны взаимодействовать со стандартными потоками с помощью <stdio.h> вообще. Если lseek не произошло, то дочерний процесс увидит, что до 4095 байт stdin были пропущены! Вместо этого вы всегда должны использовать только read и write из <unistd.h>. В качестве альтернативы вам может повезти добавить следующий вызов в начало main до того, как что-либо будет прочитано из stdin:

if (setvbuf(stdin, NULL, _IONBF, 0) != 0) {
    perror("setvbuf:");
   exit(1);
}

Это установит поток stdin в небуферизованный режим, поэтому он не должен читать слишком много. Тем не менее, справочная страница Linux для fgets говорит:

Не рекомендуется смешивать вызовы функций ввода из библиотеки stdio с низкоуровневыми вызовами read(2) для файлового дескриптора, связанного с входным потоком; результаты будут неопределенными и, скорее всего, не такими, какие вы хотите.

Кстати, это невозможно воспроизвести, если вместо этого stdin поступает из канала:

% echo -e '1\n2' | ./a.out  
pid: 498, ppid: 21285
buf: 1
pid: 498, ppid: 21285
buf: 2
pid: 498, ppid: 21285
buf: end of getcmd

Но, естественно, это делает видимой другую проблему — ребенок видит, что ввод пропускается.


P.S.

Вы никогда не проверяете возвращаемое значение fgets, поэтому вы не знаете, когда возникает ошибка чтения.

Если во время операции возникает ошибка чтения, содержимое массива становится неопределенным и возвращается нулевой указатель.

person Antti Haapala    schedule 13.08.2017
comment
@ afr0ck Я считаю, что это плохая попытка заставить и родителя, и потомка видеть указатель файла в той позиции, в которой он был бы расположен, если бы не было буферизации. Однако я не могу найти документацию об этом вообще. - person Antti Haapala; 13.08.2017
comment
Это звучит как ошибка для меня. Я не могу придумать оправдания тому, что ищу в ребенке. Это необоснованное вмешательство. И он не воспроизводится в некоторых других системах (в частности, в macOS Sierra 10.12.6). Зато хороший анализ происходящего. Отличная работа! - person Jonathan Leffler; 13.08.2017