5. A biblioteca ROTE

Sumário

5.1. O papel da ROTE no Omnitty

Como foi explicado na seção anterior, a função da biblioteca ROTE é fornecer a funcionalidade de emulação de terminal de maneira modular, isto é, de maneira que possa ser facilmente integrada em qualquer programa (ou pelo menos nos programas escritos em C).

Portanto, a ROTE tem um papel fundamental no Omnitty, já que a principal funcionalidade do Omnitty é permitir que o usuário interaja com o "terminal" de cada máquina: a ROTE é a peça de software que faz todo o trabalho de preparação da parte visual desses terminais, interpretação de seqüências de escape, administração dos descritores de arquivo de pseudo-terminal, entre outros.

5.2. Interface


Figura 5.2. Interface da ROTE.

A Figura 5.2 mostra um diagrama do funcionamento externo da biblioteca ROTE, isto é, a maneira como ela interage com outros programas. O programa que utiliza a biblioteca, chamado programa controlador (Omnitty, no caso contreto), cria um ou mais RoteTerm's. Cada RoteTerm é uma "tela virtual", mantida pela ROTE. Em retrospecto, talvez o nome devesse ser algo como RoteScreen ou RoteConsole, já que a palavra "terminal" já está por demasiado sobrecarregada, e queremos enfatizar aqui o fato de que esses objetos representam o aspecto visual do terminal, não o terminal no sentido UNIX da palavra.

Cada RoteTerm guarda o descritor do pseudo-tty associado a ele, representado na figura como uma caixa laranja denominada "pty master". Quando o programa controlador solicita, a ROTE executa um processo filho com fork e configura suas streams padrão (entrada, saída, erro) para apontarem para o lado slave do pseudo-tty. Os programas-filho portanto rodam no terminal virtual como ilustrado na figura; a ROTE utiliza o lado master desse pseudo-terminal para fazer a emulação de tela e apresentar o resultado ao programa controlador na forma de um RoteTerm.

De maneira mais concreta, o programa controlador interage com a ROTE da seguinte maneira.

Primeiramente, cria um ou mais RoteTerm's. Por exemplo:

RoteTerm *rt = rote_vt_create(80, 25);

Isto cria um RoteTerm de tamanho 80 colunas por 25 linhas. Após criar um RoteTerm, é necessário pôr algo para rodar dentro dele:

rote_vt_fortpty(rt, "nano");

Isto roda o programa nano dentro do terminal. Contudo, a ROTE necessita que os RoteTerm's sejam 'atualizados' de tempos em tempos, ou seja, a ROTE exige que o programa controlador chame uma função, rote_vt_update, periodicamente para que a biblioteca possa efetuar seu processamento. Esse processamento consiste em ler e interpretar todos os dados disponíveis no pseudo-terminal e atualizar a RoteTerm de maneira correspondente. Por isso, o aplicativo principal deve conter esta chamada no seu loop principal, ou em qualquer outro lugar onde ela seja chamada periodicamente:

rote_vt_update(rt);

O programa principal pode acessar a tela virtual do terminal de maneira simples. Por exemplo, eis um código que conta quantas letras 'a' aparecem na tela do nano quando ele inicialmente abre.

int i, j;
int count = 0;

for (i = 0; i < rt->rows; i++)
   for (j = 0; j < rt->cols; j++)
      if (rt->cells[i][j].ch == 'a') count++;

printf("Aparecem %d letras 'a' na tela.\n", count);

Muito embora contar letras "a" na tela, compreensivelmente, não tenha sido um dos grandes anseios da humanidade no decorrer dos séculos, esse trecho de código demonstra como a interface que a biblioteca ROTE fornece difere da interface de terminais puros do UNIX, pois a ROTE focaliza no aspecto visual e comportamental de um console, e não puramente em entrada e saída como é a noção de terminal para o UNIX.

Uma outra operação importante é inserir caracteres, simulando digitações do usuário do terminal. Como anteriormente explicamos, as teclas que o usuário pressiona num terminal físico não são tratadas pelo próprio terminal e sim enviadas ao mainframe para processamento; este, se assim lhe aprouver, através do driver de terminal, ecoará os caracteres digitados de volta ao terminal para que o usuário possa vê-los.

