← Sistemas de Archivos y E/S Bloque 3 · Entrada/salida con llamadas al sistema · Tema 8 de 50

Tema 8

Un cat y un cp propios, con manejo de errores

Ya sabemos abrir, leer, escribir, cerrar y seleccionar posiciones. Ahora reuniremos esas piezas en dos herramientas útiles: un cat que concatena entradas y un cp que copia el contenido de un archivo regular. El trabajo no termina al obtener un descriptor: hay que completar las escrituras, conservar los errores, evitar truncar el propio origen y cerrar las referencias adquiridas. Construiremos un núcleo común, comprobaremos resultados binarios con cmp y provocaremos fallos controlados. Un simulador mostrará qué estado queda en cada fase, incluido el destino parcial de una copia incompleta.

1. Definir qué harán las herramientas

El propósito de este tema no es reproducir todas las opciones de las herramientas del sistema. Vamos a definir interfaces pequeñas que permitan examinar su corrección. Sus nombres serán mi-cat y mi-cp para no confundirlas con las utilidades instaladas.

HerramientaInterfazCompromiso
mi-catmi-cat [ARCHIVO|- ...]Concatena las entradas por stdout. Sin argumentos usa stdin; un guion también representa stdin.
mi-cpmi-cp ORIGEN DESTINOCopia bytes entre archivos regulares. Crea el destino o reemplaza su contenido después de validar las referencias.

Mi-cat no interpreta opciones: un argumento distinto de guion se trata como nombre de archivo. No numera líneas ni transforma caracteres. Mi-cp no copia directorios, no deduce nombres dentro de una carpeta destino y no conserva automáticamente los atributos del origen. La selección de rutas y las condiciones de trabajo deben ser explícitas.

Una copia de contenido correcta entrega cada byte válido una sola vez y en el orden recibido, comprueba el resultado de las operaciones y devuelve un estado de fallo si no puede completar el trabajo. Eso no implica por sí solo conservar metadatos, publicar atómicamente ni garantizar persistencia ante un corte de energía.

Separar datos y diagnósticos

Mi-cat debe enviar por stdout exclusivamente el contenido concatenado. Si escribe «copiando archivo…» allí, ese mensaje se convertirá en parte del archivo al redirigir su salida. Los errores y los contadores de una transferencia fallida irán por stderr.

Los estados de salida serán 0 si se completó lo solicitado, 1 si hubo un error de operación y, en mi-cp, 2 si se usó mal la interfaz. Un resultado parcial puede existir aunque el estado sea 1; el programa no debe ocultar ese hecho devolviendo éxito.

2. Un bloque válido y un sufijo pendiente

El núcleo de ambas herramientas es el mismo: leer un bloque, completar la escritura de sus bytes válidos y volver a leer. No se usa strlen y no se exige que un bloque termine en salto de línea o byte nulo.

algoritmo · dos bucles con responsabilidades distintas
repetir:
    n = read(entrada, buffer, capacidad)
    si n > 0:
        enviados = 0
        mientras enviados < n:
            m = write(salida, buffer + enviados, n - enviados)
            si m > 0: enviados += m
            si EINTR: reintentar esa parte
            si otro fallo: informar y terminar como incompleto
        volver a leer
    si n == 0: terminar la transferencia
    si EINTR: reintentar lectura
    si otro fallo: informar y terminar como incompleto

El bucle exterior administra la entrada; el interior conserva la parte del bloque que aún no aceptó la salida. No se lee un bloque nuevo sobre ese mismo arreglo mientras quede un sufijo pendiente. Pedir una cantidad y recibir menos sigue siendo progreso válido.

Contadores útiles cuando algo falla

Mantendremos dos totales: bytes leídos y bytes escritos. Si una lectura obtiene 100 y la salida acepta 30 antes de fallar, los totales pueden diferir en 70. No significa que el origen haya perdido bytes: significa que una parte obtenida en memoria no llegó al destino.

El núcleo devuelve también qué operación falló. El llamador conserva errno antes de cerrar o generar otros mensajes. Así se puede decir «write sobre destino: no queda espacio» en lugar de atribuir cualquier problema a open o a read.

Política de descriptores bloqueantes

Estas herramientas sencillas no implementan un bucle de eventos. Reintentan EINTR para continuar el trabajo; si aparece EAGAIN o EWOULDBLOCK, comunican transferencia incompleta. No giran consumiendo CPU hasta que el descriptor esté disponible. El modo no bloqueante de las aperturas de mi-cp se usa para inspeccionar el tipo sin quedar esperando una FIFO; luego solo se aceptan archivos regulares.

3. El núcleo compartido en C

Guarda el siguiente archivo como io-comun.h. Contiene la transferencia, los diagnósticos y una función de cierre. Se incluye en los dos programas para que no haya dos versiones del mismo bucle que puedan divergir por una corrección.

Las funciones son static: cada programa obtiene su copia interna al compilar. Para una biblioteca mayor separaríamos una cabecera y un archivo de implementación; aquí esta organización permite compilar cada ejecutable con una sola orden y mantener la lógica común visible.

io-comun.h · transferencia binaria y propiedad de las referencias
#ifndef IO_COMUN_H
#define IO_COMUN_H

#include <sys/stat.h>
#include <unistd.h>
#include <fcntl.h>
#include <errno.h>
#include <inttypes.h>
#include <stdio.h>
#include <string.h>

