# FILE*: буферизация — анализ solid-c и варианты (2026-07-06)

Статус: **вариант B+ РЕАЛИЗОВАН 2026-07-06** (решение пользователя).
Единый ленивый буфер BUFSIZ=512 на чтение/запись с автопереключением
направления (_F_DIROUT), статическая таблица OPEN_MAX=8 слотов
(без malloc для FILE), _fclosall через atexit, ungetc через hold,
fprintf/vfprintf через vsprintf+fwrite, fflush(NULL) = все потоки.
Внутренности: libc/file/_file.h (+_file_sync/_file_buf/_file_slots/
_fclosall).  Фактическая цена: filetest 7411→9929 Б _CODE (доля
только-читающих потребителей ~+1.3 КБ, включая malloc); программы
без FILE* не платят.  Верификация: MAME filetest + fdmax + fbench.
Ниже — исходный анализ, на основании которого принималось решение.

## Как сделано в solid-c (SRC/CLIB/STDIO.ASM)

Структура FILE — 14 байт, статический массив `_iob[8]` (без malloc
для самих FILE; псевдопотоки stdout/stderr/stdaux/stdprn лежат ПЕРЕД
массивом и адресуются отрицательными индексами — трюк, нам не нужен):

    flags(2), level(2), curp(2), fd(2), buffer(2), hold(1), token(2), dummy(1)

Механика:

- **Буфер 512 Б, ленивый malloc** при первом буферизуемом fgetc/fputc
  (флаг `_F_BUF` = «буфер наш, free при fclose»).  Программа без
  файлового I/O не платит ничего.
- **Чтение** (`_fgetc`): `level == 0` → `read(fd, buffer, 512)`,
  `curp = buffer`; отдача — `*curp++`, `level--`; ставится `_F_IN`.
- **Запись** (`_fputc`): `*curp++ = c`, `level++`; при `level == 512` —
  fflush (один `write` всего буфера); ставится `_F_OUT`.
- **Полудуплекс**: fputc при взведённом `_F_IN` — ОШИБКА (не
  авто-flush); направление сбрасывает только fflush.
- **fflush входного потока**: `lseek(fd, -level, SEEK_CUR)` — откат
  непрочитанного readahead, буфер инвалидируется.  Выходного —
  `write(buffer, level)`.
- **fseek/ftell** = fflush + голый lseek/ltell по fd (после flush
  позиция fd совпадает с логической позицией потока).
- **ungetc**: буфер не пуст → `*--curp = c`; пуст/отсутствует → символ
  в поле `hold`, `curp` указывает на hold.
- **fclosall через atexit** — сброс буферов при exit.
- Консольные потоки минуют буфер (RST-вызовы напрямую).
- **fread/fwrite — ПОБАЙТОВЫЙ цикл** через _fgetc/_fputc: большие блоки
  платят call+IY-доступ за каждый байт.  Это слабое место порта.

## Варианты для нас

**A. Полный порт solid-c** (буфер на чтение и запись).
Плюсы: ускоряются и писатели через fputc/fprintf.  Минусы: полудуплекс
(«запись после чтения без fflush — ошибка») — источник тонких багов;
обязателен flush в exit (сцепка atexit+file); больше кода во всех
модулях; наши блочные fread/fwrite пришлось бы защищать от деградации.

**B. Буферизовать ТОЛЬКО чтение (рекомендую).**
FILE += `buf(2), level(2), curp(2), hold(2)`; буфер 512 Б лениво.

- fgetc: hold → буфер → refill.  fgets остаётся циклом по fgetc
  (теперь дешёвым).
- fread: сначала хвост буфера (memcpy), остаток ≥ 512 — прямой read()
  в ptr одним syscall (обходя буфер), мелкий остаток — refill.
- Запись НЕ буферизуется — как сейчас: fputc = write(1 байт),
  fputs/fwrite = один write() на блок.  Нечего терять при аварии,
  fflush остаётся no-op по данным, полудуплекса нет.
- Согласование позиций: перед write/fseek/ftell на потоке с readahead —
  `lseek(fd, -(level), SEEK_CUR)` + инвалидация буфера (один общий
  хелпер `_file_sync`).  ftell = lseek(0,CUR) − level (без syscall не
  выйдет — lseek и так syscall).
- ungetc: через hold, работает и до первого заполнения буфера.

Плюсы: решает главную боль (парсеры), запись остаётся простой и
надёжной, никакого flush-on-exit, r+ работает через _file_sync.
Минусы: fputc-писатели остаются медленными (редкий паттерн — fputs/
fwrite блочные и так быстрые).

**B+. Единый буфер на чтение И запись с АВТОпереключением направления
(предложение 2026-07-06, кандидат в целевой дизайн).**
Схема solid-c, но без ловушки: направление переключает сама библиотека.

