netatalk.io

Dev Docs AFP Daemon

AFP Daemon (afpd)

1. AFP Daemon Overview

The afpd daemon is the core component of Netatalk that implements the Apple Filing Protocol (AFP) server. It uses a process-per-connection architecture where a single parent process accepts connections and forks a dedicated child worker for each client session.

Key Source Files

Component File
Parent process & event loop etc/afpd/main.c
Child worker event loop etc/afpd/afp_dsi.c
Session forking libatalk/dsi/dsi_getsess.c
AFP command dispatch etc/afpd/switch.c
Open fork management etc/afpd/fork.h, etc/afpd/ofork.c, etc/afpd/fork.c
Child process table libatalk/util/server_child.c, include/atalk/server_child.h
Idle worker thread etc/afpd/idle_worker.c, etc/afpd/idle_worker.h

Caching subsystem (directory cache, AD metadata cache, resource fork cache, IPC cache hint system) is documented in the dedicated Caching Architecture page.

Process Architecture Diagram

%%{ init: { 'themeVariables': { 'fontSize': '14px' }, 'flowchart': { 'nodeSpacing': 15, 'rankSpacing': 40 } } }%%
graph TB
    subgraph "afpd Parent Process"
        A["poll() event loop<br/>(main.c: main())"]:::yellow
        B["Signal handler<br/>afp_goaway()"]:::orange
        C["Child table<br/>server_child_t"]:::cyan
        D["Hint buffer<br/>hint_buf[128]"]:::purple
    end

    subgraph "afpd Child Workers"
        E["Child 1<br/>afp_over_dsi()"]:::green
        F["Child 2<br/>afp_over_dsi()"]:::green
        G["Child N<br/>afp_over_dsi()"]:::green
    end

    subgraph "Each Child Process"
        H["AFP command<br/>dispatch"]:::purple
        I["Idle worker<br/>pthread"]:::green
        J["Dircache<br/>(ARC)"]:::cyan
        K["AD cache<br/>(Tier 1)"]:::cyan
        L["Rfork cache<br/>(Tier 2)"]:::cyan
    end

    Mac1["Mac Client 1"]:::blue
    Mac2["Mac Client 2"]:::blue
    MacN["Mac Client N"]:::blue
    CNID["CNID<br/>Database"]:::salmon

    Mac1 --> E
    Mac2 --> F
    MacN --> G
    A -->|"fork()"| E
    A -->|"fork()"| F
    A -->|"fork()"| G
    A --> C
    A --> D
    D -->|"hint pipe"| E
    D -->|"hint pipe"| F
    D -->|"hint pipe"| G
    E -->|"IPC socketpair"| A
    F -->|"IPC socketpair"| A
    E --- H
    E --- I
    H --> J
    J --> K
    K --> L
    H --> CNID

    classDef blue fill:#74b9ff,stroke:#333,rx:10,ry:10
    classDef purple fill:#a29bfe,stroke:#333,rx:10,ry:10
    classDef green fill:#55efc4,stroke:#333,rx:10,ry:10
    classDef yellow fill:#ffeaa7,stroke:#333,rx:10,ry:10
    classDef grey fill:#dfe6e9,stroke:#333,rx:10,ry:10
    classDef salmon fill:#fab1a0,stroke:#333,rx:10,ry:10
    classDef cyan fill:#81ecec,stroke:#333,rx:10,ry:10
    classDef orange fill:#ff9f43,stroke:#333,rx:10,ry:10

2. Parent Process Architecture

The parent process in main() performs initialization, then enters an event loop that manages listening sockets, child IPC, signals, and cache hint batching.

Global State

Defined as file-scope statics in etc/afpd/main.c:

Variable Type Purpose
dsi_obj AFPObj DSI protocol configuration object
asp_obj AFPObj ASP protocol configuration object (DDP)
server_children server_child_t * Hash table of active child processes
reloadconfig sig_atomic_t Flag set by SIGHUP handler
gotsigchld sig_atomic_t Flag set by SIGCHLD handler
asev struct asev * Poll fd set and metadata

Event Loop

The main event loop in main() uses poll() with a dynamic timeout:

%%{ init: { 'themeVariables': { 'fontSize': '14px' }, 'flowchart': { 'nodeSpacing': 15, 'rankSpacing': 40 } } }%%
flowchart TD
    START["while (1)"]:::yellow
    TIMEOUT{"hint_buf_count()<br/>&gt; 0?"}:::cream
    POLL["poll(asev→fdset,<br/>asev→used,<br/>poll_timeout)"]:::yellow
    SIGCHLD{"gotsigchld?"}:::orange
    CH["child_handler():<br/>waitpid loop,<br/>server_child_remove,<br/>asev_del_fd"]:::orange
    ERR{"ret &lt; 0?"}:::red
    EINTR{"EINTR?"}:::cream
    RELOAD{"reloadconfig?"}:::orange
    DOCFG["Reset sockets,<br/>re-parse config,<br/>re-init listeners,<br/>server_child_kill<br/>(SIGHUP)"]:::orange
    FDEVT{"ret &gt; 0?"}:::cream
    FDLOOP["for each fd<br/>with revents"]:::purple
    LISTEN["LISTEN_FD:<br/>dsi_start() →<br/>fork child,<br/>add IPC_FD"]:::cyan
    IPC["IPC_FD:<br/>ipc_server_read()"]:::purple
    FLUSH{"hint_buf_count &gt; 0<br/>AND (ret==0 OR<br/>count ≥ 128)?"}:::cream
    DOFLUSH["hint_flush_pending<br/>(server_children)"]:::purple

    START --> TIMEOUT
    TIMEOUT -->|"Yes: 50ms"| POLL
    TIMEOUT -->|"No: -1 (block)"| POLL
    POLL --> SIGCHLD
    SIGCHLD -->|"Yes"| CH --> ERR
    SIGCHLD -->|"No"| ERR
    ERR -->|"Yes"| EINTR
    EINTR -->|"Yes"| START
    EINTR -->|"No"| BREAK["break (fatal)"]:::red
    ERR -->|"No"| RELOAD
    RELOAD -->|"Yes"| DOCFG --> START
    RELOAD -->|"No"| FDEVT
    FDEVT -->|"Yes"| FDLOOP
    FDLOOP --> LISTEN
    FDLOOP --> IPC
    LISTEN --> FLUSH
    IPC --> FLUSH
    FDEVT -->|"No (timeout)"| FLUSH
    FLUSH -->|"Yes"| DOFLUSH --> START
    FLUSH -->|"No"| START

    classDef yellow fill:#ffeaa7,stroke:#333,rx:10,ry:10
    classDef cream fill:#f8f4e8,stroke:#333,rx:10,ry:10
    classDef orange fill:#ff9f43,stroke:#333,rx:10,ry:10
    classDef red fill:#ee5a5a,stroke:#333,color:#fff,rx:10,ry:10
    classDef purple fill:#a29bfe,stroke:#333,rx:10,ry:10
    classDef cyan fill:#81ecec,stroke:#333,rx:10,ry:10
    classDef green fill:#55efc4,stroke:#333,rx:10,ry:10

Step ordering is critical (in main() event loop):

  1. SIGCHLD first — removes dead children from the table before hint flush or fd events can reference them
  2. Error handling — uses saveerrno because child_handler() clobbers errno via waitpid/close/asev_del_fd
  3. Config reload — resets listening sockets, re-parses config, broadcasts SIGHUP to children
  4. FD events — dispatches LISTEN_FD (new connections) and IPC_FD (child messages) events
  5. Hint flush — flushes buffered hints when timeout expires (ret == 0) or buffer is full (count ≥ HINT_BUF_SIZE = 128). See Caching Architecture: IPC Hint System for details.

Signal Mask Handling

Signals are unblocked before poll() and blocked immediately after (in the main() event loop):

pthread_sigmask(SIG_UNBLOCK, &sigs, NULL);
ret = poll(asev->fdset, asev->used, poll_timeout);
pthread_sigmask(SIG_BLOCK, &sigs, NULL);

The signal set sigs includes SIGALRM, SIGHUP, SIGUSR1, and SIGCHLD (configured in main() before the event loop).


3. Session Establishment

New connections are handled by dsi_getsession() called from dsi_start() (static function in main.c).