Da mesma maneira, não é a ROTE a responsável por interpretar teclas pressionadas pelo usuário; limita-se ela portanto a enviar os caracteres para o pseudo-terminal, de onde serão recebidos pelo processo escravo que está executando sob o outro lado dele. Esse programa, também por intermédio da configuração do driver de terminal (ou por seus próprios mecanismos) poderá decidir ou não ecoar os caracteres de volta, mas isto excede o escopo de controle da ROTE. Assim, se vierem de volta pelo terminal caracteres ecoando as digitações, a ROTE fielmente as reproduzirá na tela virtual, assim como faria com quaisquer caracteres recebidos, sem nem ao menos ter consciência de que se trata do ecoamento de digitações.

A função que trata da inserção de caracteres no terminal, da maneira como delineada no último parágrafo, é a rote_vt_write, que opera da seguinte maneira:

const char *stuff = "rm -rf /\n";
RoteTerm *rt;

(... inicialização de rt ...)

rote_vt_write(rt, stuff, strlen(stuff));

Isto fará a string rm -rf /, seguida de um caractere de nova-linha, sejam enviados para o pseudo-terminal subjacente. Se o shell bash estiver rodando como processo escravo a esse terminal, ele receberá os caracteres como se os mesmos estivesse sendo digitados pelo usuário. Se ainda por cima estiver rodando como root, esse pequeno programa logrará conseqüências potencialmente catastróficas; para não dizer suicidas também, já que o programa acabará por causar a deleção de si mesmo.

5.3. A Estrutura RoteTerm

A principal estrutura fornecida pela ROTE é o RoteTerm, cuja definição reproduzo abaixo, a menos dos comentários explicativos originais de cada membro da estrutura, que omito porque pretendo explicá-los, nos parágrafos que seguem, em maior detalhe do que seria fornecido pelos referidos comentários.

/* Defines a Rote Virtual Console */
typedef struct RoteTerm_ {
   int rows, cols;
   RoteCell **cells;

   int crow, ccol;
   unsigned char curattr;

   pid_t childpid;
   RoteTermPrivate *pd;

   bool curpos_dirty;
   bool *line_dirty;
} RoteTerm;

5.4. Funcionamento Básico (autômato)

A parte mais onerosa do processamento interno da ROTE é a interpretação da entrada. Os caracteres que vêm do pseudo-terminal são uma mistura com os seguintes componentes:

Caracteres de controle podem ocorrer em meio a seqüências de escape, que podem ocorrem em meio a uma torrente de caracteres literais; algumas seqüências de escape podem conter caracteres literais ou caracteres de controle, que não devem ser interpretados como tal por virtude de fazerem parte de uma seqüência de escape. Para interpretar tamanha complexidade, portanto, a ROTE implementa uma espécie de autômato. De acordo com certos estados, ela efetua alguma ação com os caracteres que vêm chegando da entrada, possivelmente mudando de estado em razão do recebimento de algum deles.

A título de exemplo, eis o código da função "top-level" de interpretação de entrada, que, de acordo com o estado atual do terminal, delega o trabalho para outras funções subsidiárias.

void rote_vt_inject(RoteTerm *rt, const char *data, int len) {
   int i;
   for (i = 0; i < len; i++, data++) {
      if (*data == 0) continue;  /* completely ignore NUL */
      if (*data >= 1 && *data <= 31) {
         /* It's a control char, let's do what it says */
         handle_control_char(rt, *data);
         continue;
      }

      if (rt->pd->escaped && rt->pd->esbuf_len < ESEQ_BUF_SIZE) {
         /* append character to ongoing escape sequence */
         rt->pd->esbuf[rt->pd->esbuf_len] = *data;
         rt->pd->esbuf[++rt->pd->esbuf_len] = 0;

         /* see if we can make any sense of out it so far */
         try_interpret_escape_seq(rt);
      }
      else if (rt->pd->graphmode)
         /* We're in graph mode, interpret char as such */
         put_graphmode_char(rt, *data);
      else
         /* Plain vanilla. Spew it out. */
         put_normal_char(rt, *data);
   }
}

