Collaborative Development and Code Review
Reviewing C is a different job from reviewing most languages. The compiler catches less, the runtime catches nothing, and the defects that matter — ownership, lifetime, bounds — are invisible in a diff unless you know to look. This week is a checklist and a workflow.
- Work on a branch, keep it current, and choose between merge and rebase.
- Submit a change that a reviewer can evaluate without reconstructing your reasoning.
- Review C for memory safety, ownership, and undefined behavior systematically.
- Write commit messages and version numbers that mean something.
- File a bug report someone can act on.
1Branching
git switch -c fix-config-leak # branch and switch (modern spelling)
# … work, commit …
git push -u origin fix-config-leakOne branch per change, named after the change. Keep it short-lived: a branch open for three weeks diverges until merging it is its own project.
Merge or rebase
git switch fix-config-leak
git fetch origin
git merge origin/main # preserves history; adds a merge commit
# or
git rebase origin/main # replays your commits; linear history| Merge | Rebase |
|---|---|
| True history, including the messy parts | Linear, readable history |
| Safe on a shared branch | Never on a shared branch — it rewrites commits |
| Conflicts resolved once | Conflicts possibly once per commit |
| Bisection sees the merge | Bisection is cleaner |
The workable rule: rebase your own unpushed work, merge everything else. Rebasing commits that someone else has pulled rewrites history they already have, and the recovery is unpleasant.
Before submitting, tidy your own branch:
git rebase -i origin/main # squash "fix typo" into its parent
git log --oneline origin/main.. # review what you are about to submitNobody needs to see "wip", "fix typo", and "actually fix it". They need to see one coherent commit per idea.
2Submitting a change
A pull request has three parts a reviewer reads in order: the title, the description, and the diff. Most of the review's quality is decided before the diff.
Title: Fix use-after-free when config reload fails
What
parse_config() freed the old configuration before validating the
new one. A malformed file left the global pointer dangling and the
next request dereferenced freed memory.
Why this fix
Validate into a temporary, then swap and free. The alternative —
copying the old config first — doubles peak memory for a case that
should be rare.
Testing
New test reloads with a syntax error and checks the old config is
still served. Full suite passes under ASan and UBSan.
Risk
The swap is not atomic; a concurrent reader could observe either
version. That was already true and is out of scope here (#1240).The Risk section is the one people omit and reviewers value most. Stating what you did not fix, and why, prevents the review spending its attention rediscovering it.
| Do | Do not |
|---|---|
| One logical change per request | Bundle a fix with a refactor |
| Under about 400 lines | Submit 2000 and expect a real review |
| Say how you tested it | Leave the reviewer to guess |
| Answer every comment | Silently push a change instead of replying |
| Push fixes as new commits during review | Force-push mid-review; the reviewer loses their place |
3Reviewing C
Read the diff twice. Once for what it does, once against this list — in order, because the early items cause the expensive failures.
Memory
- Every allocation checked for
NULL? - Every path — including every early return — frees what it allocated?
- Any pointer used after
free? Set toNULLafterwards? reallocassigned to a temporary, not to the original pointer?- Ownership stated: who frees the result, and is it documented?
Bounds
- Every array index provably within range?
- Every buffer write bounded, with the size passed alongside the pointer?
strcpy,strcat,sprintf, orgetsanywhere? Each is a finding.- Room for the terminator — length + 1?
- Loop bounds
<rather than<=?
Integers and undefined behavior
- Signed and unsigned compared or mixed in arithmetic?
- A size computed by multiplication that could overflow?
- An overflow check performed after the operation?
- Shift counts bounded by the type's width?
- Any variable read before it is written?
Errors and concurrency
- Every return value that can fail actually checked?
errnosaved before any intervening call?- Shared state accessed without a lock? Locks taken in a consistent order?
- Any non-reentrant function —
strtok,localtime— in threaded code?
Review the deleted lines too. A diff draws the eye to additions, but removing a bounds check, a free, or a NULL guard is a defect that appears only in the red half. Read what left as carefully as what arrived.
4How to write a review comment
Review the code, never the person. Two formulations of the same finding:
Poor: You forgot to free buf on the error path.
Good: Line 47: if parse() fails we return without freeing buf.
A goto cleanup here would match the pattern used in
load_index() and cover both exits.The second names the location, states the consequence, proposes a fix, and cites a precedent in the same codebase. It is also shorter to act on.
Mark the severity, because not everything is equal:
| Prefix | Means |
|---|---|
| blocking | A defect. Must change before merge. |
| question | I do not understand; explain or clarify. |
| suggestion | Would improve it; your call. |
| nit | Style or typo; ignore if you disagree. |
Without the labels, a reviewer's typo comment and their use-after-free comment look identical, and the author has to guess which blocks the merge.
Two further habits: approve when it is good enough rather than perfect — a review that demands perfection stalls everything — and say what you liked, because a review consisting only of complaints teaches nothing about what to repeat.
5Versions and changelogs
Semantic versioning — MAJOR.MINOR.PATCH — states what a consumer must do:
| Bump | When | Consumer must |
|---|---|---|
| PATCH | A bug fix, no interface change | Nothing |
| MINOR | New functionality, backwards compatible | Nothing |
| MAJOR | A breaking change | Change their code |
For a C library the rule is stricter than for most languages, because compatibility has two levels. API compatibility means existing source still compiles. ABI compatibility means an already-compiled program still links and runs — and that breaks on changes invisible in the header's text, such as adding a member to a struct the caller can see, or reordering enumerators. Week 45 covers the mechanics; here, note only that "I just added a field" can be a major version.
A changelog is written for users, not from git log:
## 2.3.0 — 2026-09-20
### Added
- `config_reload()` for reloading without a restart
### Fixed
- Use-after-free when a config reload failed (#1234)
- Memory leak in the error path of `index_open()` (#1251)
### Changed
- `parse_line()` now rejects trailing whitespace. Previously it was
silently ignored; scripts relying on that will need updating.6Worked example: a review, in full
Here is a patch as submitted. Review it before reading on.
--- a/src/config.c
+++ b/src/config.c
@@ -40,6 +40,38 @@ static Config *global_config = NULL;
+/* Load a configuration file and install it. */
+int config_reload(const char *path)
+{
+ FILE *f = fopen(path, "r");
+ if (!f) return -1;
+
+ char *buf = malloc(8192);
+ fread(buf, 1, 8192, f);
+
+ free(global_config);
+ global_config = malloc(sizeof(Config));
+
+ char *line = strtok(buf, "\n");
+ while (line) {
+ char key[32], value[64];
+ sscanf(line, "%s = %s", key, value);
+
+ if (strcmp(key, "timeout")) {
+ global_config->timeout = atoi(value);
+ } else if (strcmp(key, "name") == 0) {
+ strcpy(global_config->name, value);
+ }
+ line = strtok(NULL, "\n");
+ }
+
+ fclose(f);
+ return 0;
+}The review
blocking — line 45: unchecked allocation. malloc(8192) is not checked, and fread writes to buf immediately. On failure this is a null dereference. Same on line 50 for the Config.
blocking — line 46: buf is not null-terminated. fread returns a count that is ignored, and nothing writes a '\0'. strtok then reads past the end of the allocation. Capture the return value and terminate:
size_t n = fread(buf, 1, 8191, f);
buf[n] = '\0';blocking — line 49: the old config is freed before the new one is valid. This is the defect the change was meant to avoid. If parsing fails, global_config points at freed memory and the next request dereferences it. Build the new config in a local, then swap:
Config *fresh = calloc(1, sizeof *fresh);
/* … parse into fresh, returning on error … */
Config *old = global_config;
global_config = fresh;
free(old);blocking — line 55: strcmp used backwards. if (strcmp(key, "timeout")) is true when the strings differ. Every non-timeout key sets the timeout. Week 14's trap, and the compiler cannot help.
blocking — line 54: unbounded sscanf. %s into char key[32] with no width is a stack buffer overflow from a config file. Use %31s and %63s, and check the return value — a malformed line currently leaves both buffers uninitialized and they are used anyway.
blocking — line 58: strcpy into a fixed buffer. Unbounded copy of attacker-controlled text. snprintf(global_config->name, sizeof global_config->name, "%s", value).
blocking — buf is leaked. Allocated on line 45, never freed on any path.
blocking — f is leaked on the early return at line 43? No — the return precedes the fopen succeeding. But if the malloc check is added as suggested, that new path must close f. This is exactly why week 27 prefers one cleanup label over scattered returns.
question — is this called from more than one thread? strtok keeps hidden static state and global_config is swapped without synchronization. If reloads can be concurrent with requests, both are races. strtok_r at minimum.
suggestion — atoi cannot report failure. A malformed timeout becomes 0 silently. strtol with endptr, as in parse_port() two files over.
nit — the comment says what the function does; the interesting part is the ownership. Worth stating that the caller does not own the result and that the previous config is freed.
The revised version
/* Load a configuration file and install it as the active config.
* On failure the previous configuration remains installed and is
* unchanged. The caller does not own the result.
* Returns 0 on success, -1 on failure with errno set where possible. */
int config_reload(const char *path)
{
int status = -1;
FILE *f = NULL;
char *buf = NULL;
Config *fresh = NULL;
f = fopen(path, "r");
if (f == NULL) {
goto cleanup;
}
buf = malloc(CONFIG_MAX + 1);
if (buf == NULL) {
goto cleanup;
}
size_t n = fread(buf, 1, CONFIG_MAX, f);
if (ferror(f)) {
goto cleanup;
}
buf[n] = '\0'; /* fread does not terminate */
fresh = calloc(1, sizeof *fresh); /* calloc: every field defined */
if (fresh == NULL) {
goto cleanup;
}
char *save = NULL; /* strtok_r: no hidden state */
for (char *line = strtok_r(buf, "\n", &save);
line != NULL;
line = strtok_r(NULL, "\n", &save)) {
char key[32], value[64];
if (sscanf(line, "%31s = %63s", key, value) != 2) {
continue; /* skip malformed lines */
}
if (strcmp(key, "timeout") == 0) {
long v;
if (!parse_long(value, &v) || v < 0) {
goto cleanup; /* refuse rather than default to 0 */
}
fresh->timeout = (int)v;
} else if (strcmp(key, "name") == 0) {
snprintf(fresh->name, sizeof fresh->name, "%s", value);
}
}
/* Only now is the old configuration replaced. */
Config *old = global_config;
global_config = fresh;
fresh = NULL; /* ownership transferred */
free(old);
status = 0;
cleanup:
free(fresh); /* NULL unless we failed */
free(buf);
if (f != NULL) {
fclose(f);
}
return status;
}What changed, and which week it came from
| Fix | From |
|---|---|
| Every allocation checked | Week 19 |
| One cleanup label, no leaks on any path | Week 27 |
| Validate before replacing the old config | Week 20 — use after free |
%31s, snprintf instead of strcpy | Weeks 8, 14 |
strcmp(...) == 0 | Week 14 |
strtol instead of atoi | Week 23 |
strtok_r | Week 41 |
fread return value used; buffer terminated | Week 26 |
Eight blocking findings in twenty-eight lines, and every one is a topic from earlier in this course. That is what makes a checklist worth having: the defects are not exotic, and a reviewer working from memory will miss two or three of them on a Friday afternoon.
Practise it
Find an open pull request in a C project on GitHub and review it against section 3's list. Write the comments out, even if you do not post them, and then read what the actual reviewers said. The gap between your list and theirs is the most direct feedback available on how you read C.
7Common mistakes
| Mistake | What happens | Fix |
|---|---|---|
| Rebasing a shared branch | Rewrites history others have | Rebase only your own unpushed work. |
| A 2000-line pull request | Approved without being read | Split it. |
| Bundling a fix with a refactor | Cannot be reviewed, reverted, or backported | Separate commits, separate requests. |
| Reviewing only added lines | A deleted bounds check slips through | Read the red half too. |
| Unlabelled review comments | Author cannot tell a typo from a defect | blocking / question / suggestion / nit. |
| Reviewing the person | Defensiveness; worse outcomes | Name the line and the consequence. |
| Force-pushing mid-review | Reviewer loses their place | Add commits; squash at the end. |
| Bumping MINOR for an ABI break | Installed programs crash | An ABI break is MAJOR. |
8Check yourself
When is rebasing dangerous?
On any branch someone else has pulled. Rebasing creates new commits with new identities, so collaborators' history no longer matches and their next pull produces duplicates or conflicts. Rebase your own unpushed work to tidy it; merge everything else.
Why review the deleted lines in a diff?
Because removing code is as capable of introducing a defect as adding it. A deleted bounds check, free, or null guard leaves no trace in the added half, and the eye is drawn to additions. Reviewing the red lines is how those are caught.
Why label review comments by severity?
Because a typo and a use-after-free look identical as comments, and the author cannot tell which blocks the merge. Labels also let a reviewer raise minor points without implying they are conditions — which makes it easier to mention them at all.
Why can a C library need a MAJOR bump for adding a struct member?
Because if the struct is visible in a public header, callers compiled against the old definition allocated the old size and indexed the old offsets. A new member changes the layout, so an already-compiled program linking against the new library reads and writes the wrong bytes. That is an ABI break even though the source still compiles.
Which review finding in the worked example would a compiler have caught?
None of the blocking ones. -Wall might warn about the ignored fread result, and -Wformat about nothing here — but the inverted strcmp, the unbounded %s, the premature free, and the leaked buffer all compile silently. That is precisely why C review needs a checklist rather than a reading.
9Where this leads
Week 45 turns the module you have been reviewing into something other people can install: versioning, soname, symbol visibility, pkg-config, and the ABI rules that decide whether adding a struct member is a patch or a major release.