3 Commits

Author SHA1 Message Date
MechaCat02
164c7d2aa3 test(upload): prove the stored bytes are the guest's bytes, not just that a 201 came back
Some checks failed
Checks / Backend — cargo test + clippy + fmt (push) Failing after 1m25s
Checks / Frontend — vitest + svelte-check (push) Failing after 5m41s
Checks / Keepsake viewer — builds, self-contained, committed artifact in sync (push) Failing after 5m1s
Checks / E2E — typecheck + lint (push) Failing after 52s
E2E / Playwright E2E (chromium + webkit) (push) Failing after 8m35s
E2E / Cross-UA smoke matrix (push) Failing after 4m17s
Audit / cargo audit (backend) (push) Failing after 9m0s
Audit / npm audit (frontend) (push) Successful in 1m28s
v0.18.3 through v0.18.6 were one failure in four disguises: the browser handing
the queue something that was not the file. An empty body first (WebKit stores a
picked `File` as a reference to an OS file iOS then deletes), and once the bytes
were copied, the risk of a SHORT one — that copy is a multi-second chunked loop
for a video, and the same purge can land partway through it.

Every check that missed them shared one shortcut: asserting the upload was
ACCEPTED. A truncated file is accepted. It passes the magic-byte sniff, the size
cap and the decode budget, is stored, gets a preview, appears in the gallery —
and is still not the guest's video. Acceptance was never the question.

So this drives the real composer through the real file chooser, then fetches the
stored original back and compares its SHA-256 to the file on disk. It also names
the two copy paths, because they are different code and only one of them can
produce a short blob: at or below 4 MB a single `arrayBuffer()`, above it the
chunked loop. A run with no file over 4 MB says so rather than reporting a pass
it did not earn.

Replaces the two throwaway scripts these hotfixes were verified with. Reports
which entries the upload sheet offers, so a run also records whether
PUBLIC_CAMERA_ENABLED was in effect.

Verified against v0.18.6: 32 KB single-read and 14.8 MB chunked, both
byte-identical.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-23 16:09:21 +02:00
c8795ddfac fix(upload): verify the copied bytes, so a purge mid-read cannot store a short file
Some checks failed
Audit / cargo audit (backend) (push) Failing after 8m43s
Audit / npm audit (frontend) (push) Successful in 42s
Checks / Backend — cargo test + clippy + fmt (push) Failing after 1m7s
Checks / Frontend — vitest + svelte-check (push) Failing after 5m35s
Checks / E2E — typecheck + lint (push) Failing after 32s
E2E / Playwright E2E (chromium + webkit) (push) Failing after 8m46s
Checks / Keepsake viewer — builds, self-contained, committed artifact in sync (push) Failing after 5m4s
E2E / Cross-UA smoke matrix (push) Failing after 4m55s
v0.18.5 copies a picked file's bytes into a Blob the origin owns, because WebKit
stores a `File` as a reference to an OS file that iOS later deletes. For a photo
that is one `arrayBuffer()` and effectively atomic. For a video it is a 4 MB-at-
a-time loop over as much as 500 MB, which takes many seconds -- and the purge
that motivated the whole function can land in the MIDDLE of it.

Once the OS file is gone the remaining slices read as nothing, `new Blob` builds
a short blob out of what it got, and nothing notices. That is strictly worse than
the bug it replaces: an empty body is refused with a 400, but a short body
uploads, passes the server's checks, and leaves the guest with a truncated video
that appears to have worked. Silent corruption beats loud failure only in the
sense that nobody finds out until the album is the only copy left.

So the copy is now checked against `file.size` and a short read is refused. It is
raised at PICK time, while the guest still has the file in front of them and can
select it again -- not at send time, minutes later, when the moment has passed.

`addToQueue` returns 'unreadable' rather than throwing, so the composer's
per-file loop survives it: one bad photo out of five must not abandon the other
four. The composer names the file in the toast instead of counting it the way it
counts 'full', because the guest has to locate and re-pick that specific one.