- флаг направления в FILE: буфер сейчас «readahead» или «накопитель
  записи»;
- fputc при направлении «чтение»: `_file_sync` (отмотка fd на -level,
  буфер пуст) → режим записи → накопление; сброс write() при
  заполнении;
- fgetc при направлении «запись»: flush (write(buf, level)) → режим
  чтения → refill;
- fwrite больших блоков: flush + прямой write мимо буфера; мелких —
  memcpy в буфер.  fread симметрично;
- fseek/ftell/fclose: flush-или-sync по направлению; ftell = позиция
  fd − level (чтение) / + level (запись);
- **обязателен реестр открытых потоков**: поле next в FILE
  (регистрация в fopen, снятие в fclose) + _fclosall через atexit —
  стандарт требует flush всех потоков в exit(); без этого
  `fputs(...); exit(1);` теряет данные;
- цена-семантика: ошибки записи становятся ОТЛОЖЕННЫМИ (вылезают при
  flush/fclose, не в момент fputc) — проверять результат fclose;
- цена-код: ~+350–500 Б против ~+200–300 у B (тянется только
  использующими FILE*).

**C. Оставить небуферизованным** («большие файлы читаются целиком в
EMM», паттерн mdview).  Для приложений-парсеров среднего размера
неудобно; отвергается самим существованием П2-пункта.

**D. Полная стандартная буферизация + setvbuf** — отвергнуто ранее
решением file_star_design (минимальный FILE*).

## Что взять у solid-c при варианте B

- ленивый malloc 512 Б + флаг «буфер наш»;
- откат readahead lseek'ом (механика их fflush-на-вход) — как
  `_file_sync` перед write/fseek/ftell;
- ungetc с hold-байтом;
- консольные потоки мимо буфера (у нас уже так).

Чего НЕ брать: побайтовые fread/fwrite, полудуплекс, статический
`_iob[]` с отрицательными индексами, буферизацию записи.

## Лимит открытых файлов и статическая таблица FILE (2026-07-06)

Факты: solid-c — OPEN_MAX = 8, статический массив из 8 FILE-структур
(+5 псевдопотоков перед ним), fdopen отвергает fd > 8.  DSS-доки:
FCB строятся «в рабочих областях ДОС», код ошибки 06h = «Too many
open files» (наш EMFILE = 6 совпадает).

**ПОДТВЕРЖДЕНО тестом fdmax (MAME, DSS 1.71.57, 2026-07-06)**:
пользователю доступно 8 манипуляторов, fd 2..9 (fd 1 держит шелл DSS
под запущенный .exe).  КРИТИЧНО: 9-й OPEN не возвращает 06h — он
ВЕШАЕТ систему.  Поэтому в libc/io добавлен предохранитель _fd_guard
(счётчик в open/close, отказ EMFILE на 9-м open без захода в DSS) —
таблица fopen и guard вместе закрывают и высокий, и низкий уровень.

Следствие для B+: вместо malloc-FILE + связного списка-реестра —
**статическая таблица из 8 слотов** (свободный слот: flags == 0):
- реестр для flush-on-exit бесплатен: _fclosall = цикл по таблице;
- fopen без malloc — единственный отказ синхронен с отказом DSS
  (EMFILE), утечка «fclose без free» невозможна;
- цена: ~112–128 Б BSS (в exe не входит), только у программ с fopen;
- буферы НЕ статические — остаются ленивыми malloc 512 Б (потолок
  8×512 = 4 КБ heap в худшем случае);
- объявить FOPEN_MAX 8 в stdio.h; если fdmax покажет лимит DSS < 8 —
  уменьшить таблицу.

## Семантика инвалидации (вариант B) — контрольный сценарий

Буфер — только кэш опережающего чтения.  Правило: **любая запись и
любой fseek обнуляют буфер; запись всегда идёт напрямую в файл после
отмотки позиции** (`_file_sync`: `lseek(fd, -level, SEEK_CUR)` +
`level = 0`).  Буфер при записи НЕ патчится — write-through с правкой
окна отвергнут как сложный ради редкого паттерна.

Сценарий «r+, чтение-запись-чтение» (обсуждено 2026-07-06):
read 512 в буфер → 10×fgetc (логическая поз. 10, fd на 512) →
первый fputc: sync отматывает fd на 10, буфер пуст, 10×write ложатся
на 10..19 → fseek(0) → fgetc перечитывает буфер С ДИСКА и видит
записанные байты.  Протечка старой копии невозможна — она уничтожена
в момент первой записи.

## Оценка/проверка

Бенчмарк до/после: цикл fgets по tests/seek/big.txt с замером ptime
(тест tests/fbench), плюс filetest-регресс в MAME.  Ожидание: чтение
~512× меньше syscall'ов; код file-модулей +200–300 Б (тянется только
использующими FILE*).