static void informar_error(const char *operacion, const char *ruta,
                           int codigo)
{
    fprintf(stderr, "%s [%s]: %s\n", operacion, ruta, strerror(codigo));
}

static int cerrar_recurso(int *fd, const char *nombre)
{
    if (*fd == -1)
        return 0;
    int actual = *fd;
    *fd = -1;  /* no volveremos a utilizar este entero como referencia */
    if (close(actual) == -1) {
        int codigo = errno;
        informar_error("close", nombre, codigo);
        return -1;
    }
    return 0;
}

static int copiar_flujo(int entrada, int salida, const char **operacion,
                        uintmax_t *leidos, uintmax_t *escritos)
{
    unsigned char buffer[16384];
    *leidos = 0;
    *escritos = 0;
    for (;;) {
        ssize_t n = read(entrada, buffer, sizeof buffer);
        if (n == -1) {
            if (errno == EINTR)
                continue;
            *operacion = "read";
            return -1;
        }
        if (n == 0)
            return 0;
        *leidos += (uintmax_t)n;
        size_t enviados = 0;
        while (enviados < (size_t)n) {
            ssize_t m = write(salida, buffer + enviados,
                              (size_t)n - enviados);
            if (m > 0) {
                enviados += (size_t)m;
                *escritos += (uintmax_t)m;
                continue;
            }
            if (m == -1 && errno == EINTR)
                continue;
            if (m == 0)
                errno = EIO;  /* no repetir indefinidamente sin progreso */
            *operacion = "write";
            return -1;
        }
    }
}

#endif

El arreglo tiene 16 KiB, pero su capacidad no se confunde con la longitud n de cada lectura. Cuando write comunica m, solo se avanza m. Si la escritura falla, el contador escritos conserva el progreso anterior. La función no hace open ni close: trabaja con referencias que le presta el llamador.

Por qué no cierra el núcleo de transferencia

La misma función puede recibir un archivo abierto por el programa, stdin o stdout. No le corresponde adivinar quién posee cada referencia ni cuánto tiempo debe seguir disponible. El llamador conoce si necesita concatenar más entradas o si ha terminado definitivamente con la salida.

Cerrar_recurso está orientada a Linux: retira el entero de la variable y ejecuta close una vez. No reintenta a ciegas un cierre que informó error, porque el número puede haber quedado disponible para reutilización. Como vimos en el tema 5, las reglas de otras plataformas exigen consultar su contrato.

4. Construir mi-cat

Mi-cat procesa sus argumentos en orden. Si no hay argumentos lee de stdin; un guion en la lista vuelve a usar esa misma entrada, en la posición o estado que conserve. Las rutas normales se abren, se transfieren y se cierran individualmente.

Un archivo de entrada que no puede abrirse no impide intentar los siguientes. Un error de salida sí detiene la concatenación: no tendría sentido consumir más entradas para una salida que ya falló. El estado global sigue siendo 1 si cualquiera de los trabajos falló.

mi-cat.c · contenido por stdout, errores por stderr
#define _POSIX_C_SOURCE 200809L
#define _FILE_OFFSET_BITS 64
#include "io-comun.h"
#include <signal.h>

/* 0: correcto; 1: problema de entrada; 2: problema de salida. */
static int procesar(int entrada, const char *nombre)
{
    struct stat origen, destino;
    if (fstat(entrada, &origen) == -1) {
        int codigo = errno;
        informar_error("fstat entrada", nombre, codigo);
        return 1;
    }
    if (fstat(STDOUT_FILENO, &destino) == -1) {
        int codigo = errno;
        informar_error("fstat salida", "stdout", codigo);
        return 2;
    }
    if (S_ISREG(origen.st_mode) && S_ISREG(destino.st_mode) &&
        origen.st_dev == destino.st_dev && origen.st_ino == destino.st_ino) {
        fprintf(stderr, "entrada y stdout son el mismo archivo: %s\n", nombre);
        return 1;
    }
    const char *operacion = NULL;
    uintmax_t leidos, escritos;
    if (copiar_flujo(entrada, STDOUT_FILENO, &operacion,
                     &leidos, &escritos) == -1) {
        int codigo = errno;
        int salida = strcmp(operacion, "write") == 0;
        informar_error(operacion, salida ? "stdout" : nombre, codigo);
        fprintf(stderr, "transferencia incompleta: leidos=%ju escritos=%ju\n",
                leidos, escritos);
        return salida ? 2 : 1;
    }
    return 0;
}

int main(int argc, char *argv[])
{
    /* Politica explicita: observar EPIPE en vez de morir por SIGPIPE. */
    struct sigaction sa = {0};
    sa.sa_handler = SIG_IGN;
    if (sigemptyset(&sa.sa_mask) == -1 || sigaction(SIGPIPE, &sa, NULL) == -1) {
        perror("configurar SIGPIPE");
        return 1;
    }
    int estado = 0;
    if (argc == 1) {
        if (procesar(STDIN_FILENO, "stdin") != 0)
            estado = 1;
    } else {
        for (int i = 1; i < argc; i++) {
            int propio = strcmp(argv[i], "-") != 0;
            int fd = propio ? open(argv[i], O_RDONLY | O_CLOEXEC)
                            : STDIN_FILENO;
            if (fd == -1) {
                int codigo = errno;
                informar_error("open entrada", argv[i], codigo);
                estado = 1;
                continue;
            }
            int resultado = procesar(fd, propio ? argv[i] : "stdin");
            if (resultado != 0)
                estado = 1;
            if (propio && cerrar_recurso(&fd, argv[i]) == -1)
                estado = 1;
            if (resultado == 2)
                break;
        }
    }
    /* Ya no hay mas contenido que enviar: comprobar tambien el cierre final. */
    int salida = STDOUT_FILENO;
    if (cerrar_recurso(&salida, "stdout") == -1)
        estado = 1;
    return estado;
}