Fork and IPC Setup

%%{ init: { 'themeVariables': { 'fontSize': '14px' }, 'flowchart': { 'nodeSpacing': 15, 'rankSpacing': 40 } } }%%
sequenceDiagram
    participant P as Parent (main.c)
    participant G as dsi_getsession()
    participant C as Child Process

    P->>G: dsi_start() → dsi_getsession()
    Note over G: socketpair(PF_UNIX, SOCK_STREAM)<br/>→ ipc_fds[0], ipc_fds[1]
    Note over G: pipe() → hint_pipe[0], hint_pipe[1]
    Note over G: setnonblock() on all 4 fds
    G->>G: dsi->proto_open(dsi) [fork()]

    rect rgb(255, 238, 170)
        Note over P: Parent path
        P->>P: close(ipc_fds[1])<br/>close(hint_pipe[0])
        P->>P: server_child_add(pid,<br/>ipc_fds[0], hint_pipe[1])
        P->>P: dsi->proto_close(dsi)
        P->>P: asev_add_fd(child→ipc_fd,<br/>IPC_FD)
    end

    rect rgb(85, 239, 196)
        Note over C: Child path
        C->>C: obj→ipc_fd = ipc_fds[1]<br/>obj→hint_fd = hint_pipe[0]
        C->>C: close(ipc_fds[0])<br/>close(hint_pipe[1])<br/>close(serversock)
        C->>C: server_child_free()
        C->>C: dsi_opensession(dsi)
        C->>C: → return to dsi_start()
        C->>C: configfree() → afp_over_dsi()
    end

Key details from dsi_getsession():


4. Child Worker Architecture

Each child process runs afp_over_dsi() which never returns. It initializes the dircache, idle worker thread, and TCP socket options, then enters a command processing loop.

Child Event Loop

%%{ init: { 'themeVariables': { 'fontSize': '14px' }, 'flowchart': { 'nodeSpacing': 15, 'rankSpacing': 40 } } }%%
flowchart TD
    INIT["dircache_init()<br/>idle_worker_init()<br/>TCP options<br/>ipc_child_state(RUNNING)"]:::green
    OUTER["while (1)"]:::green
    JMPCHK{"sigsetjmp<br/>recon_jmp?"}:::cream
    INNER["Inner poll loop"]:::green

    subgraph "Inner Poll Loop (idle wait)"
        ABUF{"[A] DSI buffer<br/>has data?"}:::cream
        BSTATE{"[B] Sleep/<br/>Disconnect/<br/>Die?"}:::cream
        CPOLL["[C] Setup pfds:<br/>dsi→socket +<br/>obj→hint_fd"]:::green
        ISTART["idle_worker_start()"]:::green
        DPOLL["[D] poll(pfds, nfds, -1)"]:::green
        ISTOP["idle_worker_stop()"]:::green
        EHINT["[F] Hint pipe:<br/>process_cache_hints()"]:::purple
        GSOCK{"[G-H] Socket<br/>ready?"}:::cream
    end

    RECV["dsi_stream_receive()"]:::green
    CMD{"DSI command?"}:::cream
    CLOSE["DSIFUNC_CLOSE:<br/>shutdown + exit"]:::red
    TICKLE["DSIFUNC_TICKLE:<br/>clear DSI_DATA"]:::cyan
    AFPCMD["DSIFUNC_CMD:<br/>afp_switch[fn]()"]:::purple
    AFPWRT["DSIFUNC_WRITE:<br/>afp_switch[fn]()"]:::purple
    REPLAY{"Replay cache<br/>hit?"}:::cream
    CACHED["Return cached<br/>result"]:::cyan
    EXEC["Execute AFP<br/>command"]:::purple
    REPLY["dsi_cmdreply()"]:::green
    PEND["pending_request()<br/>fce_pending_events()"]:::purple

    INIT --> OUTER
    OUTER --> JMPCHK
    JMPCHK -->|"Normal"| INNER
    JMPCHK -->|"Reconnect"| INNER
    INNER --> ABUF
    ABUF -->|"Yes"| RECV
    ABUF -->|"No"| BSTATE
    BSTATE -->|"Yes"| RECV
    BSTATE -->|"No"| CPOLL --> ISTART --> DPOLL --> ISTOP
    ISTOP --> EHINT --> GSOCK
    GSOCK -->|"Yes"| RECV
    GSOCK -->|"No (hint only)"| CPOLL
    RECV --> CMD
    CMD --> CLOSE
    CMD --> TICKLE --> PEND
    CMD --> AFPCMD
    CMD --> AFPWRT --> PEND
    AFPCMD --> REPLAY
    REPLAY -->|"Hit"| CACHED --> REPLY --> PEND --> OUTER
    REPLAY -->|"Miss"| EXEC --> REPLY

    classDef green fill:#55efc4,stroke:#333,rx:10,ry:10
    classDef cream fill:#f8f4e8,stroke:#333,rx:10,ry:10
    classDef purple fill:#a29bfe,stroke:#333,rx:10,ry:10
    classDef cyan fill:#81ecec,stroke:#333,rx:10,ry:10
    classDef red fill:#ee5a5a,stroke:#333,color:#fff,rx:10,ry:10
    classDef yellow fill:#ffeaa7,stroke:#333,rx:10,ry:10
    classDef orange fill:#ff9f43,stroke:#333,rx:10,ry:10