Scope: only files above the 4 MB chunk threshold can hit the partial case, so
this does not touch the photo path that v0.18.5 fixed. It closes the hole that
fix opened for videos.
2026-08-22 16:05:22 +00:00
0fba8defc2 fix(upload): iPhone sent an EMPTY body -- the queue stored a file reference, not bytes
Some checks failed
Audit / cargo audit (backend) (push) Failing after 9m1s
Audit / npm audit (frontend) (push) Successful in 1m10s
Checks / Backend — cargo test + clippy + fmt (push) Failing after 55s
Checks / Keepsake viewer — builds, self-contained, committed artifact in sync (push) Has been cancelled
Checks / E2E — typecheck + lint (push) Has been cancelled
E2E / Playwright E2E (chromium + webkit) (push) Has been cancelled
E2E / Cross-UA smoke matrix (push) Has been cancelled
Checks / Frontend — vitest + svelte-check (push) Has been cancelled
Measured at the reverse proxy during the live event, not inferred:

  status=201 content_length=6449056 dur=3.473s dev=Android
  status=400 content_length=0       dur=0.022s dev=iPhone
  status=400 content_length=0       dur=0.008s dev=iPhone
  status=400 content_length=0       dur=0.009s dev=iPhone

Content-Length ZERO, in 7-22 ms. Nothing was ever put on the wire, which is why
this was never a transport problem -- HTTP/3 was disabled first on the theory
that QUIC was truncating large POSTs, and it changed nothing.

`addToQueue` stored the picked `File` itself: `blob: file`. WebKit persists a
File in IndexedDB as a REFERENCE to the OS backing file rather than a copy, and
iOS deletes that file soon after the picker closes. What is left is a neutered
File: `.name` and `.size` still read correctly, so nothing downstream looks
wrong, and `xhr.send()` does NOT throw -- the note at the send site assumed it
would -- it sends an empty body. The server cannot parse a multipart with no
parts and answers 400 "Error parsing `multipart/form-data` request", which is
the same 400 a genuinely truncated upload produces.

That collision is what made v0.18.4 make things worse: it reclassified that 400
as retryable, so each dead photo re-sent an empty body five times. 79 failed
requests, 1 success, four guests with nothing uploaded.

TWO FIXES.

Bytes are copied at pick time, so IndexedDB owns data no OS purge can reach.
Chunked at 4 MB rather than one `arrayBuffer()`: this queue accepts videos up to
500 MB and pulling that into the JS heap would get the tab killed, trading a
failed upload for a crash. Each chunk becomes its own Blob, so peak heap is one
chunk and the browser's blob store holds the rest, spilling to disk as it sees
fit.

An unreadable blob is TERMINAL, never retried. `entry.blob.size` cannot detect
it -- a neutered File reports the original size -- so the check probes one real
byte before send. Items queued before this fix are still sitting in IndexedDB
holding dead references, and this is what stops them retrying forever. The
message asks the guest to re-pick the photo, because the photo is fine: it is
still in the camera roll, only the browser's copy is gone.

The batch keeps draining past one of these: other queued photos may be readable.
2026-08-22 15:54:06 +00:00
4 changed files with 308 additions and 3 deletions

View File