Una política explícita para SIGPIPE

Escribir en una tubería sin lectores puede generar SIGPIPE y terminar el proceso con su acción por defecto. Aquí ignoramos esa señal de forma deliberada para que write pueda comunicar EPIPE y el programa lo informe por stderr. No estamos afirmando que todas las herramientas deban escoger la misma política.

La salida estándar se cierra una sola vez, cuando no queda nada que concatenar. Los descriptores de archivos abiertos por el programa se cierran después de cada entrada. Stdin se conserva entre apariciones del guion; no se vuelve a abrir ni se rebobina automáticamente.

Prevenir un flujo que se alimenta de sí mismo

Si la entrada y stdout son el mismo archivo regular, el programa lo rechaza. Esto evita, por ejemplo, leer y anexar indefinidamente sobre el mismo objeto. Comparamos dispositivo e inodo, no solo las cadenas de las rutas.

Hay un límite importante: en ./mi-cat original > original, el shell puede haber truncado original antes de ejecutar mi-cat. El programa puede detectar la identidad, pero no recuperar los bytes que ya borró la redirección. Comprender quién abrió y modificó cada recurso es parte del diagnóstico.

5. Laboratorio: concatenar texto y bytes

Usa Bash en Linux o WSL y una carpeta nueva. Guarda io-comun.h y mi-cat.c dentro antes de compilar. No se requieren permisos de administrador. Los nombres de los ejecutables no sustituyen a las herramientas del sistema.

terminal · preparar entradas conocidas
$ LAB8=$(mktemp -d "$HOME/tema8.XXXXXX")
$ cd "$LAB8"
$ printf 'uno\n' > primero.txt
$ printf 'dos\n' > segundo.txt
$ printf 'A\0B\n' > binario.dat
$ touch vacio.dat
$ gcc -std=c11 -Wall -Wextra -Wpedantic -O2 mi-cat.c -o mi-cat
$ ./mi-cat primero.txt segundo.txt
uno
dos

La herramienta no agrega separadores ni saltos de línea. En el ejemplo los saltos forman parte de cada archivo. Si una entrada terminara en una letra y la siguiente empezara en otra, ambas aparecerían contiguas.

Intercalar la entrada estándar

terminal · una lista ordenada de fuentes
$ printf 'medio\n' | ./mi-cat primero.txt - segundo.txt
uno
medio
dos
$ ./mi-cat < primero.txt > desde-stdin.txt
$ cmp primero.txt desde-stdin.txt
$ echo $?
0

En la primera orden, el guion no nombra un archivo especial en disco: nuestra interfaz lo interpreta como stdin. En la segunda no hay argumentos y se usa directamente el descriptor heredado. Mi-cat no necesita conocer el nombre elegido por el shell para su salida.

Verificar datos que no se ven como texto

terminal · conservar el NUL y el resto de los bytes
$ ./mi-cat binario.dat vacio.dat > concatenado.dat
$ cmp binario.dat concatenado.dat
$ wc -c < concatenado.dat
4
$ od -An -tx1 concatenado.dat
 41 00 42 0a

El archivo vacío no añade nada, y el byte cero no detiene la transferencia. El rango válido procede de read, no de strlen. Cmp sin mensajes y con estado 0 confirma que los contenidos comparados son iguales.

Un error de entrada no borra los trabajos correctos

terminal · continuar con las entradas siguientes y devolver fallo global
$ LC_ALL=C ./mi-cat primero.txt no-existe.txt segundo.txt > parcial.txt 2>errores-cat.txt
$ echo $?
1
$ cat parcial.txt
uno
dos
$ cat errores-cat.txt
open entrada [no-existe.txt]: No such file or directory

El contenido de las dos entradas existentes se produjo, pero no se cumplió la solicitud completa: una de las entradas faltaba. El estado 1 conserva esa información. No mezcles «hay bytes útiles» con «todo salió bien».

6. Copiar sin destruir el origen

El siguiente intento parece natural, pero es peligroso: abrir origen de lectura y destino con O_TRUNC, para después comprobar si son el mismo archivo. Si ambos nombres llegan al mismo inodo, la comprobación llega tarde: el segundo open ya vació el objeto que pretendías leer.

C · orden incorrecto, no usar como copiador
int entrada = open(origen, O_RDONLY);
int salida = open(destino, O_WRONLY | O_CREAT | O_TRUNC, 0666);
/* Comparar aqui los inodos no deshace el truncado ya realizado. */

No basta con comparar las rutas como texto. datos.txt, ./datos.txt, un enlace duro y un enlace simbólico pueden llegar al mismo objeto. Necesitamos consultar la identidad de las referencias que realmente se obtuvieron.

  1. Abrir y validar el origen. Debe ser un archivo regular; si falla, no se prepara una salida.
  2. Abrir el destino sin O_TRUNC. O_CREAT permite crearlo si falta; el contenido anterior todavía no se vacía.
  3. Validar el destino y comparar identidades. Se usa fstat en los descriptores. Si st_dev y st_ino coinciden, se rechaza la copia.
  4. Truncar la referencia ya validada. Se ejecuta ftruncate sobre el descriptor de destino, no otra apertura por ruta.
  5. Transferir y cerrar. Se completa cada bloque y se conserva cualquier fallo hasta el estado de salida.

