Skip to content

Commit 52b770e

Browse files
committed
Receiver strict completion check + server diagnostic logs
Fix silent-drop paths in the receiver: a chunk failing with 404, exhausted retries, or persistent decrypt errors now flags the index as permanently failed. The completion check refuses to finish until every known chunk is either in the local store or on the failed list; gaps surface as status='incomplete' with the list of missing indices (no more silently assembling a truncated file). Add targeted server logs to diagnose future losses: - sessionChunkGet 404 -> WARNING with session/client/buffer state - addChunk rejected -> WARNING with data size - setChunkAsReceived -> INFO when sanitize removed chunks (with list) - removeReceiver sanitize -> INFO with removed indices - dropInitialChunksFreeze -> INFO with sanitized indices (was a count) Also parallel-fetch guard: re-entrant fetchAndDecryptChunk calls for the same index short-circuit via pendingFetches, avoiding duplicate work if new_chunk and start_init both request the same index.
1 parent 1f086d1 commit 52b770e

5 files changed

Lines changed: 91 additions & 5 deletions

File tree

src/transfersession.cpp

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -161,6 +161,10 @@ void TransferSession::removeReceiver(const std::string &publicId)
161161
m_buffer.removeOneFromExpectedConsumers(publicId, removedChunks);
162162
if (not removedChunks.empty())
163163
{
164+
std::string idxs;
165+
for (auto id : removedChunks) { if (!idxs.empty()) idxs += ','; idxs += std::to_string(id); }
166+
PLOG_INFO << "[sess=" << m_id << "] removeReceiver " << publicId
167+
<< " triggered sanitize of chunks [" << idxs << "]";
164168
Publisher<Event::TransferSession>::notifySubscribers(Event::TransferSession::chunksWasRemoved, removedChunks);
165169
}
166170

@@ -264,6 +268,10 @@ bool TransferSession::addChunk(const std::string &binaryData)
264268
return false;
265269
}
266270

271+
PLOG_DEBUG << "[sess=" << m_id << "] addChunk -> index=" << newIndex
272+
<< " size=" << binaryData.size()
273+
<< " bufferCount=" << m_buffer.chunkCount();
274+
267275
Event::Data::ChunkInfo info;
268276
info.index = newIndex;
269277
info.size = binaryData.size();
@@ -343,8 +351,18 @@ void TransferSession::setChunkAsReceived(size_t index, std::shared_ptr<Client> c
343351

344352
if (not removedChunks.empty())
345353
{
354+
std::string idxs;
355+
for (auto id : removedChunks) { if (!idxs.empty()) idxs += ','; idxs += std::to_string(id); }
356+
PLOG_INFO << "[sess=" << m_id << "] confirm chunk " << index
357+
<< " by " << client->publicId() << " -> sanitized [" << idxs
358+
<< "] bufferCount=" << newCount;
346359
Publisher<Event::TransferSession>::notifySubscribers(Event::TransferSession::chunksWasRemoved, removedChunks);
347360
}
361+
else
362+
{
363+
PLOG_DEBUG << "[sess=" << m_id << "] confirm chunk " << index
364+
<< " by " << client->publicId() << " (no removal, bufferCount=" << newCount << ")";
365+
}
348366

349367
const auto newAllowed = m_buffer.newChunkIsAllowed();
350368
if (oldAllowed != newAllowed)
@@ -403,7 +421,12 @@ void TransferSession::dropInitialChunksFreeze()
403421
return;
404422
}
405423

406-
PLOG_INFO << "Session " << m_id << ": initial freeze dropped, " << removedChunks.size() << " chunks freed";
424+
{
425+
std::string idxs;
426+
for (auto id : removedChunks) { if (!idxs.empty()) idxs += ','; idxs += std::to_string(id); }
427+
PLOG_INFO << "[sess=" << m_id << "] initial freeze dropped, " << removedChunks.size()
428+
<< " chunks sanitized: [" << idxs << "]";
429+
}
407430