AFP Command Dispatch Table

The command dispatch uses a 256-entry function pointer array in switch.c. There are two tables:

The global pointer afp_switch starts pointing to preauth_switch and is switched to postauth_switch after successful authentication. UAMs can register custom handlers via uam_afpserver_action().

Dispatch in afp_over_dsi():

err = (*afp_switch[function])(obj,
                              (char *)dsi->commands, dsi->cmdlen,
                              (char *)&dsi->data, &dsi->datalen);

Replay Cache

A fixed-size replay cache prevents duplicate command execution (defined in afp_dsi.c):

typedef struct {
    uint16_t DSIreqID;
    uint8_t  AFPcommand;
    uint32_t result;
} rc_elem_t;

static rc_elem_t replaycache[REPLAYCACHE_SIZE];

REPLAYCACHE_SIZE = 128 (defined in include/atalk/afp.h). Each entry is indexed by dsi->clientID % REPLAYCACHE_SIZE.

Tickle Handling

The SIGALRM-based alarm_handler() (in afp_dsi.c) fires at the configured tickle interval:


5. Open Fork Management

AFP file access is tracked through the open fork table defined in fork.h and ofork.c.

struct ofork

Defined in fork.h:

struct file_key {
    dev_t       dev;
    ino_t       inode;
};

struct ofork {
    struct file_key     key;           // dev_t dev + ino_t inode
    struct adouble      *of_ad;        // AppleDouble metadata handle
    struct vol          *of_vol;       // volume
    cnid_t              of_did;        // parent directory CNID
    uint16_t            of_refnum;     // AFP fork reference number
    int                 of_flags;      // AFPFORK_* flags
    const unsigned char *of_virtual_data;  // virtual file data pointer
    off_t               of_virtual_len;    // virtual file length
    struct ofork        **prevp, *next;    // hash chain pointers
};

Hash Table

Key Operations

Function File Purpose
of_alloc() ofork.c Allocate fork, hash by dev/ino, assign refnum
of_find() ofork.c Lookup by refnum (array index + refnum verification)
of_findname() ofork.c Lookup by dev/ino from stat() result
of_dealloc() ofork.c Unhash, decrement ad_refcount, free if zero
of_closefork() ofork.c Full close: flush dirty metadata, send IPC cache hints, ad_close

AFPFORK Flags

Defined in fork.h:

Flag Value Purpose
AFPFORK_DATA 1<<0 Open data fork
AFPFORK_RSRC 1<<1 Open resource fork
AFPFORK_META 1<<2 Open metadata
AFPFORK_DIRTY 1<<3 AD metadata modified (set in write_fork() in fork.c when ad_meta_fileno != -1)
AFPFORK_ACCRD 1<<4 Read access
AFPFORK_ACCWR 1<<5 Write access
AFPFORK_MODIFIED 1<<6 Data written (set in write_fork(), triggers FCE and IPC hints)
AFPFORK_ERROR 1<<7 Error opening fork
AFPFORK_VIRTUAL 1<<8 Virtual file fork