Ftruncate a cero cambia el tamaño del objeto abierto; no cambia por sí solo el cursor. En este caso la apertura de destino es nueva y su posición comienza en cero, así que la transferencia escribe desde el principio. La documentación de open(2) explica los efectos de las banderas de apertura que hacen necesario este orden.

Qué protección se obtiene

La identidad se comprueba sobre aperturas que mantienen referencias a los objetos. Un cambio posterior en el texto de la ruta no convierte mágicamente el descriptor en otra cosa. Si se rechazó el mismo inodo antes del truncado, ese objeto no se vacía por nuestra copia.

Esto no resuelve toda modificación concurrente posible. Otro proceso puede editar el origen o el destino y alterar lo que se observa. Nuestro copiador no publica una versión nueva atómicamente ni promete que la ruta de destino conserve el mismo significado durante toda la ejecución. El alcance concreto es evitar la copia destructiva sobre el mismo objeto abierto.

7. Construir mi-cp

Guarda mi-cp.c junto con io-comun.h. El origen y el destino deben ser archivos regulares. Los enlaces simbólicos se siguen durante open; se copia el contenido del objeto al que conducen, no la entrada enlace como tal. Los nombres con espacios funcionan al pasarlos entre comillas desde el shell.

mi-cp.c · comprobar referencias antes de truncar
#define _POSIX_C_SOURCE 200809L
#define _FILE_OFFSET_BITS 64
#include "io-comun.h"

int main(int argc, char *argv[])
{
    if (argc != 3) {
        fprintf(stderr, "uso: %s ORIGEN DESTINO\n", argv[0]);
        return 2;
    }
    int entrada = -1;
    int salida = -1;
    int estado = 1;
    struct stat origen, destino;

    /* O_NONBLOCK evita esperar una FIFO antes de poder rechazar su tipo. */
    entrada = open(argv[1], O_RDONLY | O_NONBLOCK | O_CLOEXEC);
    if (entrada == -1) {
        int codigo = errno;
        informar_error("open origen", argv[1], codigo);
        goto limpiar;
    }
    if (fstat(entrada, &origen) == -1) {
        int codigo = errno;
        informar_error("fstat origen", argv[1], codigo);
        goto limpiar;
    }
    if (!S_ISREG(origen.st_mode)) {
        fprintf(stderr, "el origen no es un archivo regular: %s\n", argv[1]);
        goto limpiar;
    }
    salida = open(argv[2], O_WRONLY | O_CREAT | O_NONBLOCK | O_CLOEXEC, 0666);
    if (salida == -1) {
        int codigo = errno;
        informar_error("open destino", argv[2], codigo);
        goto limpiar;
    }
    if (fstat(salida, &destino) == -1) {
        int codigo = errno;
        informar_error("fstat destino", argv[2], codigo);
        goto limpiar;
    }
    if (!S_ISREG(destino.st_mode)) {
        fprintf(stderr, "el destino no es un archivo regular: %s\n", argv[2]);
        goto limpiar;
    }
    if (origen.st_dev == destino.st_dev && origen.st_ino == destino.st_ino) {
        fprintf(stderr, "origen y destino son el mismo archivo\n");
        goto limpiar;
    }
    if (ftruncate(salida, 0) == -1) {
        int codigo = errno;
        informar_error("ftruncate destino", argv[2], codigo);
        goto limpiar;
    }

    const char *operacion = NULL;
    uintmax_t leidos, escritos;
    if (copiar_flujo(entrada, salida, &operacion, &leidos, &escritos) == -1) {
        int codigo = errno;
        const char *ruta = strcmp(operacion, "read") == 0 ? argv[1] : argv[2];
        informar_error(operacion, ruta, codigo);
        fprintf(stderr, "copia incompleta: leidos=%ju escritos=%ju\n",
                leidos, escritos);
        goto limpiar;
    }
    estado = 0;

limpiar:
    /* Un fallo de cierre convierte tambien una transferencia completa en fallo. */
    if (cerrar_recurso(&salida, argv[2]) == -1)
        estado = 1;
    if (cerrar_recurso(&entrada, argv[1]) == -1)
        estado = 1;
    return estado;
}

Por qué se acepta solo un archivo regular

Una herramienta que copie cualquier objeto debe definir qué hace con directorios, dispositivos, sockets o FIFO. Nuestra interfaz pide archivos regulares y lo comprueba. O_NONBLOCK permite no quedar esperando al abrir una FIFO antes de inspeccionarla; en archivos regulares Linux no lo convierte en un lector de disco asíncrono.

La bandera no garantiza que abrir cualquier dispositivo del mundo sea inocuo o instantáneo. Los laboratorios usarán rutas de archivos y una FIFO creada para la prueba. El rechazo de tipos delimita el copiador; no lo convierte en una herramienta universal de inspección de dispositivos.

Una salida por cada referencia adquirida

Entrada y salida comienzan en -1. Si falla la primera apertura, no se cierra un descriptor que nunca se obtuvo. Si falla la segunda, se libera la entrada. Si falla una lectura o escritura, se intentan cerrar ambas referencias y se mantiene el estado de fallo.

