Skip to content

Commit f226ff1

Browse files
Close all nineteen discarded write and load results, and four more defects with them
The prerequisite row has called ignored return values the most serious class of defect in this tree for a while, and then listed two NUL truncations as what was left. That list was wrong. Counted rather than estimated, there were nineteen call sites that discarded the result of a write or a load, and this closes every one. WHY THEY GOT WORSE THIS MORNING BEFORE THEY GOT BETTER Twelve of the nineteen are `MessageData::Write`. Until today a failed Write produced a message with no body and returned true, so those twelve wrote a broken message and carried on. This morning's MIME work made Write refuse rather than commit a body-less message - which is correct, and which turned all twelve into silent no-ops: the header a rule set, the trace header on a local delivery, the vacation body, the virus notification simply do not get written and delivery continues as though they had. That is the shape worth naming. Fixing a layer properly can make the defects above it LESS visible rather than more, and the twelve were about to disappear into "works fine". They now go through `MessageData::WriteReported`, which reports what has not been written and names it. Four of the twelve are not modifications of an existing message but the FIRST write of one built from nothing - a rule-generated reply, a delivery-failure notification, an out-of-office reply, a virus notification. For those, discarding the result queued a message row whose file does not exist and left the delivery to trip over it. They now abort: no reply at all is better than a reply the recipient can never read, and each of those functions already returned early on other conditions. THE OTHER SEVEN, EACH HANDLED THE WAY ITS OWN SURROUNDINGS ALREADY HANDLE FAILURE `VirusScanner` had two, and they were the ones with teeth. The scanner makes two passes - the whole file, then each attachment separately, because a scanner that does not decode MIME will not see inside a base64 part. Both inputs to the second pass were taken on trust: a message that could not be parsed produced an EMPTY attachment list, so the loop ran over nothing and the function returned "no virus found"; and a failed temp-file write left `ScanFile_` scanning a file that was missing or empty, which comes back clean. Neither is unscanned mail - the whole file is scanned first - which is why these report (6001, 6002) rather than refusing the message. Refusing on a transient file lock would turn a degraded scan into rejected mail. `SpamAssassinClient` counted bytes it had not written, so a short write could satisfy the length check and hand back a truncated message to replace the original. It now aborts the response, which is exactly what the two other failure paths in that function already do. `MessageAttachmentStripper` rewrites a message from what it read, and read it without checking. Its outcome was accidentally safe rather than deliberately so - it depended on FindFirstPart returning nothing for an empty parse - so it now says what happened and leaves the file alone. `Attachment::SaveAs` is reached from COM, where a script has just been told the save worked. It is void, so a full disk looked exactly like success and the script moved on to the next attachment. Reported as 6003. `IMAPFetch` answered the client from an empty body when the load failed: a message that renders blank, with an OK, which the client then caches and stops asking for. Now reported. Be clear about what that does not fix - `ReportCriticalError_` says in its own comment that it reports "and then throws an exception", and it does not throw, so the blank message still goes out. That is written up rather than changed, because making it throw alters the failure semantics of every FETCH and wants its own test. `Logger`'s two are the one failure in the server that cannot be logged: LOG_APPLICATION comes straight back, and ErrorManager writes the error log through the same function, so on the failing disk that provokes it the report would re-enter what just failed, once per line. They go once per process to the debugger and the Windows event log instead. A mail server whose audit trail has silently stopped used to look exactly like a quiet one. FOUR MORE, FOUND WHILE DOING IT `Event::WaitFor` returned void - with a comment beside the discarded result reading "result will be false if there's a timeout". A caller could bound a wait and not be told which of the two happened, which is the entire point of bounding it. The predicate form also fixes a quieter bug: a bare `wait_for` returns on a spurious wakeup too, and the old code then cleared the flag and carried on as though the event had fired. That was the missing piece for `ExternalFetch::Start`, which ended in an UNBOUNDED wait on a work-queue thread, set only by `~TCPConnection`, with the POP3 client setting an idle timeout and no absolute ceiling. A remote server sending one byte before each expiry held that fetch thread indefinitely; enough accounts in that state and external fetching stops for every account on the server. The fix is not just a timeout: `Start` drops its reference to the connection before waiting, so timing out would return while the session is still live and let the next cycle collect the same mailbox concurrently - the duplicate-delivery shape half of today was spent removing. It keeps a weak reference and disconnects what it abandons, through `EnqueueDisconnect` so the shutdown is posted to the strand that owns the socket rather than called across threads. One hour, chosen as a backstop for the pathological case and not as a performance bound. `SqlLogDevice` sent `PRIMARY KEY CLUSTERED` to SQL CE, which does not accept it. Every constraint in the script SQL CE actually runs is NONCLUSTERED, and the one CLUSTERED form in it is stripped by `SQLScriptParser::PreprocessLine_` before SQL CE sees it - these statements do not go through that parser. It failed invisibly because the line carries `[IGNORE-ERRORS]`, so on the installer's DEFAULT backend the SQL log device has been running without a primary key on hm_log for as long as this has been there. `ScheduledTask::SetNextRunTime` computed "now + interval" after the task returned, so the real period was interval plus however long the task took and every pass drifted by its own duration. Nothing was ever skipped, so nothing ever looked wrong - the runs just wandered away from the times the administrator set. Now measured from the run that was due, with a guard so a task that overran its own interval resumes the cadence instead of running back-to-back to catch up. VERIFICATION Release build clean. Full suite green.
1 parent 0d1fd5f commit f226ff1