@@ -0,0 +1,150 @@
#!/usr/bin/env node
/**
* Drives a REAL upload through the composer's file picker and proves the bytes survived.
*
* Written during the v0.18.3v0.18.6 run of hotfixes, which were all one failure wearing
* different masks: the browser handing the queue something that was not the file. First an
* empty body (WebKit stores a picked `File` as a reference to an OS file iOS then deletes),
* then — once the bytes were copied — the risk of a SHORT one, because that copy is a
* multi-second chunked loop for a video and the same purge can land partway through it.
*
* Every check that missed those bugs shared one shortcut: it asserted the upload was
* ACCEPTED. A truncated file is accepted. It passes the magic-byte sniff, the size cap and the
* decode budget, is stored, gets a preview, and shows in the gallery — and is still not the
* guest's video. So this asserts the only thing that actually settles it: fetch the stored
* original back and compare its SHA-256 to the file on disk.
*
* Two paths matter and they are not the same code:
* * at or below MATERIALISE_CHUNK_BYTES (4 MB) the copy is a single `arrayBuffer()`
* * above it, a chunked loop — the one that can produce a short blob
* Pass at least one file of each, or the run proves half of what it claims.
*
* Requires the sim stack (e2e/docker-compose.sim.yml) on :3102.
*
* node e2e/loadtest/upload-integrity-check.mjs photo.jpg big-video.mp4
*/
import { chromium, devices } from '@playwright/test';
import { readFile, stat } from 'node:fs/promises';
import { createHash } from 'node:crypto';
import { basename } from 'node:path';
const BASE = process.env.SIM_BASE ?? 'http://localhost:3102';
const API = `${BASE}/api/v1`;
/** Mirrors MATERIALISE_CHUNK_BYTES in frontend/src/lib/upload-queue.ts. */
const CHUNK_BYTES = 4 * 1024 * 1024;
const sha = (buf) => createHash('sha256').update(buf).digest('hex');
const files = process.argv.slice(2);
if (!files.length) {
console.error('usage: upload-integrity-check.mjs <file> [file...] (include one >4 MB)');
process.exit(2);
}
async function main() {
const browser = await chromium.launch();
const context = await browser.newContext({ ...devices['Pixel 7'] });
// The guide is a modal over the composer; leaving it up means the picker is never reached.
await context.addInitScript(() => localStorage.setItem('eventsnap_guide_seen', '1'));
const page = await context.newPage();
const pageErrors = [];
page.on('pageerror', (e) => pageErrors.push(String(e).slice(0, 140)));
await page.goto(`${BASE}/join`, { waitUntil: 'load' });
await page.waitForTimeout(1200);
await page.fill('input[type=text]', `Integrity ${Date.now() % 100000}`);
await page.locator('button[type=submit]').first().click();
await page.waitForTimeout(4500);
for (const label of ['Weiter', 'Verstanden', 'Los geht']) {
const btn = page.getByRole('button', { name: new RegExp(label, 'i') });
if ((await btn.count()) && (await btn.first().isVisible())) {
await btn.first().click();
await page.waitForTimeout(700);
break;
}
}
const token = await page.evaluate(() => localStorage.getItem('eventsnap_jwt'));
// Report which upload entries the sheet offers, so a run also records whether
// PUBLIC_CAMERA_ENABLED was in effect — the composer path differs with it.
await page.locator('button[aria-label="Hochladen"]').last().click();
await page.waitForTimeout(900);
const sheet = await page.locator('body').innerText();
console.log(
`[sheet] Galerie=${sheet.includes('Foto oder Video wählen')} ` +
`Kamera=${sheet.includes('Jetzt aufnehmen')}\n`
);
// Close it again. The FAB TOGGLES, so leaving the sheet open here makes the loop's first
// "open the sheet" click close it instead — and the file chooser then never fires.
await page.getByRole('button', { name: /Abbrechen/i }).first().click();
await page.waitForTimeout(600);
const results = [];
for (const path of files) {
const name = basename(path);
const local = await readFile(path);
const size = (await stat(path)).size;
const caption = `integrity ${name} ${Date.now()}`;
const chunked = size > CHUNK_BYTES;
// Open the sheet fresh for every file — submitting returns to the feed, and the
// invariant this relies on is simply that the sheet is CLOSED at the top of each pass.
await page.locator('button[aria-label="Hochladen"]').last().click();
await page.waitForTimeout(900);
const chooser = page.waitForEvent('filechooser');
await page.getByText('Foto oder Video wählen').click();
(await chooser).setFiles(path);
await page.waitForTimeout(2500);
await page.locator('textarea').fill(caption);
await page.getByRole('button', { name: /^Hochladen$/ }).first().click();
// Generous: the queue retries, and a large file on a throttled box takes its time.
await page.waitForTimeout(Math.max(9000, (size / 1e6) * 900));
// Find it server-side. The feed is the guest's own view, so this also proves the photo
// is actually visible rather than merely stored.
const feed = await fetch(`${API}/feed?limit=50`, {
headers: { Authorization: `Bearer ${token}` },
}).then((r) => r.json());
const row = feed.uploads?.find((u) => u.caption === caption);
if (!row) {
results.push({ name, size, chunked, ok: false, why: 'never appeared in the feed' });
continue;
}
// The whole point: compare the bytes that came BACK, not the status that went out.
const served = Buffer.from(
await fetch(`${API}/upload/${row.id}/original`).then((r) => r.arrayBuffer())
);
const ok = served.length === size && sha(served) === sha(local);
results.push({
name,
size,
chunked,
ok,
why: ok ? '' : `stored ${served.length} of ${size} bytes / hash mismatch`,
});
}
await browser.close();
console.log('═'.repeat(66));
console.log('UPLOAD INTEGRITY');
console.log('═'.repeat(66));
for (const r of results) {
console.log(
` ${r.ok ? '✓' : '✗'} ${r.name.padEnd(24)} ${(r.size / 1e6).toFixed(1).padStart(6)} MB ` +
`${r.chunked ? 'chunked copy' : 'single read '} ${r.why}`
);
}
if (!results.some((r) => r.chunked))
console.log('\n ⚠ no file above 4 MB — the chunked copy path was NOT exercised');
if (pageErrors.length) console.log(`\n page errors: ${pageErrors[0]}`);
const failed = results.filter((r) => !r.ok).length;
console.log(`\n${failed ? `${failed} of ${results.length} corrupted` : `✓ all ${results.length} byte-identical`}`);
console.log('═'.repeat(66));
process.exit(failed ? 1 : 0);
}
main().catch((e) => {
console.error('integrity check failed:', e);
process.exit(1);
});