El destino se cierra primero para observar cuanto antes sus posibles errores pendientes. También se comprueba el cierre de la entrada. No se ejecuta close sobre el mismo entero dos veces desde una variable que siguiera aparentando contener una referencia válida.

8. Laboratorio: comprobar las copias

terminal · compilar y copiar contenido binario
$ cd "$LAB8"
$ gcc -std=c11 -Wall -Wextra -Wpedantic -O2 mi-cp.c -o mi-cp
$ ./mi-cp binario.dat copia.dat
$ cmp binario.dat copia.dat
$ echo $?
0
$ stat -c '%s %n' binario.dat copia.dat
4 binario.dat
4 copia.dat

No se imprime un mensaje de éxito por stdout: el estado y las comprobaciones permiten decidir el resultado. Los tamaños iguales son necesarios para estas copias, pero no suficientes para demostrar igualdad; cmp compara los bytes.

Reemplazar un destino más largo

terminal · evitar que sobreviva un sufijo anterior
$ printf 'contenido antiguo bastante largo\n' > destino.txt
$ ./mi-cp primero.txt destino.txt
$ cmp primero.txt destino.txt
$ wc -c < destino.txt
4
$ ./mi-cp vacio.dat destino.txt
$ wc -c < destino.txt
0

La entrada vacía es una copia válida: después de validar identidades, el destino queda a tamaño cero y la primera lectura devuelve EOF. Sin el truncado previo, una copia más corta podría dejar un sufijo del archivo anterior.

Un tamaño que no coincide con el búfer

terminal · varios bloques y una última porción corta
$ head -c 40003 /dev/urandom > datos-grandes.bin
$ ./mi-cp datos-grandes.bin datos-grandes-copia.bin
$ cmp datos-grandes.bin datos-grandes-copia.bin
$ wc -c < datos-grandes-copia.bin
40003
$ sha256sum datos-grandes.bin datos-grandes-copia.bin

Las sumas impresas deben coincidir y variarán entre ejecuciones porque los datos se generan de nuevo. No se necesita memorizar una suma esperada: el origen creado es la referencia de esa práctica. Cmp ofrece además una comprobación directa del par.

Comprobar identidad con nombres distintos

terminal · rechazo antes de vaciar el objeto
$ printf 'conservar este contenido\n' > protegido.txt
$ ln protegido.txt alias-duro.txt
$ ln -s protegido.txt alias-simbolico.txt
$ ./mi-cp protegido.txt ./protegido.txt
origen y destino son el mismo archivo
$ ./mi-cp protegido.txt alias-duro.txt
origen y destino son el mismo archivo
$ ./mi-cp protegido.txt alias-simbolico.txt
origen y destino son el mismo archivo
$ cat protegido.txt
conservar este contenido

Cada rechazo devuelve 1. Las cadenas son distintas, pero las referencias llegan al mismo dispositivo e inodo. Como aún no se ejecutó ftruncate, el contenido se conserva. Comprobarlo con cat y cmp es parte del experimento, no solo confiar en el mensaje.

9. Provocar errores y leer los resultados

Una herramienta de E/S se comprueba también cuando algo falla. No hace falta llenar el volumen ni modificar el sistema para probar rutas inexistentes, tipos no admitidos o una salida que rechaza escrituras.

Un origen inexistente no debe vaciar el destino

terminal · fallar antes de preparar la salida
$ printf 'version anterior\n' > previo.txt
$ LC_ALL=C ./mi-cp no-existe.bin previo.txt
open origen [no-existe.bin]: No such file or directory
$ echo $?
1
$ cat previo.txt
version anterior

El orden de adquisición ayuda: no se obtiene el destino si el origen ni siquiera se pudo abrir. Si el origen existe pero no es regular, el rechazo también ocurre antes de abrir la salida. No confundas esa protección con deshacer automáticamente un fallo posterior al truncado.

Una FIFO como origen se rechaza sin esperar un escritor

terminal · observar el límite de tipos de mi-cp
$ mkfifo canal
$ ./mi-cp canal copia-del-canal.dat
el origen no es un archivo regular: canal
$ test ! -e copia-del-canal.dat
$ echo $?
0

La apertura de lectura no bloqueante de la FIFO permite llegar a fstat y rechazar el tipo. No se entró en el bucle de lectura ni se creó el destino. Mi-cat, en cambio, es un consumidor de flujos y puede esperar entrada de una FIFO según cómo se use.

Probar un fallo de salida con /dev/full

Si existe /dev/full en tu Linux, permite provocar un error de escritura sin llenar el disco. Mi-cat puede enviar su salida a ese descriptor. Mi-cp lo rechaza por no ser un archivo regular, por lo que para este caso usamos la herramienta de flujo.

terminal · abrir una salida no demuestra poder completarla
$ LC_ALL=C ./mi-cat primero.txt >/dev/full
write [stdout]: No space left on device
transferencia incompleta: leidos=4 escritos=0
$ echo $?
1

Los mensajes van por stderr y el texto de la entrada no se aceptó en la salida. La descripción puede variar según la biblioteca; ENOSPC es el significado relevante. No se ha llenado tu sistema de archivos real: es el comportamiento del dispositivo de prueba.

Errores después de un prefijo aceptado

Si el destino acepta varios bloques y luego falla, mi-cp deja un archivo incompleto y devuelve 1. No intenta reconstruir la versión anterior, no borra automáticamente el nombre y no anuncia éxito. El simulador y las pruebas controladas del núcleo permiten observar este caso sin agotar recursos reales.