O código para a interpretação dos caracteres de controle é tedioso e consiste num switch/case enorme uma série de possibilidades de caracteres de controle, e para cada um deles a ação apropriada para interpretar-lhe o sentido. Eis aqui um excerto:

      case '\r': rt->ccol = 0; break; /* carriage return */
      case '\n':  /* line feed */
         rt->ccol = 0; cursor_line_down(rt);
         rt->curpos_dirty = true;
         break;
      case '\b': /* backspace */
         if (rt->ccol > 0) rt->ccol--;
         rt->curpos_dirty = true;
         break;
      case '\t': /* tab */
         while (rt->ccol % 8) put_normal_char(rt, ' ');
         break;
      case '\x1B': /* begin escape sequence (aborting previous one if any) */
         new_escape_sequence(rt);
         break;
      case '\x0E': /* enter graphical character mode */
         rt->pd->graphmode = true;
         break;
      case '\x0F': /* exit graphical character mode */
         rt->pd->graphmode = false;
         break;
      case '\x9B': /* CSI character. Equivalent to ESC [ */
         new_escape_sequence(rt);
         rt->pd->esbuf[rt->pd->esbuf_len++] = '[';
         break;
      case '\x18': case '\x1A': /* these interrupt escape sequences */
         cancel_escape_sequence(rt);
         break;

Note como esta função ainda delega tarefas para outras funções, para maior modularidade. Por exemplo, a função cursor_line_down trata de mover o cursor uma linha para baixo e fazer o scrolling do terminal se isso for necessário.

Uma outra coisa importante a notar é que, como em qualquer autômato de interesse prático, há caracteres que o fazem mudar de estado como por exemplo '\x1B', também conhecido como ESC, que faz o autômato entrar em modo de interpretação de seqüência de escape.

5.5. Interpretação de Seqüências de Escape

A interpretação das seqüências de escape é provavelmente a parte mais complexa e trabalhosa da biblioteca. Consiste em tomar seqüências iniciadas por ESC e interpretar-lhes o sentido pretendido. Hieroglíficas, com documentação escassa e muitos restos fossilizados de terminais paleozóicos, essas seqüências são no mínimo traiçoeiras, mesmo escolhendo-se um subconjunto delas consideravelmente menor do que, digamos o xterm ou mesmo o driver de terminal do Linux.

A maior parte das seqüências de escape utilizadas hoje em dia são de um subconjunto muito extenso conhecido como CSI Escape Sequences. Morfologicamente, são aquelas caracterizadas por iniciarem-se com ESC e um abre-colchete, possuirem zero ou mais números em notação decimal, separados por ponto-e-vírgula, e terminarem com uma letra um caractere de arroba, ou um caractere de acento grave (entre outros).

O suporte da ROTE se concentra nas seqüências CSI. Para interpretá-las, primeiro é necessário separar os números decimais que aparecem separados por ponto-e-vírgula e formar uma lista com os mesmos. Esses são chamados os parâmetros. Então é necessário verificar o último caractere para determinar o que a seqüência deve fazer (o último caractere é chamado verbo).

Na ROTE, cada verbo está implementado como uma função (às vezes mais de um verbo está implementado na mesma função por causa da similaridade de efeitos); a função principal para seqüências CSI só compõe a lista de parâmetros e delega o processamento para a função-verbo correspondente, passando-lhe os parâmetros.

void rote_es_interpret_csi(RoteTerm *rt) {
   static int csiparam[MAX_CSI_ES_PARAMS];
   int param_count = 0;
   const char *p = rt->pd->esbuf + 1;
   char verb = rt->pd->esbuf[rt->pd->esbuf_len - 1];

   if (!strncmp(rt->pd->esbuf, "[?", 2)) /* private-mode CSI, ignore */
      return; 

   /* parse numeric parameters */
   while ((*p >= '0' && *p <= '9') || *p == ';') {
      /* (... omitido ...) */
   }

   /* delegate handling depending on command character (verb) */
   switch (verb) {
      case 'm': /* it's a 'set attribute' sequence */
         interpret_csi_SGR(rt, csiparam, param_count); break;
      case 'J': /* it's an 'erase display' sequence */
         interpret_csi_ED(rt, csiparam, param_count); break;
      case 'H': case 'f': /* it's a 'move cursor' sequence */
         interpret_csi_CUP(rt, csiparam, param_count); break;
      case 'A': case 'B': case 'C': case 'D': case 'E': case 'F': case 'G':
      case 'e': case 'a': case 'd': case '`':
         /* it is a 'relative move' */
         interpret_csi_C(rt, verb, csiparam, param_count); break;
      case 'K': /* erase line */
         interpret_csi_EL(rt, csiparam, param_count); break;
      case '@': /* insert characters */
         interpret_csi_ICH(rt, csiparam, param_count); break;
      case 'P': /* delete characters */
         interpret_csi_DCH(rt, csiparam, param_count); break;
      /* (.... demais omitidas ....) */
   }
}