Refcounting

The struct adouble has a reference count (ad_refcount). When multiple forks of the same file are open simultaneously, they share the same adouble via ad_ref(). of_dealloc() decrements the refcount and only frees the adouble when it reaches zero.


6. Caching Subsystem

Full documentation: Caching Architecture

Each afpd worker child maintains its own private, per-process three-tier cache hierarchy. There is no shared memory between workers — cross-process coherence is handled by the IPC cache hint relay system.

Tier What Key File
Dircache struct dir entries (files & dirs) with stat fields, ARC eviction dircache.c
AD cache AppleDouble metadata (FinderInfo, dates, attributes) inline in struct dir ad_cache.c
Rfork cache Resource fork content buffers with byte-budget LRU ad_cache.c
IPC hints Cross-process invalidation via parent relay server_ipc.c

The dircache supports both ARC (Adaptive Replacement Cache) and LRU modes. The IPC hint system uses batched, priority-sorted, PIPE_BUF-safe writes via dedicated hint pipes. See the Caching Architecture page for complete details including:


7. Signal Handling

Parent Process Signals

All signals are handled by afp_goaway() in main.c:

Signal Action Details
SIGTERM/SIGQUIT Shutdown server_child_kill(SIGTERM), _exit(0)
SIGUSR1 Disable logins Increments nologin, calls auth_unload(), forwards SIGUSR1 to children
SIGHUP Reload config Sets reloadconfig = 1 (deferred to event loop)
SIGCHLD Child died Sets gotsigchld = 1 (deferred to event loop)
SIGPIPE Ignored SIG_IGN
SIGXFSZ Ignored SIG_IGN (vfat O_LARGEFILE workaround)

Signal masks are cross-configured: each handler’s sa_mask blocks all other handled signals (configured in main() before the event loop) to prevent re-entrant handler execution.

The child_handler() function (called from the event loop, not directly from the signal handler) loops with waitpid(WAIT_ANY, &status, WNOHANG) to reap all dead children, calling server_child_remove() (in server_child.c) and asev_del_fd() for each.

Child Process Signals

Installed by afp_over_dsi_sighandlers() in afp_dsi.c:

Signal Handler Purpose
SIGHUP afp_dsi_reload() Sets reload_request = 1load_volumes()
SIGURG afp_dsi_transfer_session() Primary reconnect: receive new socket fd via IPC, siglongjmp() back to event loop
SIGTERM/SIGQUIT afp_dsi_die() Send AFPATTN_SHUTDOWN, close session, exit
SIGUSR1 afp_dsi_timedown() 5-minute shutdown: send attention message, set 300s timer
SIGUSR2 afp_dsi_getmesg() Server message delivery
SIGINT afp_dsi_debug() Toggle max_debug logging to /tmp/afpd.PID.XXXXXX
SIGALRM alarm_handler() Tickle timer, idle/disconnect timeout management
SIGCHLD child_handler() (child-side) Simple wait(NULL) for any sub-processes

All child signal handlers use sigfillset(&action.sa_mask) with SA_RESTART, ensuring no signal re-entrancy.


8. Idle Worker

The idle worker is a background pthread in each child process, defined in idle_worker.c and declared in idle_worker.h.

Purpose

The idle worker performs deferred cleanup tasks only while the main thread is blocked in poll(), avoiding contention with AFP command processing:

  1. Free invalidated dircache entries — dequeues from invalid_dircache_entries (populated by dir_remove() during AFP commands) and calls dir_free()
  2. Deferred child removal scans — processes one hash chain per iteration via dircache_process_deferred_chain(), cleaning stale dircache entries including ARC ghost entries

Coordination Protocol