Borrar por ruta tras un error tampoco es una limpieza trivial: esa ruta podría haber cambiado de significado o estar enlazada con otros nombres. La política de recuperación debe diseñarse. Aquí conservamos el resultado parcial para no ocultar que hubo efectos y para mantener explícito el alcance.

10. Recursos, limpieza y estados de salida

Una tabla de estados ayuda a revisar los caminos del programa. En cada punto hay que distinguir qué referencias son nuestras y qué efectos ya se han producido.

Punto del falloReferencias adquiridasEfecto posible en destinoLimpieza
Open del origen.Ninguna.Ninguno por mi-cp.No cerrar números no obtenidos.
Validación del origen.Entrada.No se abrió la salida.Cerrar entrada.
Open del destino.Entrada, si la segunda apertura falló.Depende del resultado de esa operación; no hay truncado solicitado.Cerrar lo que se adquirió.
Validación tras abrir destino.Entrada y salida.Un nombre nuevo pudo crearse; un archivo existente no fue truncado por nuestra secuencia.Cerrar ambas.
Lectura o escritura tras ftruncate.Entrada y salida.Archivo vacío o parcialmente copiado.Informar progreso, cerrar ambas, devolver 1.
Cierre después de transferir.Las referencias aún no cerradas.Los bytes pueden parecer completos, pero se informó un error.Comunicarlo y conservar estado de fallo.

No reemplazar el error principal por uno de limpieza

El núcleo retorna con errno del fallo de transferencia. El programa lo guarda inmediatamente, emite el diagnóstico y luego limpia. Si close informa otro error, se muestra además; no se usa un errno leído después de todos los cierres para adivinar qué falló originalmente.

Devolver el resultado del último close directamente sería incorrecto si antes falló write y el cierre tuvo éxito. El estado global acumula el fallo y no vuelve a cero por una operación posterior correcta.

Comprobar close también forma parte del resultado

El sistema puede informar ciertos errores de escritura al cerrar. Por eso no basta con alcanzar EOF en el origen. El manual de close(2) describe esos errores y la precaución con los reintentos en Linux. Nuestro copiador devuelve fallo si cualquiera de los cierres que comprueba comunica un problema.

Aun con cierres correctos no prometemos durabilidad ante fallos de energía. Fsync y la persistencia del directorio requieren otro contrato, que veremos en los temas de caché y consistencia. «No se detectaron errores en esta copia» y «se publicó una versión durable de forma atómica» son afirmaciones diferentes.

11. Contenido, metadatos, huecos y concurrencia

Permisos del archivo nuevo y del existente

Mi-cp pide mode 0666 al crear. Sin ACL por defecto, la umask quita los bits correspondientes. No copia automáticamente el modo del origen. Si el destino ya existe, su modo no se recalcula mediante ese argumento; ftruncate y write modifican contenido, no sustituyen por sí solos su propietario o permisos.

terminal · contenido igual, permisos que responden a otra política
$ chmod 600 primero.txt
$ (umask 022; ./mi-cp primero.txt copia-modo.txt)
$ stat -c '%a %n' primero.txt copia-modo.txt
600 primero.txt
644 copia-modo.txt

La salida supone un directorio sin ACL por defecto y un destino que no existía. Conservar permisos, grupo, propietario, fechas, ACL o atributos extendidos exige decisiones y operaciones adicionales. No añadas chmod sin decidir qué bits deben preservarse y en qué momento conviene aplicarlos.

Los huecos pueden materializarse

terminal · misma longitud y contenido, representación potencialmente distinta
$ truncate -s 1M disperso.img
$ printf 'X' | dd of=disperso.img bs=1 seek=1048575 conv=notrunc status=none
$ ./mi-cp disperso.img copia-dispersa.img
$ cmp disperso.img copia-dispersa.img
$ stat -c '%s %b %B %n' disperso.img copia-dispersa.img
$ du -B1 disperso.img copia-dispersa.img

Mi-cp lee los ceros de las regiones dispersas y los escribe explícitamente. En un sistema ordinario sin compresión u otras optimizaciones, el destino puede usar más bloques. Su contenido sigue siendo correcto si cmp confirma la igualdad. GNU cp tiene políticas y opciones adicionales para huecos y metadatos, documentadas en su manual de cp.

Una copia no es una instantánea automática

El programa lee hasta el EOF que observe. Si otro proceso modifica, trunca o extiende el origen durante el recorrido, la salida puede mezclar estados o crecer de manera inesperada. Un archivo que crece continuamente puede prolongar el trabajo. Fstat inicial no congela sus bytes.

El destino existente se modifica en el mismo objeto. Otros enlaces duros a ese destino también ven truncado y escrituras. Otro lector puede observar el intervalo vacío o un prefijo antes de terminar. Para publicar otra versión suelen estudiarse temporales y rename; eso cambia también cómo se tratan identidad y enlaces.

La igualdad de dispositivo e inodo protege el origen frente a nuestra copia sobre sí mismo; no ofrece un protocolo de concurrencia de todos los escritores del equipo. El contrato de esta herramienta se entiende mejor al hacer explícita esa frontera.

12. Cómo verificar sin confiar solo en la pantalla

Un texto corto que «se ve bien» puede esconder errores con NUL, últimas porciones cortas, archivos vacíos o tamaños que coinciden justo con el búfer. Conviene combinar conjuntos de datos y condiciones de fallo.