408431
bool shouldRemove = false;
409432
{

src/webapi.cpp

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -691,12 +691,18 @@ void WebAPI::sessionChunkGet(const crow::request &req, crow::response &res)
691691
const auto chunk = session.first->getChunk(index, client);
692692
if (chunk == nullptr)
693693
{
694+
PLOG_WARNING << "[sess=" << sessionId << "] GET chunk " << index
695+
<< " -> 404 (not in buffer); client=" << client->publicId()
696+
<< " currentMaxChunkIndex=" << session.first->currentMaxChunkIndex()
697+
<< " someRemoved=" << session.first->someChunkWasRemoved();
694698
res.code = 404;
695699
res.body = "Chunk not found";
696700
res.end();
697701
return;
698702
}
699703

704+
PLOG_DEBUG << "[sess=" << sessionId << "] GET chunk " << index
705+
<< " -> 200 (" << chunk->size() << " bytes); client=" << client->publicId();
700706
res.code = 200;
701707
res.set_header("Content-Type", "application/octet-stream");
702708
res.body = std::string(chunk->begin(), chunk->end());
@@ -904,6 +910,9 @@ void WebAPI::wsOnMessage(crow::websocket::connection &conn, const std::string &d
904910
}
905911
if (not session.first->addChunk(data))
906912
{
913+
PLOG_WARNING << "[sess=" << joinedSessionId << "] addChunk rejected (buffer full or oversized);"
914+
<< " size=" << data.size()
915+
<< " currentMaxChunkIndex=" << session.first->currentMaxChunkIndex();
907916
conn.send_text( SerializableEvent::AddingChunkFailure{}.json() );
908917
}
909918
return;

web/src/components/SessionReceiver.svelte

Lines changed: 55 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,12 @@
2626
let uploadFinished = $state(sessionData?.state?.upload_finished || false);
2727
let errorMsg = $state('');
2828
29+
// Track chunks that are mid-flight (HTTP GET in progress) and chunks
30+
// that gave up after all retries. Used by the strict completion check
31+
// to avoid silently assembling a file with holes.
32+
let pendingFetches = $state(new Set());
33+
let failedChunks = $state(new Set());
34+
2935
// --- File System Access API (disk-based storage) ---
3036
const hasFSAccess = typeof globalThis.showSaveFilePicker === 'function';
3137
let fileHandle = $state(null);
@@ -173,10 +179,26 @@
173179
}
174180
}
175181
182+
function markFetchDone(index) {
183+
pendingFetches.delete(index);
184+
pendingFetches = new Set(pendingFetches);
185+
}
186+
187+
function markFetchFailed(index, reason) {
188+
console.warn(`[pip] chunk ${index} failed: ${reason}`);
189+
failedChunks.add(index);
190+
failedChunks = new Set(failedChunks);
191+
markFetchDone(index);
192+
}
193+
176194
async function fetchAndDecryptChunk(index, retries = 3) {
177195
// Wait for save file dialog before downloading
178196
if (writableReady) await writableReady;
179197
if (writable ? writtenChunks.has(index) : chunks.has(index)) return;
198+
if (pendingFetches.has(index)) return; // another call is already handling it
199+
200+
pendingFetches.add(index);
201+
pendingFetches = new Set(pendingFetches);
180202
181203
for (let attempt = 0; attempt < retries; attempt++) {
182204
try {
@@ -198,17 +220,28 @@
198220
sendAction('confirm_chunk', { index });
199221
}
200222
chunksConfirmed++;
223+
markFetchDone(index);
224+
return;
225+
}
226+
if (result.status === 404) {
227+
// Chunk removed on the server before we could fetch it.
228+
// No retry — keep as failure so the strict completion
229+
// check can surface the gap instead of a silent skip.
230+
markFetchFailed(index, `HTTP 404 (chunk gone from server)`);
201231
return;
202232
}
203-
if (result.status === 404) return; // chunk removed, skip
233+
console.warn(`[pip] chunk ${index} attempt ${attempt + 1} failed: HTTP ${result.status} ${result.error || ''}`);
204234
await new Promise(r => setTimeout(r, 1000 * (attempt + 1)));
205235
} catch (err) {
236+
console.warn(`[pip] chunk ${index} attempt ${attempt + 1} exception: ${err.message}`);
206237
if (attempt === retries - 1) {
207238
errorMsg = `Chunk ${index}: ${err.message}`;
208239
}
209240
await new Promise(r => setTimeout(r, 1000 * (attempt + 1)));
210241
}
211242
}
243+
244+
markFetchFailed(index, `exhausted ${retries} retries`);
212245
}
213246
214247
async function onNewChunk(msg) {
@@ -255,11 +288,29 @@
255288
256289
function checkIfDone() {
257290
if (completeHandled) return;
258-
const hasData = writable ? writtenChunks.size > 0 : chunks.size > 0;
259-
if (uploadFinished && hasData && noMorePendingChunks
260-
&& chunksAcknowledged >= chunksConfirmed) {
291+
if (!uploadFinished) return;
292+
if (pendingFetches.size > 0) return;
293+
if (chunksAcknowledged < chunksConfirmed) return;
294+
295+
const have = writable ? writtenChunks.size : chunks.size;
296+
const expected = highestKnownChunk;
297+
298+
if (expected === 0) return; // nothing uploaded yet
299+
if (have + failedChunks.size < expected) return; // still waiting for new_chunk events or fetches
300+
301+
if (failedChunks.size === 0 && have >= expected) {
261302
finishWithBlob();
303+
return;
262304
}
305+
306+
// All indices accounted for, but some permanently failed. Refuse to
307+
// silently assemble a file with holes — report the gap instead.
308+
completeHandled = true;
309+
const missing = [...failedChunks].sort((a, b) => a - b);
310+
console.error(`[pip] transfer incomplete, ${missing.length} chunk(s) missing:`, missing);
311+
errorMsg = `Incomplete: ${missing.length} chunk(s) missing`;
312+
if (writable) { writable.close().catch(() => {}); writable = null; }
313+
oncomplete?.({ status: 'incomplete', missing });
263314
}
264315
265316
// Track if we're still expecting chunks

web/src/components/TransferComplete.svelte

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131
case 'sender_is_gone': return tt('senderGone');
3232
case 'no_receivers': return tt('noReceiversEnd');
3333
case 'kicked': return tt('kicked');
34+
case 'incomplete': return tt('incomplete');
3435
default: return tt('error');
3536
}
3637
}

web/src/lib/i18n.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ const translations = {
4242
kicked: "You were removed from the session",
4343
autoDropFreeze: "Start transfer automatically when a receiver connects",
4444
autoDropFreezeHint: "Drops the initial wait as soon as the first chunk is picked up",
45+
incomplete: "Transfer incomplete — some chunks were lost",
4546
},
4647
ru: {
4748
sendFile: "Отправить файл",
@@ -86,6 +87,7 @@ const translations = {
8687
kicked: "Вы были удалены из сессии",
8788
autoDropFreeze: "Начать передачу автоматически, когда подключится получатель",
8889
autoDropFreezeHint: "Снимает стартовое ожидание, как только первый чанк будет получен",
90+
incomplete: "Передача не завершена — часть чанков потеряна",
8991
},
9092
};
9193

0 commit comments

Comments
 (0)