22 files changed

Lines changed: 307 additions & 46 deletions

Roadmap.md

Lines changed: 2 additions & 2 deletions
Large diffs are not rendered by default.

hmailserver/source/Server/Common/AntiSpam/SpamAssassin/SpamAssassinClient.cpp

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -179,9 +179,22 @@ namespace HM
179179
}
180180
}
181181

182-
// Append output to the file
182+
// Append output to the file.
183+
//
184+
// The result was discarded, and this file becomes the message: a failed or short
185+
// write left total_result_bytes_written_ counting bytes that are not on disk, so
186+
// the length check below could be satisfied by a file that is truncated, and
187+
// SpamAssassin's rewritten message would replace the original minus whatever did
188+
// not get written. Aborting keeps the original message, which is what the two
189+
// other failure paths in this function already do.
183190
size_t written_bytes = 0;
184-
result_->Write(pBuf, written_bytes);
191+
192+
if (!result_->Write(pBuf, written_bytes))
193+
{
194+
LOG_DEBUG("SA: the response could not be written to disk; keeping the original message.");
195+
AbortResponse_();
196+
return;
197+
}
185198

186199
total_result_bytes_written_ += written_bytes;
187200

hmailserver/source/Server/Common/AntiSpam/SpamTestSpamAssassin.cpp

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -141,7 +141,7 @@ namespace HM
141141
// The Return-Path header was added above to help SpamAssassin with its SPF checks.
142142
// We should remove it again to restore the headers to original state (except for any added by SA).
143143
pMessageData->DeleteField("Return-Path");
144-
pMessageData->Write(sFilename);
144+
pMessageData->WriteReported(sFilename, "The removal of the Return-Path header added for SpamAssassin's SPF checks");
145145
}
146146
else
147147
{

hmailserver/source/Server/Common/AntiVirus/VirusScanner.cpp

Lines changed: 35 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -215,9 +215,26 @@ namespace HM
215215
}
216216

217217

218-
// Read message, extract attachments,
218+
// Read message, extract attachments,
219219
std::shared_ptr<MimeBody> pMimeBody = std::shared_ptr<MimeBody>(new MimeBody);
220-
pMimeBody->LoadFromFile(sLongFilename);
220+
221+
// The result used to be discarded, and a message that could not be parsed then
222+
// produced an EMPTY attachment list - so this second pass silently scanned
223+
// nothing and the message was reported clean by it. The whole file has already
224+
// been scanned above, so this is not "delivered unscanned"; what is lost is the
225+
// per-attachment pass, which is the one that matters for a scanner that does not
226+
// decode MIME itself. Reported rather than made fatal: refusing the message on a
227+
// transient file lock would turn a degraded scan into rejected mail.
228+
const MimeLoadResult loadResult = pMimeBody->LoadFromFile(sLongFilename);
229+
230+
if (loadResult != MimeLoadResult::Loaded)
231+
{
232+
ErrorManager::Instance()->ReportError(ErrorManager::Medium, 6001, "VirusScanner::Scan",
233+
Formatter::Format("The message {0} could not be parsed for per-attachment virus scanning, so only the whole message file was scanned. Any attachment the scanner cannot decode by itself has therefore not been examined separately.",
234+
sLongFilename));
235+
236+
return false;
237+
}
221238

222239
std::list<std::shared_ptr<MimeBody> > oList;
223240
pMimeBody->GetAttachmentList(pMimeBody, oList);
@@ -230,7 +247,20 @@ namespace HM
230247

231248
// Create a temporary filename.
232249
sLongFilename.Format(_T("%s\\%s.tmp"), IniFileSettings::Instance()->GetTempDirectory().c_str(), GUIDCreator::GetGUID().c_str());
233-
pBody->WriteToFile(sLongFilename);
250+
251+
// Likewise discarded before this. A failed write leaves the temp file missing
252+
// or empty and ScanFile_ then scans that - and reports it clean, which is the
253+
// scanner agreeing that nothing is wrong with a file it never saw.
254+
if (!pBody->WriteToFile(sLongFilename))
255+
{
256+
ErrorManager::Instance()->ReportError(ErrorManager::Medium, 6002, "VirusScanner::Scan",
257+
Formatter::Format("An attachment of {0} could not be written to {1} for scanning, so it has not been examined. The rest of the message has been.",
258+
PersistentMessage::GetFileName(pMessage), sLongFilename));
259+
260+
FileUtilities::DeleteFile(sLongFilename);
261+
iter++;
262+
continue;
263+
}
234264