PruebaQué compruebaEvidencia útil
Archivo vacío.EOF inicial y truncado de destino.Estado 0, tamaño cero y cmp.
Datos con NUL y todos los valores de byte.No depender de cadenas de texto.Comparación binaria, no solo cat.
Tamaños 16383, 16384 y 16385.Bordes del búfer y última lectura corta.Cmp y longitud.
Destino más largo.No conservar sufijo antiguo.Cmp y tamaño final.
Mismo objeto mediante otro nombre.Rechazo antes de truncar.Estado 1 y origen intacto.
Fallo de write después de progreso.Contador y sufijo pendiente correctos.Destino prefijo y fallo comunicado.
Fallo de close.No informar éxito por comprobar solo EOF.Estado global 1.

Los estados de una tubería también se interpretan

En Bash, el estado de una tubería es normalmente el del último comando. Si una herramienta intermedia falla pero la última termina bien, puedes perder esa evidencia al mirar solo $?. Para pruebas, set -o pipefail o el arreglo PIPESTATUS permiten examinar la situación con más detalle.

Comprueba $? inmediatamente después de la operación que te interesa. Un cat para mirar el archivo, un echo o un cmp ya sustituyen ese estado. En los ejemplos, el echo aparece donde debe observarse el resultado de la orden anterior.

Forzar retornos parciales sin llenar el disco

El tema 6 mostró parciales reales en una tubería. Para verificar el núcleo de copia también se pueden usar funciones de prueba que envuelvan read y write y limiten cada retorno, o inyecten EINTR y un error tras cierto progreso. Deben respetar el contrato que están simulando: no es válido hacer que write «acepte» más bytes de los pedidos.

Una prueba útil revisa el destino y los contadores, no solo que el programa no se caiga. El simulador siguiente representa el ciclo completo y deja visibles esos efectos. Las utilidades de compilación con advertencias ayudan a encontrar tipos y usos incorrectos, pero no demuestran por sí solas que la secuencia de referencias y efectos sea correcta.

13. Simulador del ciclo completo de una copia

Este modelo representa mi-cp con origen ABCDEFGHIJ y un destino independiente que puede existir o faltar. También puedes hacer que los nombres lleguen al mismo objeto. La secuencia abre sin truncar, obtiene identidades, decide si continuar, vacía, transfiere y cierra.

Verificar antes de modificar y limpiar después de fallar

Objetos, referencias y progreso
PasoOperaciónResultadoEfecto

La relación y el estado inicial se toman al reiniciar. El límite de write puede cambiar entre pasos. El modelo usa peticiones de lectura de cuatro bytes y completa cada bloque antes del siguiente. Un evento de fallo ocurre sin progreso de esa llamada; las operaciones anteriores conservan sus efectos. Un error de cierre retira la referencia y mantiene fallo, siguiendo la política del modelo Linux.

Se omiten permisos, cambios concurrentes, banderas detalladas, búferes de biblioteca y persistencia. Las identidades de enlaces se simplifican al objeto regular alcanzado. No se garantiza que cualquier fallo de un sistema real deje exactamente el mismo estado; aquí las inyecciones se definen para examinar la secuencia y la limpieza.

Secuencias para comprobar

  1. Con objetos distintos, avanza hasta comparar identidades. El destino todavía conserva VERSION-ANTERIOR. Solo el paso de ftruncate lo deja vacío.
  2. Reinicia con un enlace duro al origen. Se rechaza la copia antes de ftruncate; el origen y el destino compartido siguen mostrando ABCDEFGHIJ.
  3. Provoca un fallo al abrir origen. No se obtiene ninguna referencia y el destino existente no cambia.
  4. Con destino nuevo, falla fstat del destino después de abrirlo. Puede quedar el archivo nuevo vacío, aunque no se haya iniciado la transferencia.
  5. Escribe un prefijo y provoca un fallo de write. La copia pasa a limpieza, el destino conserva ese prefijo y el estado final es 1.
  6. Completa todos los bytes y falla close del destino. La comparación de contenido podría ser correcta, pero el programa debe devolver fallo.

14. Resumen

15. Preguntas de repaso y práctica

Antes de desplegar las respuestas, identifica qué referencias se adquirieron y qué operaciones ya pudieron modificar los datos.

1. ¿Por qué hay dos bucles en copiar_flujo?

El exterior obtiene cada bloque de entrada; el interior completa su salida aunque write acepte solo una parte. No se reutiliza el búfer mientras queden bytes pendientes.

2. Read devuelve 100 y write acepta 30 antes de fallar. ¿Qué contadores se esperan?

Leídos 100 y escritos 30 para ese caso inicial. Los 70 restantes estuvieron en memoria pero no se aceptaron en destino. El fallo no elimina los 30 ya transferidos.

3. ¿Por qué mi-cat no usa strlen sobre el búfer?

Porque el contenido puede incluir NUL y no tiene por qué estar terminado como una cadena de C. La longitud válida procede del retorno de read.

4. ¿Qué significa un guion en los argumentos de mi-cat?

La interfaz lo interpreta como stdin. No lo abre como ruta ni lo rebobina; utiliza el estado del descriptor heredado cuando llega a ese punto de la lista.

5. Si una entrada falta, pero las otras dos se concatenaron, ¿el resultado global debe ser 0?

No en nuestra interfaz. Hay bytes útiles, pero una parte de lo solicitado falló. El estado global es 1 y los diagnósticos se mantienen separados del contenido.

6. ¿Por qué un error de salida detiene mi-cat en vez de seguir leyendo archivos?