5.6. Atualização de máquinas sem bloquear

Uma das tarefas que a ROTE deve fazer é atualizar um console virtual de acordo com as últimas informações advindas do terminal subjacente. Para isso, precisa receber essas informações. Contudo, se a ROTE procedesse diretamente à leitura do descritor de arquivo do pseudo-tty master, poderia ocorrer algo indesejável: o aplicativo poderia bloquear esperando por entrada.

Num programa que não tivesse outras atribuições senão monitorar um só console, isto provavelmente não seria problemático; contudo a ROTE foi desenvolvida justamente para que o programa cliente possa interagir com vários consoles, possivelmente com várias máquinas diferentes. Por isso, a função rote_vt_update, que faz a atualização, não pode se deixar bloquear se não houver dados para ler e processar, e só deve ler tantos bytes quantos houver disponíveis no momento, sem aguardar pela chegada de bytes adicionais.

Para conseguir este efeito, a ROTE utiliza a famosa chamada de sistema UNIX conhecida como select, que permite averiguar se, num descritor de arquivos, existem dados para serem lidos, e proceder à leitura dos mesmos somente se houver, e na quantidade que houver.

Para propósitos de ilustração, aqui o código da função rote_vt_update:

void rote_vt_update(RoteTerm *rt) {
   fd_set ifs;
   struct timeval tvzero;
   char buf[512];
   int bytesread;
   int n = ROTE_VT_UPDATE_ITERATIONS;

   if (rt->pd->pty < 0) return;  /* nothing to pump */

   while (n--) { /* iterate at most ROVE_VT_UPDATE_ITERATIONS times.
                  * As Phil Endecott pointed out, if we don't restrict this,
                  * a program that floods the terminal with output
                  * could cause this loop to iterate forever, never
                  * being able to catch up. So we'll rely on the client
                  * calling rote_vt_update often, as the documentation
                  * recommends :-) */

      /* check if pty has something to say */
      FD_ZERO(&ifs); FD_SET(rt->pd->pty, &ifs);
      tvzero.tv_sec = 0; tvzero.tv_usec = 0;
   
      if (select(rt->pd->pty + 1, &ifs, NULL, NULL, &tvzero) <= 0)
         return; /* nothing to read, or select() failed */

      /* read what we can. This is guaranteed not to block, since
       * select() told us there was something to read. */
      bytesread = read(rt->pd->pty, buf, 512);
      if (bytesread <= 0) return;   

      /* inject the data into the terminal */
      rote_vt_inject(rt, buf, bytesread);
   }
}

Note como vários cuidados são tomados para que a função nunca bloqueie. Originalmente, somente o select parecia garantir isso, mas uma das várias contribuições recebidas por usuários da biblioteca apontou um bug sutil: mesmo com a precaução de nunca fazer uma chamada de sistema read sem que tivéssemos certeza que havia algo para ler, ignoramos algo contrário: se o processo filho dispára e imprime caracteres no terminal continuamente, era possível que a função rote_vt_update nunca terminasse de executar, pois a cada iteração havia mais dados para serem processados. A versão atual, portanto, limita o número de iterações que o loop pode fazer, deixando o restante da atualização para uma próxima vez que a função for chamada, de maneira a não bloquear o chamador por muito tempo.

5.7. Criação dos processos-filhos

Uma outra funcionalidade digna de algum comentário é a função rote_vt_forkpty, que cuida de criar o pseudo-terminal e iniciar um processo rodando sob ele. Eis o código que lhe corresponde:

pid_t rote_vt_forkpty(RoteTerm *rt, const char *command) {
   struct winsize ws;
   pid_t childpid;
   
   ws.ws_row = rt->rows;
   ws.ws_col = rt->cols;
   ws.ws_xpixel = ws.ws_ypixel = 0;

   childpid = forkpty(&rt->pd->pty, NULL, NULL, &ws);
   if (childpid < 0) return -1;

   if (childpid == 0) {
      /* we are the child, running under the slave side of the pty. */

      /* Cajole application into using linux-console-compatible escape
       * sequences (which is what we are prepared to interpret) */
      setenv("TERM", "linux", 1);

      /* Now we will exec /bin/sh -c command. */
      execl("/bin/sh", "/bin/sh", "-c", command, NULL);

      fprintf(stderr, "\nexecl() failed.\nCommand: '%s'\n", command);
      exit(127);  /* error exec'ing */
   }

   /* if we got here we are the parent process */
   rt->childpid = childpid;
   return childpid;
}

O código chama a função forkpty da libutil, para fazer o trabalho de criar o pseudo-terminal e dar efetivamente o fork, criando o processo filho. O efeito da função é que o processo pai recebe, em rt->pt->pty o descritor de arquivo do lado master do pseudo-terminal, e o processo filho já roda com sua entrada, saída e erro padrão direcionados para o lado slave do pseudo-terminal.

Assim como na chamada fork tradicional, diferenciamos entre o pai e o filho através do valor de retorno da chamada forkpty. Se detectamos que somos o processo filho, ajustamos a variável de ambiente TERM para que seu valor seja linux, pois os programas utilizam esta variável de ambiente para determinar em que tipo de terminal estão rodando, e desta forma produzir seqüências de escape apropriadas para aquele tipo de terminal; uma vez que a biblioteca ROTE foi inteiramente baseada nas seqüências de escape e no comportamento do console modo-texto do Linux, o valor dessa variável deve ser ajustado para linux para induzir os programas-filho a produzirem uma saída compatível. Após ajustar essa variável de ambiente, o processo filho faz a chamada de sistema exec para substituir sua imagem pelo shell /bin/sh, ao qual ele passa argumentos para fazer com que ele execute o comando contido no buffer command recebido como parâmetro pela função.

O processo-pai não tem muito o que fazer nesta função após a criação do processo-filho, bastando que ele registre o PID do processo-filho na estrutura correspondente e saia da função.

É importante ressaltar que o processo-filho nunca sai dessa função uma vez criado, pois no momento em que ele faz a chamada de sistema exec, o programa principal some da memória e é substituído pelo shell (que por sua vez roda o comando solicitado). Já se o exec falha por qualquer razão, o processo filho morre com a chamada exit(127) que lhe sucede após a impressão de uma breve mensagem de erro.

Poderia-se perguntar onde essa mensagem de erro aparece. A resposta é simples: uma vez que o processo-filho está rodando com sua saída de erro padrão apontada para o lado slave do pseudo-terminal, a mensagem de erro aparece como entrada no lado master. A ROTE então, quando tiver a oportunidade (através de uma chamada a rote_vt_update), irá processar a mensagem de erro advinda do processo, desenhando-a no console virtual. Ou seja, a mensagem de erro, contrário ao que poderia parecer, não polui a saída do aplicativo cliente (aquele que está empregando a ROTE), mas aparece, de maneira limpa, emulado pela própria ROTE dentro do console virtual correspondente.

5.8. A ROTE como projeto independente

Além de ser parte integrante do Omnitty (e por isso figurar nesta monografia), a ROTE também é, como anteriormente descrito, um projeto independente que pode ser usado por si só.

Um dos exemplos da utilização da ROTE em outros contextos é o projeto Anyterm (www.anyterm.org), criado por Phil Endecott nos EUA, que consiste num emulador de terminal que funciona a partir do próprio browser, desta maneira permitindo que o usuário logue-se num servidor remoto sem ter de instalar nenhum software, sendo possível usá-lo mesmo em máquinas com sistemas operacionais diversos do UNIX.

Eis aqui um diagrama de como a ROTE se encaixa no AnyTerm. Este diagrama foi extraído do próprio site do AnyTerm. Observe a utilização da ROTE como backend, e também a utilidade das dirty flags que ela fornece, pois a comunicação via web é lenta e a quantidade de dados transmitidos precisa ser minimizada.


Figura 5.5. A ROTE no projeto AnyTerm.
Copyright © 2005 Phil Endecott (www.anyterm.org).