View File

@@ -65,6 +65,38 @@ describe('classifyUploadStatus', () => {
* cases are transcribed from real responses captured against the running backend, so they fail * cases are transcribed from real responses captured against the running backend, so they fail
* if that reasoning is ever reverted. * if that reasoning is ever reverted.
*/ */
/**
* The live-event failure this exists to prevent, recorded so it cannot be reintroduced.
*
* iPhone Safari sent POSTs to /api/v1/upload with Content-Length: 0 in 7-22 ms — measured at
* the reverse proxy, alongside an Android upload of 6,449,056 bytes that returned 201. Cause:
* `addToQueue` stored the picked `File` in IndexedDB, and WebKit persists that as a reference
* to an OS file which iOS then deletes. The File keeps its name and size and reads as nothing,
* and `xhr.send()` does not throw — it puts an empty body on the wire.
*
* Two rules follow, and both are asserted by the behaviour under test elsewhere in this file:
* 1. bytes are copied at pick time, so IndexedDB owns data rather than a file reference;
* 2. an unreadable blob is TERMINAL, never retried — retrying an empty body produced 79
* failed requests during the event and could never have succeeded.
*
* Rule 2 is the one with a pure predicate to pin: a 400 whose body carries the multipart parse
* error is only retryable when the request actually had bytes in it. `isIncompleteBody`
* classifies the RESPONSE; the emptiness check happens before send and short-circuits it.
*/
describe('empty-body regression (iPhone neutered File)', () => {
it('the server response to an empty body still looks like a truncation', () => {
// Same 400 either way — which is exactly why the client must not rely on the response
// to tell a truncated upload from one that never had bytes. The pre-send readability
// probe is what separates them.
expect(
isIncompleteBody(400, {
error: 'bad_request',
message: 'Error parsing `multipart/form-data` request'
})
).toBe(true);
});
});
describe('isIncompleteBody', () => { describe('isIncompleteBody', () => {
const parseError = 'Error parsing `multipart/form-data` request'; const parseError = 'Error parsing `multipart/form-data` request';

View File

@@ -628,6 +628,14 @@ class TerminalError extends Error {
*/ */
class NetworkError extends Error {} class NetworkError extends Error {}
/**
* The blob is in IndexedDB but its bytes are unreadable — iOS purged the OS file behind a
* stored `File`. Deliberately NOT a NetworkError: retrying cannot bring the bytes back, and
* treating it as transient is what produced an empty-POST retry storm during the event. The
* guest has to re-pick the photo, and the message says so.
*/
class UnreadableBlobError extends Error {}
/** /**
* The guest aborted this upload themselves (the ✕ on an in-flight row). A NetworkError * The guest aborted this upload themselves (the ✕ on an in-flight row). A NetworkError
* subclass because the transport outcome is identical — but it must NOT stop the batch or * subclass because the transport outcome is identical — but it must NOT stop the batch or
@@ -881,7 +889,49 @@ export async function releaseResolvedParks(state: {
/** Outcome of an `addToQueue` call, so the caller can tell the user when a file was NOT /** Outcome of an `addToQueue` call, so the caller can tell the user when a file was NOT
* actually queued (deduped, or the queue is full of un-evictable in-flight items). */ * actually queued (deduped, or the queue is full of un-evictable in-flight items). */
export type EnqueueResult = 'queued' | 'duplicate' | 'full'; export type EnqueueResult = 'queued' | 'duplicate' | 'full' | 'unreadable';
/** Chunk size for `materialise`. Bounds peak JS heap, not total copy size. */
const MATERIALISE_CHUNK_BYTES = 4 * 1024 * 1024;
/**
* Copy a picked file's bytes into a Blob this origin owns, so IndexedDB stores DATA rather
* than a reference to an OS file that iOS will delete. See the call site in `addToQueue` for
* why that reference is the bug.
*
* Chunked deliberately. `new Blob([await file.arrayBuffer()])` is one line and correct for a
* 3 MB photo, but it pulls the whole file into the JS heap — and this queue accepts videos up
* to 500 MB, where that would very likely get the tab killed by the OS. Trading a crash for a
* failed upload is not a fix. Reading a slice at a time and letting each chunk become its own
* Blob keeps peak heap at one chunk; the browser's blob store owns the accumulated parts and
* can spill them to disk, which is exactly where a half-gigabyte video should live.
*/
async function materialise(file: File): Promise<Blob> {
let out: Blob;
if (file.size <= MATERIALISE_CHUNK_BYTES) {
out = new Blob([await file.arrayBuffer()], { type: file.type });
} else {
const parts: Blob[] = [];
for (let offset = 0; offset < file.size; offset += MATERIALISE_CHUNK_BYTES) {
const slice = file.slice(offset, offset + MATERIALISE_CHUNK_BYTES);
parts.push(new Blob([await slice.arrayBuffer()]));
}
out = new Blob(parts, { type: file.type });
}
// Verify the copy. The purge this whole function exists to defeat can also land PARTWAY
// THROUGH the loop above: a 500 MB video is many seconds of reading, and once the OS file
// is gone the remaining slices read as nothing. `new Blob` is happy to build a short blob
// out of them, and short is far worse than absent — it uploads, the server stores it, and
// the guest gets a truncated video that looks like it worked. A read that returns fewer
// bytes than the file claims is never legitimate, so refuse it here, while the guest is
// still holding the phone and can pick the file again.
if (out.size !== file.size) {
throw new UnreadableBlobError(
'Diese Datei konnte nicht vollständig gelesen werden — bitte wähle sie noch einmal aus.'
);
}
return out;
}
export async function addToQueue( export async function addToQueue(
file: File, file: File,
@@ -929,6 +979,31 @@ export async function addToQueue(
// This id is also the server-side idempotency key (`client_upload_id`), so it is minted // This id is also the server-side idempotency key (`client_upload_id`), so it is minted
// exactly ONCE per file here and reused by every retry — see uploadItem. // exactly ONCE per file here and reused by every retry — see uploadItem.
const id = uuid(); const id = uuid();
// MATERIALISE THE BYTES. Do not store the `File` itself.
//
// WebKit persists a File in IndexedDB as a REFERENCE to the OS backing file rather than a
// copy of its contents. iOS purges that file soon after the picker closes, which leaves a
// "neutered File": `.name` and `.size` still read correctly, so nothing looks wrong, but
// the bytes are gone. WebKit then does NOT throw on `xhr.send()` — the note at the send
// site assumed it would — it puts the request on the wire with an EMPTY BODY, the server
// cannot parse a multipart with no parts, and the guest sees a 400.
//
// Measured on the live event rather than inferred: every failing iPhone upload reached
// Caddy with `Content-Length: 0` in 7-22 ms, while an Android upload in the same minute
// sent 6,449,056 bytes and got a 201.
//
// Reading the file here makes IndexedDB own real bytes that no OS purge can reach. It
// costs one full read at pick time, which is also the moment the file is guaranteed still
// readable — the picker has only just handed it over.
// A file the browser cannot fully read is not a queueable item. Returning a result rather
// than throwing keeps the composer's per-file loop intact: one bad photo out of five must
// not abandon the other four, which is what an exception here would do.
let blob: Blob;
try {
blob = await materialise(file);
} catch {
return 'unreadable';
}
const entry: QueueEntry = { const entry: QueueEntry = {
id, id,
userId, userId,
@@ -939,7 +1014,7 @@ export async function addToQueue(
caption, caption,
hashtags, hashtags,
status: 'pending', status: 'pending',
blob: file blob
}; };
await storePut(entry); await storePut(entry);
@@ -1112,6 +1187,11 @@ async function processQueue(): Promise<void> {
// NetworkError, which it extends.) // NetworkError, which it extends.)
continue; continue;
} }
if (e instanceof UnreadableBlobError) {
// This one photo is unrecoverable, but the others in the queue may be fine
// (a re-picked copy, or one taken after the fix). Keep draining.
continue;
}
if (e instanceof NetworkError) { if (e instanceof NetworkError) {
// Connectivity dropped mid-flight. If offline the item is back to 'pending' // Connectivity dropped mid-flight. If offline the item is back to 'pending'
// and the `online` listener resumes it; if the failure hit while nominally // and the `online` listener resumes it; if the failure hit while nominally
@@ -1162,7 +1242,26 @@ async function uploadItem(id: string): Promise<void> {
// and charging the guest's quota twice. Both 200 (deduped) and 201 (created) are // and charging the guest's quota twice. Both 200 (deduped) and 201 (created) are
// success; `classifyUploadStatus` already treats the whole 2xx range that way. // success; `classifyUploadStatus` already treats the whole 2xx range that way.
formData.append('client_upload_id', entry.id); formData.append('client_upload_id', entry.id);
formData.append('file', entry.blob, entry.fileName); // Never send a body we cannot read. `entry.blob.size` is NOT sufficient on WebKit: a
// neutered File keeps its metadata and reports the original size while reading as
// nothing. Only an actual read tells the truth, so probe one byte.
//
// This covers items queued BEFORE the materialise-on-pick fix above, which are still
// sitting in IndexedDB holding a dead File reference. Without it those retry until the
// budget is spent, every attempt an empty POST — 79 of them during the event.
const blob = entry.blob;
let readable = false;
try {
readable = (await blob.slice(0, 1).arrayBuffer()).byteLength > 0;
} catch {
readable = false;
}
if (!readable && entry.fileSize > 0) {
throw new UnreadableBlobError(
'Dieses Foto ist auf dem Gerät nicht mehr lesbar — bitte wähle es noch einmal aus.'
);
}
formData.append('file', blob, entry.fileName);
if (entry.caption) formData.append('caption', entry.caption); if (entry.caption) formData.append('caption', entry.caption);
if (entry.hashtags) formData.append('hashtags', entry.hashtags); if (entry.hashtags) formData.append('hashtags', entry.hashtags);
@@ -1480,6 +1579,20 @@ async function uploadItem(id: string): Promise<void> {
} }
throw e; throw e;
} }
if (e instanceof UnreadableBlobError) {
// The bytes are gone from the browser's storage (iOS purged the OS file behind a
// stored `File`). No retry can recover them, so this is terminal — but unlike a
// server rejection the photo itself is fine and still in the camera roll, so the
// message asks for a re-pick rather than reporting the file as refused. Dropping
// the dead blob also frees the queue slot for the re-picked copy.
delete entry.blob;
entry.status = 'blocked';
entry.error = e.message;
await storePut(entry);
updateItemStatus(id, 'blocked', e.message);
toast(`${entry.fileName}: ${e.message}`, 'error', 8000);
throw e;
}
if (e instanceof TerminalError) { if (e instanceof TerminalError) {
// Permanent rejection — drop the blob (we'll never resend it) and mark blocked // Permanent rejection — drop the blob (we'll never resend it) and mark blocked
// so the UI shows a clear reason and offers no retry. // so the UI shows a clear reason and offers no retry.

View File

@@ -163,6 +163,16 @@
} }
const result = await addToQueue(sf.file, caption, hashtagsString); const result = await addToQueue(sf.file, caption, hashtagsString);
if (result === 'full') full++; if (result === 'full') full++;
// The browser could not read this file's bytes (iOS purges the OS file behind a
// picked photo). Named per file rather than counted like `full`: the guest has to
// find and re-pick this specific one, so a bare number would not be actionable.
if (result === 'unreadable') {
toast(
`„${sf.file.name}“ konnte nicht gelesen werden. Bitte wähle das Foto noch einmal aus.`,
'error',
8000
);
}
} }
// Don't let a full queue silently swallow photos the user thinks were queued. // Don't let a full queue silently swallow photos the user thinks were queued.
if (full > 0) { if (full > 0) {