Porque no puede completar su salida y consumir entradas adicionales no resuelve el fallo. Se cierran las referencias adquiridas y se devuelve error.

7. ¿Qué problema resuelve ignorar SIGPIPE en este programa?

Permite observar el error EPIPE de write y emitir un diagnóstico, en vez de terminar por la acción por defecto de la señal. Es una política explícita de la herramienta.

8. ¿Por qué mi-cp no abre inicialmente el destino con O_TRUNC?

Porque todavía no comprobó si el destino y el origen son el mismo objeto. Truncar primero podría destruir el contenido que se quiere copiar antes de detectar el problema.

9. ¿Es suficiente comparar las cadenas de origen y destino?

No. Rutas equivalentes, enlaces duros y enlaces simbólicos pueden llegar al mismo objeto con textos diferentes. Se compara dispositivo e inodo mediante fstat de las referencias abiertas.

10. ¿Para qué se usa ftruncate después de validar?

Para quitar el contenido anterior del destino ya comprobado, evitando conservar un sufijo si el origen es más corto. Se opera sobre la referencia obtenida, sin volver a abrir el nombre.

11. ¿Qué aporta O_NONBLOCK al rechazo de FIFO en mi-cp?

Evita esperar una contraparte de la FIFO antes de inspeccionar su tipo y rechazarlo. En archivos regulares no convierte el bucle en E/S asíncrona de disco.

12. ¿Una copia de un origen vacío debe dejar intacto el destino anterior?

No en este contrato. Después de las validaciones se trunca el destino y EOF completa una copia de longitud cero. Dejar el contenido antiguo no copiaría el origen.

13. ¿Puede quedar un destino nuevo aunque falle una validación posterior a su open?

Sí. O_CREAT pudo crear la entrada. El fracaso posterior no revierte automáticamente esa creación; la herramienta cierra referencias y devuelve fallo.

14. ¿Por qué no se devuelve simplemente el resultado del último close?

Porque una limpieza correcta no elimina un error de transferencia previo. El estado global conserva cualquier fallo y no vuelve a cero por el éxito de una operación posterior.

15. ¿Mi-cp conserva los permisos y las fechas del origen?

No automáticamente. Crea con mode 0666 sujeto a las reglas de creación y conserva el modo del destino existente. Copiar atributos requiere otro contrato y otras operaciones.

16. ¿Una copia con más bloques asignados puede tener contenido correcto?

Sí. Una copia secuencial puede materializar ceros de huecos. Cmp puede confirmar igualdad aunque du muestre asignación diferente.

17. ¿Puede mi-cat impedir la pérdida de datos de mi-cat archivo > archivo?

No puede deshacer un truncado que el shell realizó antes de ejecutarlo. Puede rechazar la identidad al empezar, pero no recuperar automáticamente el contenido ya perdido.

18. Si todos los bytes coinciden, pero close informa un error, ¿la copia debe anunciar éxito?

No según el contrato de esta herramienta. El error de cierre se comunica y el estado final es 1. La observación de contenido no invalida el fallo ni garantiza durabilidad.

Para practicar

  1. Compila los dos programas con todas las advertencias indicadas. Comprueba que ambos utilizan exactamente io-comun.h y explica qué responsabilidad sigue teniendo cada main.
  2. Concatena tres archivos sin saltos de línea y predice la secuencia exacta. Incluye un archivo vacío y otro con NUL. Verifica mediante cmp y od.
  3. Usa mi-cat con una entrada inexistente entre dos válidas. Guarda stdout y stderr en archivos distintos y comprueba el estado inmediatamente después de la ejecución.
  4. Genera archivos de longitudes 0, 1, 16383, 16384, 16385 y 40003 bytes. Copia cada uno con mi-cp y comprueba su igualdad binaria y tamaño.
  5. Prepara un destino mayor que el origen y verifica que no quede su sufijo. Después copia un archivo vacío sobre ese mismo destino.
  6. Crea un enlace duro y uno simbólico al origen. Intenta copiar sobre ambos y comprueba que el origen mantiene sus bytes después de cada rechazo.
  7. Con un origen inexistente, intenta copiar sobre un archivo que tenga contenido importante para la práctica. Comprueba que mi-cp no lo vació.
  8. Crea una FIFO y úsala como origen de mi-cp sin ningún escritor. Verifica que se rechaza sin quedarse esperando y que no aparece el destino nuevo.
  9. Compara los permisos de un origen 0600 y un destino nuevo creado con umask 0022. Luego usa un destino existente con otro modo. Explica cuál conserva o calcula cada operación.
  10. Copia el archivo disperso del tema 7 con mi-cp. Compara contenido, longitud y bloques asignados. No concluyas pérdida de datos solo porque creció la asignación.
  11. En el simulador provoca fallos antes de abrir destino, después de crearlo, durante write y al cerrar. Para cada caso anota nombres existentes, contenido y referencias pendientes.
  12. Diseña una mejora que escriba primero en un temporal del mismo directorio y publique después. Enumera qué decisiones adicionales requiere sobre permisos, enlaces, errores, rename y persistencia; no supongas que cambiar una línea resuelve todos esos requisitos.

En el tema 9 implementaremos redirecciones con dup y dup2. Los programas de este tema ya utilizan stdin y stdout como referencias heredadas; ahora veremos cómo un proceso prepara esas conexiones para ejecutar otra herramienta sin cambiar su lógica de transferencia.