235265
VirusScanningResult result = ScanFile_(sLongFilename);
236266
if (result.GetVirusFound())
@@ -305,8 +335,8 @@ namespace HM
305335

306336
if (changes_made)
307337
{
308-
message_data->Write(file_name);
309-
338+
message_data->WriteReported(file_name, "The removal of virus-bearing attachments");
339+
310340
// Update the size of the message.
311341
message->SetSize(FileUtilities::FileSize(file_name));
312342
}

hmailserver/source/Server/Common/Application/Logger.cpp

Lines changed: 46 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -595,9 +595,14 @@ namespace HM
595595
break;
596596
}
597597

598+
// Both Write results below are checked through ReportWriteFailure_. They cannot be
599+
// reported the ordinary way: LOG_APPLICATION and ErrorManager both end up back
600+
// here, so a disk that has stopped accepting writes would recurse through the
601+
// thing that just failed. See ReportWriteFailure_.
598602
if (writeUnicode)
599603
{
600-
file->Write(sData);
604+
if (!file->Write(sData))
605+
ReportWriteFailure_(file->GetName());
601606
}
602607
else
603608
{
@@ -616,13 +621,52 @@ namespace HM
616621
sAnsiString = sData.Mid(0, max_log_line_len_ - 30) + " ... " + sData.Mid(iDataLenTmp - 25);
617622
// We keep 25 of end which includes crlf but need to account for middle ... too
618623

619-
file->Write(sAnsiString);
624+
if (!file->Write(sAnsiString))
625+
ReportWriteFailure_(file->GetName());
620626
}
621627

622628
if (!keepFileOpen)
623629
file->Close();
624630
}
625631

632+
void
633+
Logger::ReportWriteFailure_(const String &fileName)
634+
//---------------------------------------------------------------------------
635+
// DESCRIPTION:
636+
// A log line could not be written to disk.
637+
//
638+
// This is the one failure in the server that cannot be logged. LOG_APPLICATION
639+
// comes straight back here, and ErrorManager::ReportError writes the error log
640+
// through this same function - so on a full or failing disk, which is precisely
641+
// when this fires, the report would re-enter the thing that just failed and do
642+
// it once per line.
643+
//
644+
// So it goes to the debugger and the Windows event log, once per process. Once,
645+
// because the condition is not transient and per-line reporting would itself be
646+
// the flood; and to the event log because that is the one sink that is not this
647+
// one. The result used to be discarded entirely, which meant a mail server whose
648+
// audit trail had silently stopped looked exactly like a quiet one.
649+
//---------------------------------------------------------------------------
650+
{
651+
static boost::once_flag reported = BOOST_ONCE_INIT;
652+
653+
boost::call_once(reported, [&fileName]()
654+
{
655+
String message = Formatter::Format("hMailServer could not write to its log file {0}. Logging has stopped, or is incomplete, from this point. This is reported once per run and cannot be written to the log itself.", fileName);
656+
657+
OutputDebugString(message);
658+
659+
HANDLE eventSource = RegisterEventSource(NULL, _T("hMailServer"));
660+
661+
if (eventSource != NULL)
662+
{
663+
LPCTSTR strings[1] = { message.c_str() };
664+
ReportEvent(eventSource, EVENTLOG_ERROR_TYPE, 0, 0, NULL, 1, 0, strings, NULL);
665+
DeregisterEventSource(eventSource);
666+
}
667+
});
668+
}
669+
626670

627671
String
628672
Logger::GetCurrentTime()

hmailserver/source/Server/Common/Application/Logger.h

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -199,6 +199,10 @@ namespace HM
199199

200200
void LogLive_(String &sMessage);
201201
void WriteData_(const String &sData, LogType = Normal);
202+
203+
// A log line that could not be written cannot be reported through the log. Once
204+
// per process, to the debugger and the Windows event log. See the definition.
205+
void ReportWriteFailure_(const String &fileName);
202206

203207
String log_dir_;
204208
String GetCurrentTime();

hmailserver/source/Server/Common/Application/SqlLogDevice.cpp

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -801,7 +801,16 @@ namespace HM
801801
_T(") --- [IGNORE-ERRORS]"));
802802