%%{ init: { 'themeVariables': { 'fontSize': '14px' }, 'flowchart': { 'nodeSpacing': 15, 'rankSpacing': 40 } } }%%
sequenceDiagram
    participant M as Main Thread
    participant W as Idle Worker Thread

    Note over M: About to enter poll()
    M->>W: idle_worker_start()<br/>atomic_store(is_idle, 1)
    M->>M: poll(pfds, nfds, -1)

    Note over W: Wakes from timedwait<br/>sees is_idle==1
    W->>W: atomic_store(bg_running, 1)
    W->>W: Process work items<br/>(checking is_idle per item)

    Note over M: poll() returns
    M->>W: idle_worker_stop()<br/>atomic_store(is_idle, 0)
    Note over M: Spins until<br/>bg_running==0
    Note over W: Sees is_idle==0<br/>atomic_store(bg_running, 0)
    M->>M: Continue processing<br/>AFP command

Atomic Coordination

Two atomic_int variables provide lock-free coordination:

Variable Purpose
is_idle Set to 1 by main thread before poll(), cleared after poll() returns
bg_running Set to 1 by worker while processing, cleared when done or interrupted

The implementation requires ATOMIC_INT_LOCK_FREE == 2 (verified at compile time). On platforms without lock-free atomics, the idle worker is disabled entirely with stub functions that fall back to synchronous cleanup.

API

Function Safety Purpose
idle_worker_init() Normal Create worker pthread (called once in afp_over_dsi() after dircache_init())
idle_worker_start() Async-signal-safe Signal main thread entering poll() (single atomic store)
idle_worker_stop() NOT signal-safe Reclaim exclusive access (atomic store + spin on bg_running)
idle_worker_stop_signal_safe() Async-signal-safe Signal-safe variant: sets is_idle=0 without spin (caller ends in exit())
idle_worker_shutdown() Normal Clean shutdown: set shutdown_flag, signal condvar, pthread_join()
idle_worker_is_active() Normal Returns worker_started flag
idle_worker_log_stats() Normal Log cycle statistics (started/completed/interrupted)

Worker Thread Details

Integration with AFP Commands

When the idle worker is active (idle_worker_is_active() returns true), the main thread skips synchronous dir_free_invalid_q() after AFP command execution. Instead, the worker handles it during the next idle cycle. If the worker is not active (init failed), dir_free_invalid_q() is called synchronously after each command (see afp_over_dsi() DSIFUNC_CMD handling in afp_dsi.c).


9. Child Process Table

The child process table is managed by server_child.c with types in server_child.h.

server_child_t

typedef struct {
    pthread_mutex_t servch_lock;      // Lock (used with DBUS)
    int             servch_count;     // Current active session count
    int             servch_nsessions; // Maximum allowed sessions
    afp_child_t    *servch_table[CHILD_HASHSIZE]; // Hash table (32 buckets)
} server_child_t;

afp_child_t

typedef struct afp_child {
    pid_t     afpch_pid;       // Worker process PID
    uid_t     afpch_uid;       // Connected user ID
    int       afpch_valid;     // 1 if clientid is set
    int       afpch_killed;    // 1 if SIGTERM already sent
    uint32_t  afpch_boottime;  // Client boot time
    time_t    afpch_logintime; // Time child was added
    uint32_t  afpch_idlen;     // Client ID length
    char     *afpch_clientid;  // Client ID string
    int       afpch_ipc_fd;    // IPC socketpair (parent end)
    int       afpch_hint_fd;   // Hint pipe (parent write end)
    char     *afpch_hostname;  // Server hostname
    int16_t   afpch_state;     // Session state (active/sleeping/disconnected)
    char     *afpch_volumes;   // Mounted volumes string
    struct afp_child **afpch_prevp;
    struct afp_child *afpch_next;
} afp_child_t;

Hash Function

CHILD_HASHSIZE = 32. Hash: ((pid >> 8) ^ pid) & (CHILD_HASHSIZE - 1) (defined as HASH() macro in server_child.c).

Key Operations

Function Purpose
server_child_alloc() Allocate and initialize server_child_t with session limit
server_child_add() Add child with PID, IPC fd, hint fd; enforces session limit
server_child_remove() Unhash child, close IPC + hint fds, free memory, return IPC fd
server_child_free() Free all children (called by child process after fork)
server_child_kill() Send signal to all children
server_child_resolve() Find child by PID
server_child_transfer_session() Primary reconnect: write DSI ID + send_fd() + SIGURG

Footnotes

This is a mirror of the Netatalk GitHub Wiki

Last updated 2026-04-06