Beginner C fgetline() implementation I am doing C coding practice by reinventing a getline function that works with any streams. Any comments on the overall code quality, including correctness, style and performance, are greatly appreciated.
fgetln.h:
#ifndef FGETLN_H
#define FGETLN_H
#include &lt;stdio.h&gt;

char *fgetln(FILE *fp);
/* 
 * Read a line from fp, dynamically allocate memory and store it as a string.
 * The '\n' at the end of the line and any '\0' before are discarded. Return
 * a pointer to the string when successful; return NULL if the line is empty
 * or an error occurs.
 *
 * This function implements the &quot;callee allocates, caller frees&quot; model. You
 * should pass the returned pointer to free() when it is no longer needed.
 */

#endif  // FGETLN_H

fgetln.c:
#include &lt;stdio.h&gt;
#include &lt;stdbool.h&gt;
#include &lt;stdlib.h&gt;
#include &lt;stdint.h&gt;

#define EXPAND_RATIO 2
// Aggressiveness of memory expansion, must be an integer ≥ 2.

char *fgetln(FILE *fp)
{
    signed char c;

    // Look for hints of an empty line and discard any '\0' at the start.
    while (true) {
        c = getc(fp);
        if (c == '\0') continue;
        if (c == '\n' || c == EOF) return NULL;
        break;
    }

    // If we have reached here, the line has content in it.
    ungetc(c, fp);

    char *p_tmp, *line = NULL;
    size_t arr_size = sizeof(char);
    bool end_of_line = false;
    unsigned long long i = 0;

    p_tmp = realloc(line, arr_size);
    if (p_tmp == NULL) goto err_cleanup;
    line = p_tmp;

    do {
        c = getc(fp);
        if (c == '\0') continue;
        if (c == '\n' || c == EOF) {
            end_of_line = true;
            c = '\0';
        }

        if ((i * sizeof(char)) == arr_size) {
            if (arr_size == SIZE_MAX) {
                if (c != '\0') goto err_cleanup;  // Too long
            } else {
                // Assuming SIZE_MAX % sizeof(char) == 0.
                arr_size = arr_size &gt; (SIZE_MAX / EXPAND_RATIO)
                         ? SIZE_MAX
                         : arr_size * EXPAND_RATIO;
                p_tmp = realloc(line, arr_size);
                if (p_tmp == NULL) goto err_cleanup;
                line = p_tmp;
            }
        }

        line[i++] = c;
    } while (!end_of_line);

    // Shorten the string to its logical size if available.
    size_t logic_size = i * sizeof(char);

    if (logic_size &lt; arr_size) {
        p_tmp = realloc(line, logic_size);
        if (p_tmp == NULL) goto err_cleanup;
        line = p_tmp;
    }
    return line;

err_cleanup:
    free(line);
    return NULL;
}

#undef EXPAND_RATIO
Excellent comment in the header.  It's very important to be clear who owns the returned memory and how to release it.  It ought to mention that both newline and \0 are considered as end-of-line characters.
Good realloc() usage - we don't leak when allocation fails.
Some small improvements:

#define EXPAND_RATIO 2


Why do we need a preprocessor macro for this?  I would use a simple static const size_t.

size_t arr_size = sizeof(char);


sizeof (char) is 1 by definition (since sizeof yields the number of char required for its argument).  I wouldn't start with such a small buffer anyway, since almost every line will need several realloc() calls.  Start with something sensible, such as 40 (half the width of a standard terminal).

p_tmp = realloc(line, arr_size);
if (p_tmp == NULL) goto err_cleanup;
line = p_tmp;


Since we know that line is null at this point, we should just use malloc() to be clearer, and eliminate a window where *line is invalid:
char *line = malloc(arr_size);
if (!line) return line;


    if ((i * sizeof(char)) == arr_size) {


We can remove the multiplication by 1 from this:
    if (i == arr_size) {


        if (arr_size == SIZE_MAX) {


Ooh, that's very thorough.  We're unlikely to have managed to allocate that much memory in a single chunk!

            // Assuming SIZE_MAX % sizeof(char) == 0.


Again, any number modulo 1 is zero, so that's a truism, not a mere assumption.

#undef EXPAND_RATIO


Arguably overkill, given that this is the very end of the translation unit anyway.