803803
statements.push_back(
804-
_T("ALTER TABLE hm_log ADD CONSTRAINT hm_log_pk PRIMARY KEY CLUSTERED (logid) --- [IGNORE-ERRORS]"));
804+
// NONCLUSTERED, matching every constraint in CreateTablesMSSQL.sql - which is
805+
// the script SQL CE actually runs, per DatabaseSettings::GetDefaultScript.
806+
// This said CLUSTERED, which SQL CE does not accept. The one CLUSTERED form in
807+
// that script is a CREATE CLUSTERED INDEX, and SQLScriptParser::PreprocessLine_
808+
// strips the word out before SQL CE ever sees it; these statements do not go
809+
// through that parser, so it reached the provider verbatim and the statement
810+
// failed. It failed INVISIBLY, because the line carries [IGNORE-ERRORS] - so
811+
// on the installer's default backend the SQL log device has been running
812+
// without a primary key on hm_log for as long as this has been here.
813+
_T("ALTER TABLE hm_log ADD CONSTRAINT hm_log_pk PRIMARY KEY NONCLUSTERED (logid) --- [IGNORE-ERRORS]"));
805814

806815
statements.push_back(
807816
_T("CREATE INDEX idx_hm_log_logtime ON hm_log (logtime) --- [IGNORE-ERRORS]"));

hmailserver/source/Server/Common/BO/Attachment.cpp

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,16 @@ namespace HM
3030
void
3131
Attachment::SaveAs(const String &sSaveTo) const
3232
{
33-
attachment_->WriteToFile(sSaveTo);
33+
// Reached from COM (IInterfaceAttachment::SaveAs), where the caller is a script
34+
// or an administration tool that has just been told the save worked. The result
35+
// was discarded and the method is void, so a failed write - a full disk, a path
36+
// that cannot be created, a body that could not be read back - looked exactly
37+
// like a successful one and the script carried on to the next attachment.
38+
if (!attachment_->WriteToFile(sSaveTo))
39+
{
40+
ErrorManager::Instance()->ReportError(ErrorManager::Medium, 6003, "Attachment::SaveAs",
41+
Formatter::Format("The attachment could not be written to {0}. The caller has not been told, because this method cannot report it; the file is either missing or incomplete.", sSaveTo));
42+
}
3443
}
3544

3645
String

hmailserver/source/Server/Common/BO/MessageData.cpp

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -832,7 +832,29 @@ namespace HM
832832
return true;
833833
}
834834

835-
bool
835+
bool
836+
MessageData::WriteReported(const String &fileName, const String &description)
837+
//---------------------------------------------------------------------------
838+
// DESCRIPTION:
839+
// Write, reporting the failure rather than discarding it. See the header.
840+
//
841+
// LOG_APPLICATION and not ReportError: Write has already reported the cause at
842+
// High with the specific reason (6010 or 6011), and a second ERROR record for
843+
// the same event would say nothing new while doubling the chance of tripping a
844+
// fixture that asserts a clean error log. What is added here is the thing Write
845+
// cannot know - which piece of work has silently not been done.
846+
//---------------------------------------------------------------------------
847+
{
848+
if (Write(fileName))
849+
return true;
850+
851+
LOG_APPLICATION(Formatter::Format("{0} could not be written to {1}. The message has been left as it was and delivery has continued, so that change is not present in it.",
852+
description, fileName));
853+
854+
return false;
855+
}
856+
857+
bool
836858
MessageData::GetHasBodyType(const String &sBodyType)
837859
{
838860
std::shared_ptr<MimeBody> pPart = FindPart(sBodyType);
@@ -964,7 +986,7 @@ namespace HM
964986
String fileName = PersistentMessage::GetFileName(account, pMessage);
965987

966988
// Write it
967-
pMsgData->Write(fileName);
989+
pMsgData->WriteReported(fileName, "The self-test message");
968990

969991

970992
// Save it

hmailserver/source/Server/Common/BO/MessageData.h

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,18 @@ namespace HM
7171

7272
bool Write(const String &fileName);
7373

74+
// Write, and report it if it fails, naming what the caller was trying to write.
75+
//
76+
// Twelve call sites across rules, forwarding, vacation, virus notification, the
77+
// SpamAssassin pass and the attachment stripper discarded Write's result. That was
78+
// survivable while a failed Write still produced a file - it produced the WRONG
79+
// file, a message with no body, which is the defect Write now refuses to commit.
80+
// Refusing made those twelve quieter rather than correct: the header, the trace
81+
// line, the vacation body simply does not get written and delivery carries on as
82+
// though it had. None of them can do anything useful about the failure, so this
83+
// exists to make sure none of them can hide it either.
84+
bool WriteReported(const String &fileName, const String &description);
85+
7486
int GetSize() const;
7587

7688
std::shared_ptr<Attachments> GetAttachments();

0 commit comments

Comments
 (0)